Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions adapters/vscode/PixelAgentsViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
GLOBAL_KEY_HOOKS_ENABLED,
GLOBAL_KEY_HOOKS_INFO_SHOWN,
GLOBAL_KEY_LAST_SEEN_VERSION,
GLOBAL_KEY_SHOW_SESSION_NAMES,
GLOBAL_KEY_SOUND_ENABLED,
GLOBAL_KEY_WATCH_ALL_SESSIONS,
LAYOUT_REVISION_KEY,
Expand Down Expand Up @@ -214,6 +215,8 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
this.adapter.setSetting(GLOBAL_KEY_LAST_SEEN_VERSION, message.version as string);
} else if (message.type === 'setAlwaysShowLabels') {
this.adapter.setSetting(GLOBAL_KEY_ALWAYS_SHOW_LABELS, message.enabled);
} else if (message.type === 'setShowSessionNames') {
this.adapter.setSetting(GLOBAL_KEY_SHOW_SESSION_NAMES, message.enabled);
} else if (message.type === 'setHooksEnabled') {
const enabled = message.enabled as boolean;
this.adapter.setSetting(GLOBAL_KEY_HOOKS_ENABLED, enabled);
Expand Down Expand Up @@ -350,6 +353,10 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
GLOBAL_KEY_ALWAYS_SHOW_LABELS,
false,
);
const showSessionNames = this.adapter.getSetting<boolean>(
GLOBAL_KEY_SHOW_SESSION_NAMES,
true,
);
this.runtime.watchAllSessions.current = watchAllSessions;
const hooksEnabled = this.adapter.getSetting<boolean>(GLOBAL_KEY_HOOKS_ENABLED, true);
const hooksInfoShown = this.adapter.getSetting<boolean>(GLOBAL_KEY_HOOKS_INFO_SHOWN, false);
Expand All @@ -361,6 +368,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
extensionVersion,
watchAllSessions,
alwaysShowLabels,
showSessionNames,
hooksEnabled,
hooksInfoShown,
externalAssetDirectories: config.externalAssetDirectories,
Expand Down
5 changes: 5 additions & 0 deletions adapters/vscode/agentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,13 +496,17 @@ export function sendExistingAgents(
// Include folderName and isExternal per agent
const folderNames: Record<number, string> = {};
const externalAgents: Record<number, boolean> = {};
const sessionNames: Record<number, string> = {};
for (const [id, agent] of agents) {
if (agent.folderName) {
folderNames[id] = agent.folderName;
}
if (agent.isExternal) {
externalAgents[id] = true;
}
if (agent.sessionName) {
sessionNames[id] = agent.sessionName;
}
}
console.log(
`[Pixel Agents] sendExistingAgents: agents=${JSON.stringify(agentIds)}, meta=${JSON.stringify(agentMeta)}`,
Expand All @@ -514,6 +518,7 @@ export function sendExistingAgents(
agentMeta,
folderNames,
externalAgents,
sessionNames,
});
// Note: sendCurrentAgentStatuses is called separately AFTER layoutLoaded
// so that agentStatus/agentToolStart messages arrive after characters are created.
Expand Down
1 change: 1 addition & 0 deletions adapters/vscode/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
export const GLOBAL_KEY_SOUND_ENABLED = 'pixel-agents.soundEnabled';
export const GLOBAL_KEY_LAST_SEEN_VERSION = 'pixel-agents.lastSeenVersion';
export const GLOBAL_KEY_ALWAYS_SHOW_LABELS = 'pixel-agents.alwaysShowLabels';
export const GLOBAL_KEY_SHOW_SESSION_NAMES = 'pixel-agents.showSessionNames';
export const GLOBAL_KEY_WATCH_ALL_SESSIONS = 'pixel-agents.watchAllSessions';
export const GLOBAL_KEY_HOOKS_ENABLED = 'pixel-agents.hooksEnabled';
export const GLOBAL_KEY_HOOKS_INFO_SHOWN = 'pixel-agents.hooksInfoShown';
Expand Down
39 changes: 39 additions & 0 deletions core/asyncapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ components:
# Agent Teams
- $ref: '#/components/schemas/AgentTeamInfo'
- $ref: '#/components/schemas/AgentTokenUsage'
# Session metadata
- $ref: '#/components/schemas/AgentSessionName'
# Layout
- $ref: '#/components/schemas/LayoutLoaded'
# Assets
Expand All @@ -125,6 +127,7 @@ components:
- $ref: '#/components/schemas/SetSoundEnabled'
- $ref: '#/components/schemas/SetLastSeenVersion'
- $ref: '#/components/schemas/SetAlwaysShowLabels'
- $ref: '#/components/schemas/SetShowSessionNames'
- $ref: '#/components/schemas/SetHooksEnabled'
- $ref: '#/components/schemas/SetHooksInfoShown'
- $ref: '#/components/schemas/SetWatchAllSessions'
Expand Down Expand Up @@ -226,6 +229,13 @@ components:
description: Map of agent ID (string) to external flag.
additionalProperties:
type: boolean
sessionNames:
type: object
description: |
Map of agent ID (string) to user-set session name (e.g. the title set
with Claude Code's /rename). Optional; absent for unnamed sessions.
additionalProperties:
type: string

AgentStatus:
description: Active vs waiting state for an agent (drives character animation).
Expand Down Expand Up @@ -407,6 +417,22 @@ components:
outputTokens:
type: integer

AgentSessionName:
description: |
User-set session name changed (e.g. the title set with Claude Code's
/rename, persisted as a custom-title transcript record). Clients show it
in the agent's label in place of the folder name.
type: object
additionalProperties: false
required: [type, id, name]
properties:
type:
const: agentSessionName
id:
type: integer
name:
type: string

LayoutLoaded:
description: |
Office layout (tiles, furniture, colors). `null` when no layout file or
Expand Down Expand Up @@ -529,6 +555,9 @@ components:
type: boolean
alwaysShowLabels:
type: boolean
showSessionNames:
type: boolean
description: Prefer user-set session names over folder names in labels.
hooksEnabled:
type: boolean
hooksInfoShown:
Expand Down Expand Up @@ -681,6 +710,16 @@ components:
enabled:
type: boolean

SetShowSessionNames:
type: object
additionalProperties: false
required: [type, enabled]
properties:
type:
const: setShowSessionNames
enabled:
type: boolean

SetHooksEnabled:
type: object
additionalProperties: false
Expand Down
15 changes: 15 additions & 0 deletions core/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ServerMessage =
| SubagentToolPermission
| AgentTeamInfo
| AgentTokenUsage
| AgentSessionName
| LayoutLoaded
| FurnitureAssetsLoaded
| CharacterSpritesLoaded
Expand All @@ -45,6 +46,7 @@ export type ClientMessage =
| SetSoundEnabled
| SetLastSeenVersion
| SetAlwaysShowLabels
| SetShowSessionNames
| SetHooksEnabled
| SetHooksInfoShown
| SetWatchAllSessions
Expand Down Expand Up @@ -84,6 +86,7 @@ export interface ExistingAgents {
agentMeta: Record<string, AgentSeatMeta>;
folderNames: Record<string, string>;
externalAgents: Record<string, boolean>;
sessionNames?: Record<string, string>;
}

export interface AgentSeatMeta {
Expand Down Expand Up @@ -175,6 +178,12 @@ export interface AgentTokenUsage {
outputTokens: number;
}

export interface AgentSessionName {
type: 'agentSessionName';
id: number;
name: string;
}

export interface LayoutLoaded {
type: 'layoutLoaded';
layout: Record<string, any> | null;
Expand Down Expand Up @@ -238,6 +247,7 @@ export interface SettingsLoaded {
extensionVersion: string;
watchAllSessions: boolean;
alwaysShowLabels: boolean;
showSessionNames?: boolean;
hooksEnabled: boolean;
hooksInfoShown: boolean;
externalAssetDirectories: string[];
Expand Down Expand Up @@ -314,6 +324,11 @@ export interface SetAlwaysShowLabels {
enabled: boolean;
}

export interface SetShowSessionNames {
type: 'setShowSessionNames';
enabled: boolean;
}

export interface SetHooksEnabled {
type: 'setHooksEnabled';
enabled: boolean;
Expand Down
2 changes: 2 additions & 0 deletions core/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface PersistedAgent {
isTeamLead?: boolean;
leadAgentId?: number;
teamUsesTmux?: boolean;
/** User-set session name from the transcript (e.g. Claude's /rename) */
sessionName?: string;
}

/** Agent seat assignment with visual identity */
Expand Down
148 changes: 148 additions & 0 deletions server/__tests__/transcriptParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeEach, describe, expect, it } from 'vitest';

import { AgentStateStore } from '../src/agentStateStore.js';
import {
processTranscriptLine,
readSessionNameFromTranscriptTail,
} from '../src/transcriptParser.js';
import type { AgentState } from '../src/types.js';

function createTestAgent(overrides: Partial<AgentState> = {}): AgentState {
return {
id: 1,
sessionId: 'sess-1',
terminalRef: undefined,
isExternal: false,
projectDir: '/test',
jsonlFile: '/test/session.jsonl',
fileOffset: 0,
lineBuffer: '',
activeToolIds: new Set(),
activeToolStatuses: new Map(),
activeToolNames: new Map(),
activeSubagentToolIds: new Map(),
activeSubagentToolNames: new Map(),
backgroundAgentToolIds: new Set(),
isWaiting: false,
permissionSent: false,
hadToolsInTurn: false,
lastDataAt: 0,
linesProcessed: 0,
seenUnknownRecordTypes: new Set(),
hookDelivered: false,
inputTokens: 0,
outputTokens: 0,
...overrides,
};
}

describe('transcriptParser: session name (custom-title records)', () => {
let store: AgentStateStore;
let broadcasts: Array<Record<string, unknown>>;
let waitingTimers: Map<number, ReturnType<typeof setTimeout>>;
let permissionTimers: Map<number, ReturnType<typeof setTimeout>>;

beforeEach(() => {
store = new AgentStateStore();
store.set(1, createTestAgent());
broadcasts = [];
store.on('broadcast', (msg) => broadcasts.push(msg as Record<string, unknown>));
waitingTimers = new Map();
permissionTimers = new Map();
});

function process(line: string): void {
processTranscriptLine(1, line, store, waitingTimers, permissionTimers);
}

it('sets sessionName and broadcasts agentSessionName on custom-title', () => {
process(JSON.stringify({ type: 'custom-title', customTitle: 'My Session' }));
expect(store.get(1)?.sessionName).toBe('My Session');
expect(broadcasts).toContainEqual({ type: 'agentSessionName', id: 1, name: 'My Session' });
});

it('does not re-broadcast an unchanged title', () => {
process(JSON.stringify({ type: 'custom-title', customTitle: 'Same' }));
process(JSON.stringify({ type: 'custom-title', customTitle: 'Same' }));
const nameMsgs = broadcasts.filter((m) => m.type === 'agentSessionName');
expect(nameMsgs).toHaveLength(1);
});

it('broadcasts again when the title changes (rename)', () => {
process(JSON.stringify({ type: 'custom-title', customTitle: 'First' }));
process(JSON.stringify({ type: 'custom-title', customTitle: 'Second' }));
expect(store.get(1)?.sessionName).toBe('Second');
const nameMsgs = broadcasts.filter((m) => m.type === 'agentSessionName');
expect(nameMsgs).toHaveLength(2);
});

it('ignores empty or non-string titles', () => {
process(JSON.stringify({ type: 'custom-title', customTitle: ' ' }));
process(JSON.stringify({ type: 'custom-title', customTitle: 42 }));
process(JSON.stringify({ type: 'custom-title' }));
expect(store.get(1)?.sessionName).toBeUndefined();
expect(broadcasts.filter((m) => m.type === 'agentSessionName')).toHaveLength(0);
});

it('trims surrounding whitespace', () => {
process(JSON.stringify({ type: 'custom-title', customTitle: ' Spaced Out ' }));
expect(store.get(1)?.sessionName).toBe('Spaced Out');
});

it('is a no-op for unknown agent ids', () => {
expect(() =>
processTranscriptLine(
99,
JSON.stringify({ type: 'custom-title', customTitle: 'X' }),
store,
waitingTimers,
permissionTimers,
),
).not.toThrow();
expect(broadcasts.filter((m) => m.type === 'agentSessionName')).toHaveLength(0);
});
});

describe('readSessionNameFromTranscriptTail', () => {
let dir: string;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxl-tail-test-'));
});

function write(lines: string[]): string {
const f = path.join(dir, 'session.jsonl');
fs.writeFileSync(f, lines.join('\n') + '\n');
return f;
}

it('returns the most recent custom-title in the file', () => {
const f = write([
JSON.stringify({ type: 'custom-title', customTitle: 'Old Name' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }),
JSON.stringify({ type: 'custom-title', customTitle: 'New Name' }),
]);
expect(readSessionNameFromTranscriptTail(f)).toBe('New Name');
});

it('returns undefined when no title exists', () => {
const f = write([JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })]);
expect(readSessionNameFromTranscriptTail(f)).toBeUndefined();
});

it('returns undefined for a missing file', () => {
expect(readSessionNameFromTranscriptTail(path.join(dir, 'nope.jsonl'))).toBeUndefined();
});

it('skips malformed lines and empty titles', () => {
const f = write([
JSON.stringify({ type: 'custom-title', customTitle: 'Good' }),
'{"type":"custom-title", broken json',
JSON.stringify({ type: 'custom-title', customTitle: ' ' }),
]);
expect(readSessionNameFromTranscriptTail(f)).toBe('Good');
});
});
1 change: 1 addition & 0 deletions server/src/agentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ export class AgentRuntime {
linesProcessed: 0,
seenUnknownRecordTypes: new Set(),
folderName: p.folderName,
sessionName: p.sessionName,
hookDelivered: false,
inputTokens: 0,
outputTokens: 0,
Expand Down
1 change: 1 addition & 0 deletions server/src/agentStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export class AgentStateStore {
isTeamLead: agent.isTeamLead,
leadAgentId: agent.leadAgentId,
teamUsesTmux: agent.teamUsesTmux,
sessionName: agent.sessionName,
});
}
this.adapter.saveAgents(persisted);
Expand Down
Loading