From c553844fea4649c4a98dd6c466dcebbea17eabdd Mon Sep 17 00:00:00 2001 From: Rob Howley Date: Fri, 19 Jun 2026 01:05:28 -0400 Subject: [PATCH] feat(pi-session-deck): add setStatus mirror to capture footer statuses into chip files --- packages/pi-session-deck/README.md | 55 ++- .../session-deck/chips-mirror.test.ts | 367 +++++++++++++++ .../__tests__/session-deck/extension.test.ts | 445 ++++++------------ .../session-deck/chips/constants.ts | 1 + .../extensions/session-deck/chips/index.ts | 4 +- .../extensions/session-deck/chips/mirror.ts | 210 +++++++++ .../extensions/session-deck/chips/types.ts | 3 +- .../extensions/session-deck/index.ts | 11 + 8 files changed, 774 insertions(+), 322 deletions(-) create mode 100644 packages/pi-session-deck/__tests__/session-deck/chips-mirror.test.ts create mode 100644 packages/pi-session-deck/extensions/session-deck/chips/mirror.ts diff --git a/packages/pi-session-deck/README.md b/packages/pi-session-deck/README.md index 894d3d9..2fbfa9f 100644 --- a/packages/pi-session-deck/README.md +++ b/packages/pi-session-deck/README.md @@ -26,21 +26,50 @@ pi install npm:@robhowley/pi-session-deck - `/new` resets activity for the new sessionId while keeping the same runtimeId. - Compact activity states: `waiting`, `thinking`, `tool-running`, `error`, `unknown`. -## P4 chips — manual publishing only today +## P4 chips — automatic setStatus mirroring -`pi-session-deck` keeps the chip backend, but normal sessions do **not** auto-mirror `ctx.ui.setStatus()` output into chip files. +`pi-session-deck` mirrors `ctx.ui.setStatus()` output into chip JSON sidecars on every session. -Why: under current public Pi APIs, the only documented way to read extension statuses is through a custom footer callback: +### How it works -- `ctx.ui.setFooter((tui, theme, footerData) => ...)` -- `footerData.getExtensionStatuses(): ReadonlyMap` +`pi-session-deck` wraps `ctx.ui.setStatus` during `session_start` to capture each status call. The wrapper: -`ctx.ui.setFooter(...)` replaces the built-in Pi footer, so using it for "read-only" mirroring regresses core footer behavior. `pi-session-deck` therefore keeps only the explicit chip publishing backend until Pi exposes a passive observer. +1. Calls the original `setStatus` first (footer rendering is untouched). +2. Asynchronously writes the sanitized visible text to a chip file. +3. Clears the chip file on `setStatus(key, undefined)` or empty-after-sanitize text. +4. Dedupes repeated writes for the same source + text. -### What remains available today +This avoids using `ctx.ui.setFooter()` entirely — the native Pi footer is never replaced. -- Chip JSON schema, store paths, and atomic write/clear helpers remain in place. -- The optional low-level publisher helper is the safe current path for explicit chip writes. +### Captured fields + +| Field | Source | +|---|---| +| `source` | Status key (must pass slug validation) | +| `text` | Visible status text (ANSI/control stripped) | +| `updatedAt` | Mirror time (ISO 8601) | +| `runtimeId` | Presence runtime identity | +| `sessionId` | Current session (from `sessionManager.getSessionId()`) | + +Default fallback values: +- `chipId: 'default'` +- `scope: 'session'` +- `level: 'unknown'` + +### Mirroring rules + +- Writes or replaces `${source}.default.session.json` on add/change. +- Strips ANSI/control characters, normalizes whitespace, and trims before persistence. +- Treats empty-after-sanitize text as absent (clears the chip). +- Dedupes: repeated identical `source + sanitizedText` does not rewrite. +- Session shutdown clears all tracked mirrored chips. +- Repeated `session_start` does not double-wrap. + +### Known limits + +- If another extension calls `setStatus` during its own very early `session_start` before `pi-session-deck` installs the wrapper, that first value can be missed until the next refresh. +- Shortcut-created fresh UI contexts are not covered by the shared-context patch. +- This mirror captures footer-status text only; structured chip publishing remains the better contract for richer semantics. ### Current chip record shape @@ -66,15 +95,9 @@ interface SessionDeckChipRecord { ~/.pi/session-deck/chips/{runtimeId}/.{source}.{chipId}.{scope}.{uuid}.tmp ``` -### Limits - -- Until Pi exposes a passive observer, normal sessions do not mirror `ctx.ui.setStatus()` output automatically. -- A future safe observer would still only see key + visible text, not source-owned `level`, `ttlMs`, multiple chip IDs, or runtime scope. -- `/session-deck` does not consume chip files yet; this is backend groundwork only. - ## Optional low-level publisher helper -A manual publisher helper exists for custom pipelines and is the safe current P4 integration path: +A manual publisher helper exists for custom pipelines and richer package-owned data: ```ts import { diff --git a/packages/pi-session-deck/__tests__/session-deck/chips-mirror.test.ts b/packages/pi-session-deck/__tests__/session-deck/chips-mirror.test.ts new file mode 100644 index 0000000..411f2c1 --- /dev/null +++ b/packages/pi-session-deck/__tests__/session-deck/chips-mirror.test.ts @@ -0,0 +1,367 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSetStatusMirror } from '../../extensions/session-deck/chips/mirror.js'; +import { + getPresenceRuntimeIdentity, + resetPresenceRuntimeForTests, +} from '../../extensions/session-deck/presence/runtime.js'; + +const createdDirectories: string[] = []; + +async function createTestDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'pi-session-deck-mirror-')); + createdDirectories.push(dir); + return dir; +} + +afterEach(async () => { + await resetPresenceRuntimeForTests(); + await Promise.all( + createdDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +function makeUi(setStatus?: (key: string, text: string | undefined) => void) { + return { setStatus: setStatus ?? vi.fn() }; +} + +async function readChipFile(dir: string, runtimeId: string, source: string): Promise { + const filePath = join(dir, runtimeId, `${source}.default.session.json`); + return JSON.parse(await readFile(filePath, 'utf8')); +} + +describe('createSetStatusMirror', () => { + describe('install', () => { + it('wraps setStatus without calling setFooter', () => { + const mirror = createSetStatusMirror(); + const original = vi.fn(); + const ui = makeUi(original); + + mirror.reconfigure({ + runtimeId: 'runtime-1', + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + // Wrapper installed — original still works + ui.setStatus('test-key', 'hello'); + + expect(original).toHaveBeenCalledWith('test-key', 'hello'); + }); + + it('does not double-wrap on repeated install', () => { + const mirror = createSetStatusMirror(); + const original = vi.fn(); + const ui = makeUi(original); + + mirror.reconfigure({ + runtimeId: 'runtime-1', + getSessionId: () => 'session-1', + }); + mirror.install(ui); + mirror.install(ui); // second install should be noop + + ui.setStatus('test-key', 'hello'); + + expect(original).toHaveBeenCalledTimes(1); + }); + }); + + describe('mirror writes', () => { + it('writes a chip file for each setStatus call', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const diagnostics: string[] = []; + const mirror = createSetStatusMirror({ + directory: dir, + onDiagnostic: (code) => diagnostics.push(code), + }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-openrouter', 'connected'); + // Await internal async work + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toContain('pi-openrouter.default.session.json'); + }); + + const record = await readChipFile(dir, runtime.runtimeId, 'pi-openrouter'); + expect(record).toMatchObject({ + source: 'pi-openrouter', + text: 'connected', + scope: 'session', + chipId: 'default', + runtimeId: runtime.runtimeId, + sessionId: 'session-1', + }); + + expect(diagnostics).toEqual([]); + }); + + it('stores ANSI-colored text as plain visible text', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-test', '\x1b[32mhealthy\x1b[0m'); + + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toContain('pi-test.default.session.json'); + }); + + const record = await readChipFile(dir, runtime.runtimeId, 'pi-test'); + expect(record).toMatchObject({ + source: 'pi-test', + text: 'healthy', + }); + }); + + it('does not rewrite identical status text', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-dupe', 'same text'); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(1); + }); + + // Read the file to get the updatedAt before second call + const before = await readChipFile(dir, runtime.runtimeId, 'pi-dupe') as Record; + const beforeUpdatedAt = before['updatedAt'] as string; + + // Wait a tick, then set the same text again + await new Promise((r) => setTimeout(r, 100)); + ui.setStatus('pi-dupe', 'same text'); + + // Let any async work settle + await new Promise((r) => setTimeout(r, 100)); + + // File should still have the original updatedAt (not rewritten) + const after = await readChipFile(dir, runtime.runtimeId, 'pi-dupe') as Record; + expect(after['updatedAt']).toBe(beforeUpdatedAt); + }); + + it('writes a new file when text changes for same source', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-change', 'first'); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(1); + }); + + ui.setStatus('pi-change', 'second'); + await vi.waitFor(async () => { + const record = await readChipFile(dir, runtime.runtimeId, 'pi-change'); + expect(record).toMatchObject({ text: 'second' }); + }); + }); + }); + + describe('mirror clears', () => { + it('clears mirrored chip when setStatus is called with undefined', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-clear', 'present'); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(1); + }); + + ui.setStatus('pi-clear', undefined); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(0); + }); + }); + + it('clears mirrored chip when text is empty after sanitize', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('pi-empty', 'present'); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(1); + }); + + ui.setStatus('pi-empty', '\x1b[0m '); + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(0); + }); + }); + + it('does not clear unknown source (no error)', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const diagnostics: string[] = []; + const mirror = createSetStatusMirror({ + directory: dir, + onDiagnostic: (code) => diagnostics.push(code), + }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + // Clear a source that was never written — should be silent + ui.setStatus('never-written', undefined); + + // Let async work settle + await new Promise((r) => setTimeout(r, 100)); + + expect(diagnostics).toEqual([]); + }); + }); + + describe('clearTracked', () => { + it('clears all tracked mirrored entries', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('source-a', 'text a'); + ui.setStatus('source-b', 'text b'); + + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(2); + }); + + await mirror.clearTracked(); + + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toHaveLength(0); + }); + }); + + describe('first class behavior', () => { + it('does not mirror without reconfigure (no runtime context)', async () => { + const dir = await createTestDir(); + const diagnostics: string[] = []; + const mirror = createSetStatusMirror({ + directory: dir, + onDiagnostic: (code) => diagnostics.push(code), + }); + const ui = makeUi(); + + // Install without reconfigure + mirror.install(ui); + + // Should still call original + const original = vi.fn(); + const ui2 = makeUi(original); + mirror.install(ui2); + ui2.setStatus('test', 'hello'); + + expect(original).toHaveBeenCalledWith('test', 'hello'); + }); + + it('rejects invalid source slugs silently', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const diagnostics: string[] = []; + const mirror = createSetStatusMirror({ + directory: dir, + onDiagnostic: (code) => diagnostics.push(code), + }); + const ui = makeUi(); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + ui.setStatus('INVALID/SLUG', 'text'); + + await vi.waitFor(() => { + expect(diagnostics).toContain('chip_source_invalid'); + }); + }); + + it('mirrors session-deck own status (wrapper installed before setStatus call)', async () => { + const dir = await createTestDir(); + const runtime = getPresenceRuntimeIdentity(); + const mirror = createSetStatusMirror({ directory: dir }); + const original = vi.fn(); + const ui = makeUi(original); + + mirror.reconfigure({ + runtimeId: runtime.runtimeId, + getSessionId: () => 'session-1', + }); + mirror.install(ui); + + // Simulate session-deck setting its own status after wrapper installation + ui.setStatus('session-deck', 'healthy'); + + await vi.waitFor(async () => { + const files = await readdir(join(dir, runtime.runtimeId)); + expect(files).toContain('session-deck.default.session.json'); + }); + }); + }); +}); diff --git a/packages/pi-session-deck/__tests__/session-deck/extension.test.ts b/packages/pi-session-deck/__tests__/session-deck/extension.test.ts index f6ca51d..48a228a 100644 --- a/packages/pi-session-deck/__tests__/session-deck/extension.test.ts +++ b/packages/pi-session-deck/__tests__/session-deck/extension.test.ts @@ -7,63 +7,108 @@ afterEach(() => { type RegisteredHandler = (event: any, ctx: any) => Promise; -describe('pi-session-deck extension', () => { - it('registers activity hooks, preserves footer ownership, and refreshes identity/activity on repeated session_start events', async () => { - const ensurePresenceRuntimeStarted = vi.fn().mockResolvedValue({ +const MOCK_STATUS_MIRROR = { + reconfigure: vi.fn(), + install: vi.fn(), + clearTracked: vi.fn().mockResolvedValue(undefined), +}; + +function setupMocks(presenceMock?: unknown, identityMock?: unknown, activityMock?: unknown) { + const ensurePresenceRuntimeStarted = + presenceMock ?? + vi.fn().mockResolvedValue({ runtime: { runtimeId: 'runtime-1', pid: 1234, startedAt: '2026-06-12T12:00:00.000Z', }, - startup: { - state: 'healthy', - }, + startup: { state: 'healthy' }, isRunning: vi.fn(() => true), stop: vi.fn(), }); - const refreshIdentity = vi.fn().mockResolvedValue(undefined); - const refreshActivity = vi.fn().mockResolvedValue(undefined); - const registerSessionDeckCommand = vi.fn(); - vi.doMock('../../extensions/session-deck/presence/runtime.js', () => ({ - ensurePresenceRuntimeStarted, - })); - vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ - ensureIdentityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshIdentity, - getIdentity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - stopIdentityRuntime: vi.fn().mockResolvedValue(undefined), - })); - vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ - ensureActivityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshActivity, - recordMessageEnd: vi.fn().mockResolvedValue(undefined), - recordTurnStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionEnd: vi.fn().mockResolvedValue(undefined), - recordTurnEnd: vi.fn().mockResolvedValue(undefined), - getActivity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - })); - vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ - registerSessionDeckCommand, - })); + const refreshIdentity = vi.fn().mockResolvedValue(undefined); + const refreshActivity = vi.fn().mockResolvedValue(undefined); - const { default: install } = await import('../../extensions/session-deck/index.js'); - const handlers = new Map(); - const pi = { - on: vi.fn((event: string, handler: RegisteredHandler) => { - handlers.set(event, handler); - }), - }; + const identityRuntime = + identityMock ?? + vi.fn().mockResolvedValue({ + refreshIdentity, + getIdentity: vi.fn().mockReturnValue(null), + isRunning: vi.fn(() => true), + }); + + const activityRuntime = + activityMock ?? + vi.fn().mockResolvedValue({ + refreshActivity, + recordMessageEnd: vi.fn().mockResolvedValue(undefined), + recordTurnStart: vi.fn().mockResolvedValue(undefined), + recordToolExecutionStart: vi.fn().mockResolvedValue(undefined), + recordToolExecutionEnd: vi.fn().mockResolvedValue(undefined), + recordTurnEnd: vi.fn().mockResolvedValue(undefined), + getActivity: vi.fn().mockReturnValue(null), + isRunning: vi.fn(() => true), + }); - await install(pi as never); + const stopIdentityRuntime = vi.fn().mockResolvedValue(undefined); + + vi.doMock('../../extensions/session-deck/presence/runtime.js', () => ({ + ensurePresenceRuntimeStarted, + })); + vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ + ensureIdentityRuntimeStarted: identityRuntime, + stopIdentityRuntime, + })); + vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ + ensureActivityRuntimeStarted: activityRuntime, + })); + vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ + registerSessionDeckCommand: vi.fn(), + })); + vi.doMock('../../extensions/session-deck/chips/mirror.js', () => ({ + createSetStatusMirror: vi.fn(() => MOCK_STATUS_MIRROR), + })); + + return { ensurePresenceRuntimeStarted, refreshIdentity, refreshActivity, stopIdentityRuntime }; +} + +async function installExtension() { + const { default: install } = await import('../../extensions/session-deck/index.js'); + const handlers = new Map(); + const pi = { + on: vi.fn((event: string, handler: RegisteredHandler) => { + handlers.set(event, handler); + }), + }; + await install(pi as never); + return { handlers, pi }; +} + +function makeCtx(overrides: Record = {}) { + return { + mode: 'tui', + cwd: '/repo', + model: { id: 'gpt-5', provider: 'openai' }, + getContextUsage: () => ({ percent: 12.5, contextWindow: 200_000 }), + sessionManager: { + getSessionId: () => 'session-1', + getSessionFile: () => '/tmp/session-1.md', + getEntries: () => [], + getSessionName: () => 'Focused session', + getCwd: () => '/repo', + }, + ui: { setStatus: vi.fn(), setFooter: vi.fn() }, + ...overrides, + }; +} - expect(registerSessionDeckCommand).toHaveBeenCalledWith(pi); - expect(Array.from(handlers.keys())).toEqual([ +describe('pi-session-deck extension', () => { + it('registers all hooks and starts presence runtime', async () => { + setupMocks(); + const { pi } = await installExtension(); + + expect(vi.mocked(pi.on).mock.calls.map((c) => c[0])).toEqual([ 'session_start', 'message_end', 'turn_start', @@ -72,57 +117,67 @@ describe('pi-session-deck extension', () => { 'turn_end', 'session_shutdown', ]); - expect(ensurePresenceRuntimeStarted).toHaveBeenCalledTimes(1); - - const ctx = { - mode: 'tui', - cwd: '/repo', - model: { - id: 'gpt-5', - provider: 'openai', - }, - getContextUsage: () => ({ percent: 12.5, contextWindow: 200_000 }), - sessionManager: { - getSessionId: () => 'session-1', - getSessionFile: () => '/tmp/session-1.md', - getEntries: () => [], - getSessionName: () => 'Focused session', - getCwd: () => '/repo', - }, - ui: { - setStatus: vi.fn(), - setFooter: vi.fn(), - }, - }; + }); + + it('installs setStatus mirror, refreshes identity/activity on session_start, does not touch setFooter', async () => { + const { refreshIdentity, refreshActivity } = setupMocks(); + const { handlers } = await installExtension(); + + const ctx = makeCtx(); await handlers.get('session_start')?.({ reason: 'startup' }, ctx); await handlers.get('session_start')?.({ reason: 'new' }, ctx); - expect(ensurePresenceRuntimeStarted).toHaveBeenCalledTimes(3); + expect(MOCK_STATUS_MIRROR.install).toHaveBeenCalledWith(ctx.ui); + expect(MOCK_STATUS_MIRROR.reconfigure).toHaveBeenCalledWith({ + runtimeId: 'runtime-1', + getSessionId: expect.any(Function), + }); + expect(refreshIdentity).toHaveBeenNthCalledWith(1, 'startup', expect.any(Object)); expect(refreshIdentity).toHaveBeenNthCalledWith(2, 'new', expect.any(Object)); - expect(refreshIdentity.mock.calls[0]?.[1]?.getSessionName?.()).toBe('Focused session'); - expect(refreshIdentity.mock.calls[0]?.[1]?.getCwd?.()).toBe('/repo'); expect(refreshActivity).toHaveBeenNthCalledWith(1, 'startup', expect.any(Object)); expect(refreshActivity).toHaveBeenNthCalledWith(2, 'new', expect.any(Object)); - expect(ctx.ui.setFooter).not.toHaveBeenCalled(); - expect(vi.mocked(ctx.ui.setStatus)).toHaveBeenNthCalledWith(1, 'session-deck', undefined); - expect(vi.mocked(ctx.ui.setStatus)).toHaveBeenNthCalledWith(2, 'session-deck', undefined); + + expect(vi.mocked(ctx.ui.setFooter)).not.toHaveBeenCalled(); }); - it('forwards runtime events into the activity runtime', async () => { - const ensurePresenceRuntimeStarted = vi.fn().mockResolvedValue({ - runtime: { - runtimeId: 'runtime-1', - pid: 1234, - startedAt: '2026-06-12T12:00:00.000Z', - }, + it('clears tracked entries on session_shutdown', async () => { + setupMocks(); + const { handlers } = await installExtension(); + + MOCK_STATUS_MIRROR.clearTracked.mockClear(); + await handlers.get('session_shutdown')?.({}, {}); + + expect(MOCK_STATUS_MIRROR.clearTracked).toHaveBeenCalledTimes(1); + }); + + it('surfaces degraded startup state through session-deck status', async () => { + setupMocks(vi.fn().mockResolvedValue({ + runtime: { runtimeId: 'runtime-1', pid: 1234, startedAt: '2026-06-12T12:00:00.000Z' }, startup: { - state: 'healthy', + state: 'degraded', + diagnostic: { + code: 'write_error', + message: 'Failed to write presence record: permission denied', + filePath: '/tmp/session-deck/presence', + }, }, isRunning: vi.fn(() => true), stop: vi.fn(), - }); + })); + const { handlers } = await installExtension(); + + const ctx = makeCtx(); + await handlers.get('session_start')?.({ reason: 'startup' }, ctx); + + expect(vi.mocked(ctx.ui.setStatus)).toHaveBeenCalledWith( + 'session-deck', + 'session-deck degraded: Failed to write presence record: permission denied', + ); + }); + + it('forwards runtime events into the activity runtime', async () => { const activityRuntime = { refreshActivity: vi.fn().mockResolvedValue(undefined), recordMessageEnd: vi.fn().mockResolvedValue(undefined), @@ -134,33 +189,8 @@ describe('pi-session-deck extension', () => { isRunning: vi.fn(() => true), }; - vi.doMock('../../extensions/session-deck/presence/runtime.js', () => ({ - ensurePresenceRuntimeStarted, - })); - vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ - ensureIdentityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshIdentity: vi.fn().mockResolvedValue(undefined), - getIdentity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - stopIdentityRuntime: vi.fn().mockResolvedValue(undefined), - })); - vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ - ensureActivityRuntimeStarted: vi.fn().mockResolvedValue(activityRuntime), - })); - vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ - registerSessionDeckCommand: vi.fn(), - })); - - const { default: install } = await import('../../extensions/session-deck/index.js'); - const handlers = new Map(); - const pi = { - on: vi.fn((event: string, handler: RegisteredHandler) => { - handlers.set(event, handler); - }), - }; - - await install(pi as never); + setupMocks(undefined, undefined, vi.fn().mockResolvedValue(activityRuntime)); + const { handlers } = await installExtension(); await handlers.get('message_end')?.( { message: { role: 'assistant', stopReason: 'error', errorMessage: 'boom' } }, @@ -192,202 +222,13 @@ describe('pi-session-deck extension', () => { expect(activityRuntime.recordTurnEnd).toHaveBeenCalledTimes(1); }); - it('stops the identity runtime on session_shutdown', async () => { - const ensurePresenceRuntimeStarted = vi.fn().mockResolvedValue({ - runtime: { - runtimeId: 'runtime-1', - pid: 1234, - startedAt: '2026-06-12T12:00:00.000Z', - }, - startup: { - state: 'healthy', - }, - isRunning: vi.fn(() => true), - stop: vi.fn(), - }); - const stopIdentityRuntime = vi.fn().mockResolvedValue(undefined); - - vi.doMock('../../extensions/session-deck/presence/runtime.js', () => ({ - ensurePresenceRuntimeStarted, - })); - vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ - ensureIdentityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshIdentity: vi.fn().mockResolvedValue(undefined), - getIdentity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - stopIdentityRuntime, - })); - vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ - ensureActivityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshActivity: vi.fn().mockResolvedValue(undefined), - recordMessageEnd: vi.fn().mockResolvedValue(undefined), - recordTurnStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionEnd: vi.fn().mockResolvedValue(undefined), - recordTurnEnd: vi.fn().mockResolvedValue(undefined), - getActivity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - })); - vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ - registerSessionDeckCommand: vi.fn(), - })); - - const { default: install } = await import('../../extensions/session-deck/index.js'); - const handlers = new Map(); - const pi = { - on: vi.fn((event: string, handler: RegisteredHandler) => { - handlers.set(event, handler); - }), - }; - - await install(pi as never); - await handlers.get('session_shutdown')?.({}, {}); - - expect(stopIdentityRuntime).toHaveBeenCalledTimes(1); - }); - - it('does not touch footer APIs during startup even when they are available', async () => { - const ensurePresenceRuntimeStarted = vi.fn().mockResolvedValue({ - 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/presence/runtime.js', () => ({ - ensurePresenceRuntimeStarted, - })); - vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ - ensureIdentityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshIdentity: vi.fn().mockResolvedValue(undefined), - getIdentity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - stopIdentityRuntime: vi.fn().mockResolvedValue(undefined), - })); - vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ - ensureActivityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshActivity: vi.fn().mockResolvedValue(undefined), - recordMessageEnd: vi.fn().mockResolvedValue(undefined), - recordTurnStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionEnd: vi.fn().mockResolvedValue(undefined), - recordTurnEnd: vi.fn().mockResolvedValue(undefined), - getActivity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - })); - vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ - registerSessionDeckCommand: vi.fn(), - })); - - const { default: install } = await import('../../extensions/session-deck/index.js'); - const handlers = new Map(); - const pi = { - on: vi.fn((event: string, handler: RegisteredHandler) => { - handlers.set(event, handler); - }), - }; - - await install(pi as never); - - const ctx = { - mode: 'tui', - sessionManager: { - getSessionId: () => 'session-1', - getSessionFile: () => '/tmp/session-1.md', - }, - ui: { - setStatus: vi.fn(), - setFooter: vi.fn(), - }, - }; + it('session_deck own status is set on session_start', async () => { + setupMocks(); + const { handlers } = await installExtension(); + const ctx = makeCtx(); await handlers.get('session_start')?.({ reason: 'startup' }, ctx); - expect(vi.mocked(ctx.ui.setFooter)).not.toHaveBeenCalled(); expect(vi.mocked(ctx.ui.setStatus)).toHaveBeenCalledWith('session-deck', undefined); }); - - it('surfaces degraded startup state through session-deck status', async () => { - const ensurePresenceRuntimeStarted = vi.fn().mockResolvedValue({ - runtime: { - runtimeId: 'runtime-1', - pid: 1234, - startedAt: '2026-06-12T12:00:00.000Z', - }, - startup: { - state: 'degraded', - diagnostic: { - code: 'write_error', - message: 'Failed to write presence record: permission denied', - filePath: '/tmp/session-deck/presence', - }, - }, - isRunning: vi.fn(() => true), - stop: vi.fn(), - }); - - vi.doMock('../../extensions/session-deck/presence/runtime.js', () => ({ - ensurePresenceRuntimeStarted, - })); - vi.doMock('../../extensions/session-deck/identity/runtime.js', () => ({ - ensureIdentityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshIdentity: vi.fn().mockResolvedValue(undefined), - getIdentity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - stopIdentityRuntime: vi.fn().mockResolvedValue(undefined), - })); - vi.doMock('../../extensions/session-deck/activity/runtime.js', () => ({ - ensureActivityRuntimeStarted: vi.fn().mockResolvedValue({ - refreshActivity: vi.fn().mockResolvedValue(undefined), - recordMessageEnd: vi.fn().mockResolvedValue(undefined), - recordTurnStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionStart: vi.fn().mockResolvedValue(undefined), - recordToolExecutionEnd: vi.fn().mockResolvedValue(undefined), - recordTurnEnd: vi.fn().mockResolvedValue(undefined), - getActivity: vi.fn().mockReturnValue(null), - isRunning: vi.fn(() => true), - }), - })); - vi.doMock('../../extensions/session-deck/identity/command.js', () => ({ - registerSessionDeckCommand: vi.fn(), - })); - - const { default: install } = await import('../../extensions/session-deck/index.js'); - const handlers = new Map(); - const pi = { - on: vi.fn((event: string, handler: RegisteredHandler) => { - handlers.set(event, handler); - }), - }; - - await install(pi as never); - - const ctx = { - sessionManager: { - getSessionId: () => 'session-1', - getSessionFile: () => '/tmp/session-1.md', - }, - ui: { - setStatus: vi.fn(), - }, - }; - - await handlers.get('session_start')?.({ reason: 'startup' }, ctx); - - expect(vi.mocked(ctx.ui.setStatus)).toHaveBeenCalledWith( - 'session-deck', - 'session-deck degraded: Failed to write presence record: permission denied', - ); - }); }); diff --git a/packages/pi-session-deck/extensions/session-deck/chips/constants.ts b/packages/pi-session-deck/extensions/session-deck/chips/constants.ts index a6c1dc4..fe57ba8 100644 --- a/packages/pi-session-deck/extensions/session-deck/chips/constants.ts +++ b/packages/pi-session-deck/extensions/session-deck/chips/constants.ts @@ -134,6 +134,7 @@ export const CHIP_DIAGNOSTIC_CODES = { CHIP_SCOPE_INVALID: 'chip_scope_invalid', CHIP_RUNTIME_ID_MISSING: 'chip_runtime_id_missing', CHIP_SESSION_ID_MISSING: 'chip_session_id_missing', + CHIP_MIRROR_ERROR: 'chip_mirror_error', } as const satisfies Record; export type ChipDiagnosticCodeKey = keyof typeof CHIP_DIAGNOSTIC_CODES; diff --git a/packages/pi-session-deck/extensions/session-deck/chips/index.ts b/packages/pi-session-deck/extensions/session-deck/chips/index.ts index 3e3fe5e..67d0c38 100644 --- a/packages/pi-session-deck/extensions/session-deck/chips/index.ts +++ b/packages/pi-session-deck/extensions/session-deck/chips/index.ts @@ -1,11 +1,9 @@ /** * pi-session-deck P4 chips module index - * - * Public exports stay focused on manual chip publishing and direct store access. - * Unsupported footer/status mirroring paths are intentionally absent. */ export { publishSessionDeckChip, clearSessionDeckChip } from './publisher.js'; +export { createSetStatusMirror } from './mirror.js'; export { writeChipRecord, serializeChipRecord } from './writer.js'; export { getChipsDirectory, getChipRuntimeDirectory, getChipRecordPath } from './store.js'; export * from './types.js'; diff --git a/packages/pi-session-deck/extensions/session-deck/chips/mirror.ts b/packages/pi-session-deck/extensions/session-deck/chips/mirror.ts new file mode 100644 index 0000000..b19a527 --- /dev/null +++ b/packages/pi-session-deck/extensions/session-deck/chips/mirror.ts @@ -0,0 +1,210 @@ +/** + * SetStatus mirror: wraps ctx.ui.setStatus to capture footer-status text + * into chip files without owning the footer. + */ + +import { stripVTControlCharacters } from 'node:util'; +import { + CHIP_DIAGNOSTIC_CODES, + DEFAULT_CHIP_ID, + DEFAULT_CHIP_LEVEL, + DEFAULT_CHIP_SCOPE, + validateSourceSlug, +} from './constants.js'; +import type { ChipDiagnosticSink } from './types.js'; +import { clearSessionDeckChip, publishSessionDeckChip } from './publisher.js'; + +// ─── Types ──────────────────────────────────────────────────────────── + +export interface StatusMirrorOptions { + /** Override the base chips directory */ + directory?: string; + /** Diagnostic callback for fail-open messages */ + onDiagnostic?: ChipDiagnosticSink; +} + +export interface MirroredStatusContext { + runtimeId: string; + getSessionId: () => string | null; +} + +export interface SetStatusMirror { + /** Reconfigure with a new runtime identity (called on session_start) */ + reconfigure(context: MirroredStatusContext): void; + /** Install wrapper on ui.setStatus — noop if already patched */ + install(ui: { setStatus: (key: string, text: string | undefined) => void }): void; + /** Clear tracked entries for session shutdown */ + clearTracked(): Promise; +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +function sanitizeVisibleText(raw: string): string { + return stripVTControlCharacters(raw) + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isNonEmptyString(candidate: string | null | undefined): candidate is string { + return typeof candidate === 'string' && candidate.trim().length > 0; +} + +function noopDiagnostic(_code: string, _message: string): void { + // intentionally empty +} + +// ─── Mirror factory ─────────────────────────────────────────────────── + +const PATCH_KEY = '__piSessionDeckStatusMirrorPatched__' as const; + +export function createSetStatusMirror(options: StatusMirrorOptions = {}): SetStatusMirror { + const emit = options.onDiagnostic ?? noopDiagnostic; + const directory = options.directory; + + let context: MirroredStatusContext | null = null; + + // Track last-written text per source to skip identical writes + const lastMirrored = new Map(); + + return { + reconfigure(nextContext) { + context = { + runtimeId: nextContext.runtimeId, + getSessionId: nextContext.getSessionId, + }; + }, + + install(ui) { + if ((ui as Record)[PATCH_KEY] === true) { + return; // already patched + } + + const priorSetStatus = ui.setStatus.bind(ui); + + const wrapped = (key: string, text: string | undefined): void => { + // Always delegate to the original first + priorSetStatus(key, text); + + // Then mirror asynchronously (fail open — never throw through caller) + mirrorSetStatus(key, text).catch((error) => { + emit( + CHIP_DIAGNOSTIC_CODES.CHIP_MIRROR_ERROR, + `Failed to mirror status "${key}": ${getErrorMessage(error)}`, + ); + }); + }; + + (ui as Record)[PATCH_KEY] = true; + ui.setStatus = wrapped; + }, + + async clearTracked() { + const sources = Array.from(lastMirrored.keys()); + for (const source of sources) { + await clearSourceChip(source); + } + lastMirrored.clear(); + }, + }; + + async function mirrorSetStatus(source: string, text: string | undefined): Promise { + if (context === null) { + return; + } + + const sourceValidation = validateSourceSlug(source); + if (!sourceValidation.valid) { + emit(CHIP_DIAGNOSTIC_CODES.CHIP_SOURCE_INVALID, sourceValidation.reason); + return; + } + + // Clear case: undefined or empty-after-sanitize + if (text === undefined) { + await clearSourceChip(source); + lastMirrored.delete(source); + return; + } + + const sanitized = sanitizeVisibleText(text); + if (sanitized.length === 0) { + await clearSourceChip(source); + lastMirrored.delete(source); + return; + } + + // Skip if text hasn't changed (no-op dedupe) + const previous = lastMirrored.get(source); + if (previous === sanitized) { + return; + } + + const sessionId = safeCall(() => context!.getSessionId(), null); + + if (!isNonEmptyString(sessionId)) { + emit( + CHIP_DIAGNOSTIC_CODES.CHIP_SESSION_ID_MISSING, + `Cannot mirror status "${source}" without a resolved sessionId`, + ); + return; + } + + const result = await publishSessionDeckChip( + { + source, + text: sanitized, + updatedAt: new Date().toISOString(), + chipId: DEFAULT_CHIP_ID, + scope: DEFAULT_CHIP_SCOPE, + level: DEFAULT_CHIP_LEVEL, + runtimeId: context.runtimeId, + sessionId, + }, + { + ...(directory === undefined ? {} : { directory }), + onDiagnostic: emit, + }, + ); + + if (result !== null) { + lastMirrored.set(source, sanitized); + } + } + + async function clearSourceChip(source: string): Promise { + if (context === null) { + return; + } + + await clearSessionDeckChip( + { + source, + chipId: DEFAULT_CHIP_ID, + scope: DEFAULT_CHIP_SCOPE, + runtimeId: context.runtimeId, + }, + { + ...(directory === undefined ? {} : { directory }), + onDiagnostic: emit, + } + ); + } +} + +// ─── Utilities ──────────────────────────────────────────────────────── + +function safeCall(callback: () => T, fallback: T): T { + try { + return callback(); + } catch { + return fallback; + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return String(error); +} diff --git a/packages/pi-session-deck/extensions/session-deck/chips/types.ts b/packages/pi-session-deck/extensions/session-deck/chips/types.ts index 89a38af..18b25e1 100644 --- a/packages/pi-session-deck/extensions/session-deck/chips/types.ts +++ b/packages/pi-session-deck/extensions/session-deck/chips/types.ts @@ -24,7 +24,8 @@ export type ChipDiagnosticCode = | 'chip_clear_error' | 'chip_scope_invalid' | 'chip_runtime_id_missing' - | 'chip_session_id_missing'; + | 'chip_session_id_missing' + | 'chip_mirror_error'; export interface ChipDiagnostic { code: ChipDiagnosticCode; diff --git a/packages/pi-session-deck/extensions/session-deck/index.ts b/packages/pi-session-deck/extensions/session-deck/index.ts index 883cc91..a5d2c76 100644 --- a/packages/pi-session-deck/extensions/session-deck/index.ts +++ b/packages/pi-session-deck/extensions/session-deck/index.ts @@ -7,6 +7,7 @@ import { type PresenceRuntimeController, } from './presence/runtime.js'; import { ensureIdentityRuntimeStarted, stopIdentityRuntime } from './identity/runtime.js'; +import { createSetStatusMirror } from './chips/mirror.js'; import type { SessionManagerLike } from './identity/types.js'; type SessionStartReason = 'startup' | 'reload' | 'new' | 'resume' | 'fork'; @@ -37,6 +38,7 @@ interface SessionStartContext { export default async function (pi: ExtensionAPI): Promise { registerSessionDeckCommand(pi as unknown as PresenceCommandAPI); + const statusMirror = createSetStatusMirror(); function on( event: string, @@ -54,6 +56,13 @@ export default async function (pi: ExtensionAPI): Promise { const presenceRuntime = await ensurePresenceRuntimeStarted(); const sessionManager = createSessionManager(ctx); + // Install setStatus wrapper before session-deck sets its own status + statusMirror.install(ctx.ui); + statusMirror.reconfigure({ + runtimeId: presenceRuntime.runtime.runtimeId, + getSessionId: sessionManager.getSessionId, + }); + ctx.ui.setStatus(SESSION_DECK_COMMAND_NAME, getPresenceStartupStatus(presenceRuntime)); const identityRuntime = await ensureIdentityRuntimeStarted(presenceRuntime.runtime.runtimeId); @@ -103,9 +112,11 @@ export default async function (pi: ExtensionAPI): Promise { }); on('session_shutdown', async () => { + await statusMirror.clearTracked(); await stopIdentityRuntime(); }); + await ensurePresenceRuntimeStarted(); }