Skip to content
Draft
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
14 changes: 13 additions & 1 deletion packages/pi-session-deck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ When you are ready to return, use `o` or **↗ Open** to focus or reattach to th

When an agent is done, end it with `k` in the Pi TUI or **End session** in the Toolbelt.

Ghostty exact focus is supported on macOS with Ghostty 1.3 or newer when Ghostty AppleScript is enabled (`macos-applescript` is not `false`). macOS may ask for Automation permission for the terminal or Node process to control Ghostty; if the prompt appears during first startup and the session is not captured, grant permission and restart that Pi session.

## Installation

```shell
Expand Down Expand Up @@ -92,4 +94,14 @@ Session Deck observes current operational state, not conversation history.
- It does not persist prompts, transcript content, tool arguments, or tool output.
- Status chips contain sanitized visible text only.
- Tool and assistant errors are reduced to compact, safe summaries.
- JSON output and the Toolbelt view omit raw terminal metadata and tmux attach details.
- Public JSON and the Toolbelt view omit raw terminal metadata and tmux attach details.
- Ghostty focus stores only a private terminal UUID in the identity sidecar. Ghostty window/tab IDs, titles, cwd, and commands are not stored in public JSON, Toolbelt requests, or browser records.

## Terminal focus smoke checks

1. In Ghostty on macOS, verify AppleScript returns `{version, id}` for the focused terminal.
2. Plain Ghostty: start Pi, open `/session-deck`, select the session, press `o`, and confirm the existing Ghostty surface focuses.
3. Ghostty + attached tmux: start Pi inside an attached Ghostty tmux pane; `o` should focus the host surface. If that host is gone while tmux is still alive, `o` falls back to the existing tmux attach path.
4. Detached tmux from Session Deck (`w` / Toolbelt New): Open should attach through the existing tmux path and not focus an unrelated Ghostty window.
5. Plain iTerm2 and tmux+iTerm2 should keep their existing focus/attach behavior.
6. `/session-deck --json`, Toolbelt snapshots, and browser records should contain no Ghostty UUIDs or raw terminal metadata.
13 changes: 13 additions & 0 deletions packages/pi-session-deck/__tests__/session-deck/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,10 @@ describe('SessionDeckBrowser', () => {
sessionId: '$1',
windowName: 'editor',
paneId: '%12',
host: {
kind: 'ghostty',
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
},
attachCommand,
sessionTarget: 'prod:editor',
},
Expand Down Expand Up @@ -1405,13 +1409,16 @@ describe('SessionDeckBrowser', () => {
expect(publicRecord?.sessionId).toBe('public-session');
expect(publicRecord).not.toHaveProperty('terminal');
expect(publicRecord).not.toHaveProperty('socketPath');
expect(publicRecord).not.toHaveProperty('terminalId');
expect(publicRecord).not.toHaveProperty('host');
expect(publicRecord).not.toHaveProperty('paneId');
expect(publicRecord).not.toHaveProperty('attachCommand');
expect(publicRecord).not.toHaveProperty('sessionTarget');

const serialized = JSON.stringify(publicRecord ?? {});
expect(serialized).not.toContain(socketPath);
expect(serialized).not.toContain(attachCommand);
expect(serialized).not.toContain('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee');
});

it('keeps browser hint snapshots free of accidental raw terminal fields', async () => {
Expand All @@ -1423,6 +1430,7 @@ describe('SessionDeckBrowser', () => {
sessionName: 'prod',
sessionTarget: '$1',
paneId: '%12',
host: { kind: 'ghostty', terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' },
attachCommand: 'exec tmux attach-session -t prod',
},
terminalDisplay: {
Expand All @@ -1433,6 +1441,8 @@ describe('SessionDeckBrowser', () => {
},
socketPath: '/tmp/tmux/default',
paneId: '%12',
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
host: { kind: 'ghostty', terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' },
attachCommand: 'exec tmux attach-session -t prod',
sessionTarget: '$1',
} as SessionDeckRecord;
Expand All @@ -1444,9 +1454,12 @@ describe('SessionDeckBrowser', () => {
expect(view.records[0]).not.toHaveProperty('terminal');
expect(view.records[0]).not.toHaveProperty('terminalDisplay');
expect(view.records[0]).not.toHaveProperty('socketPath');
expect(view.records[0]).not.toHaveProperty('terminalId');
expect(view.records[0]).not.toHaveProperty('host');
expect(view.records[0]).not.toHaveProperty('paneId');
expect(view.records[0]).not.toHaveProperty('attachCommand');
expect(view.records[0]).not.toHaveProperty('sessionTarget');
expect(JSON.stringify(view.records[0])).not.toContain('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee');
});

it('warns when w is used outside an active named repo filter', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,40 @@ describe('pi-session-deck extension', () => {
);
});

it('collects terminal metadata before presence startup and gates focused Ghostty capture by UI', async () => {
const order: string[] = [];
const collectSessionTerminalMetadata = vi.fn(async () => {
order.push('terminal');
return undefined;
});
const ensurePresenceRuntimeStarted = vi.fn(async () => {
order.push('presence');
return {
runtime: {
runtimeId: 'runtime-1',
pid: 1234,
startedAt: '2026-06-12T12:00:00.000Z',
},
startup: { state: 'healthy' },
isRunning: vi.fn(() => true),
stop: vi.fn(),
};
});
vi.doMock('../../extensions/session-deck/identity/terminal-collect.js', () => ({
collectSessionTerminalMetadata,
}));
setupMocks(ensurePresenceRuntimeStarted);
const { handlers } = await installExtension();

order.length = 0;
await handlers.get('session_start')?.({ reason: 'startup' }, makeCtx({ hasUI: false }));

expect(order.slice(0, 2)).toEqual(['terminal', 'presence']);
expect(collectSessionTerminalMetadata).toHaveBeenCalledWith({
enableFocusedGhosttyCapture: false,
});
});

it('uses collected tmux terminal metadata for identity refresh when available', async () => {
const tmuxTerminal = {
kind: 'tmux' as const,
Expand All @@ -452,7 +486,9 @@ describe('pi-session-deck extension', () => {

const sessionManager = refreshIdentity.mock.calls[0]?.[1];
expect(sessionManager.getTerminal?.()).toEqual(tmuxTerminal);
expect(collectSessionTerminalMetadata).toHaveBeenCalledTimes(1);
expect(collectSessionTerminalMetadata).toHaveBeenCalledWith({
enableFocusedGhosttyCapture: true,
});
});

it('captures iTerm2 terminal metadata for identity refresh when ITERM_SESSION_ID is set', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ describe('session-deck joined command', () => {
sessionTarget: '$1',
windowName: 'editor',
paneId: '%12',
host: { kind: 'ghostty', terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' },
attachCommand: 'exec tmux attach-session -t prod',
},
terminalDisplay: {
Expand All @@ -433,6 +434,8 @@ describe('session-deck joined command', () => {
detail: 'tmux prod:editor %12',
openLabel: 'new iTerm2 tab attaches to tmux',
},
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
host: { kind: 'ghostty', terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' },
worktree: `${HOME}/project`,
} as SessionDeckRecord;

Expand All @@ -451,6 +454,9 @@ describe('session-deck joined command', () => {
expect(jsonMessage).not.toContain('sessionFile');
expect(jsonMessage).not.toContain('"terminal"');
expect(jsonMessage).not.toContain('terminalDisplay');
expect(jsonMessage).not.toContain('terminalId');
expect(jsonMessage).not.toContain('host');
expect(jsonMessage).not.toContain('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee');
expect(jsonMessage).not.toContain('socketPath');
expect(jsonMessage).not.toContain('paneId');
expect(jsonMessage).not.toContain('attachCommand');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,31 @@ describe('identity open shared exports', () => {
expect(commandOpenTerminalForRuntime).toBe(openTerminalForRuntime);
expect(commandOpenIterm2TerminalForRuntime).toBe(openIterm2TerminalForRuntime);
});

it('opens Ghostty sidecars through the same runtime-id path', async () => {
const execFile = vi.fn(async () => ({ stdout: 'requested\n', stderr: '' }));
const readFile = vi.fn(async () =>
JSON.stringify({
runtimeId: 'rt-ghostty',
terminal: {
kind: 'ghostty',
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
},
}),
);

const result = await openTerminalForRuntime('rt-ghostty', {
identityDirectory: '/tmp/session-deck/identity',
readFile,
platform: 'darwin',
execFile,
});

expect(result).toMatchObject({ ok: true, reason: 'requested' });
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.arrayContaining(['aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee']),
{ timeout: 3000 },
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,65 @@ describe('identity terminal metadata normalization', () => {
});
});

it('normalizes Ghostty terminal UUIDs and ignores non-identity fields', () => {
expect(
normalizeSessionTerminalMetadata({
kind: 'ghostty',
terminalId: ' AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE ',
version: '1.3.1',
windowId: 'window-1',
tabId: 'tab-1',
title: 'secret title',
cwd: '/tmp/private',
command: 'pi',
}),
).toEqual({
kind: 'ghostty',
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
});
});

it('normalizes a valid Ghostty tmux host and drops invalid hosts without dropping tmux', () => {
expect(
normalizeSessionTerminalMetadata({
kind: 'tmux',
socketPath: '/tmp/tmux/default',
sessionName: 'prod',
host: {
kind: 'ghostty',
terminalId: 'AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE',
captureMethod: 'frontmost-focused-terminal-v1',
},
}),
).toEqual({
kind: 'tmux',
socketPath: '/tmp/tmux/default',
sessionName: 'prod',
host: {
kind: 'ghostty',
terminalId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
},
});

expect(
normalizeSessionTerminalMetadata({
kind: 'tmux',
socketPath: '/tmp/tmux/default',
sessionName: 'prod',
host: { kind: 'ghostty', terminalId: 'not-a-uuid' },
}),
).toEqual({
kind: 'tmux',
socketPath: '/tmp/tmux/default',
sessionName: 'prod',
});
});

it.each([
['missing', undefined],
['non-object', 'w0t0p0'],
['wrong kind', { kind: 'terminal', sessionId: 'w0t0p0' }],
['invalid Ghostty UUID', { kind: 'ghostty', terminalId: 'not-a-uuid' }],
['empty sessionId', { kind: 'iterm2', sessionId: '' }],
['trimmed-empty sessionId', { kind: 'iterm2', sessionId: ' ' }],
['tmux without sessionName', { kind: 'tmux', socketPath: '/tmp/tmux/default' }],
Expand Down
Loading
Loading