diff --git a/.gitignore b/.gitignore index 1ab099e267..7c12eb7360 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ cli/npm/main/ test-results/ playwright-report/ e2e-output/ +.xyz-harness +.agents/ +.pi/ diff --git a/bun.lock b/bun.lock index 8400dfe307..c33705bf46 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,9 @@ "": { "name": "hapi", "devDependencies": { - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.61.0", "concurrently": "^9.2.1", - "playwright": "1.60.0", + "playwright": "1.61.0", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0", }, @@ -720,7 +720,7 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="], "@primer/octicons": ["@primer/octicons@19.23.1", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA=="], @@ -1074,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-o4O/q+vvVrOt4kLy2uBcR/ubQChQeDvq1TtybGkyPq9u1Y4LZkBbM36++TBzAXXaCNn86hQDOUjZs9seXoi18A=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2438,9 +2440,9 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], - "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], diff --git a/cli/package.json b/cli/package.json index ebfd922781..0cfc660224 100644 --- a/cli/package.json +++ b/cli/package.json @@ -83,4 +83,4 @@ "@types/parse-path": "7.0.3" }, "packageManager": "bun@1.3.14" -} +} \ No newline at end of file diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index ed699f590a..1d9f464723 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { AgentMessage } from '@/agent/types'; import { AcpMessageHandler } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; +import { clearGeneratedImages } from '@/modules/common/generatedImages'; function getToolResult(messages: AgentMessage[], id: string): Extract { const result = messages.find((message): message is Extract => @@ -2093,4 +2094,93 @@ describe('AcpMessageHandler', () => { }); }); }); + + it('emits generated_image agent messages from ACP image content blocks', async () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64') + } + }); + + await vi.waitFor(() => { + expect(messages.some((message) => message.type === 'generated_image')).toBe(true); + }); + + const imageMessage = messages.find( + (message): message is Extract => + message.type === 'generated_image' + ); + expect(imageMessage?.mimeType).toBe('image/png'); + expect(imageMessage?.fileName).toBeTruthy(); + expect(imageMessage?.imageId).toBeTruthy(); + expect(imageMessage?.source).toEqual({ ingress: 'acp' }); + clearGeneratedImages(); + }); + + it('emits generated_image before later tool_call when image registration is async', async () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]); + + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64'), + }, + }); + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'call-after-image', + title: 'Read', + kind: 'read', + status: 'in_progress', + }); + + const imageIndex = messages.findIndex((message) => message.type === 'generated_image'); + const toolIndex = messages.findIndex((message) => message.type === 'tool_call'); + expect(imageIndex).toBeGreaterThanOrEqual(0); + expect(toolIndex).toBeGreaterThan(imageIndex); + clearGeneratedImages(); + }); + + it('emits buffered text before generated_image when text precedes an ACP image block', async () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message), { flavor: 'cursor' }); + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]); + + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'Here is the screenshot:' } + }); + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64') + } + }); + + await vi.waitFor(() => { + expect(messages.some((message) => message.type === 'generated_image')).toBe(true); + }); + + const textIndex = messages.findIndex((message) => message.type === 'text'); + const imageIndex = messages.findIndex((message) => message.type === 'generated_image'); + expect(textIndex).toBeGreaterThanOrEqual(0); + expect(imageIndex).toBeGreaterThan(textIndex); + if (messages[imageIndex]?.type === 'generated_image') { + expect(messages[imageIndex].source).toEqual({ ingress: 'acp', flavor: 'cursor' }); + } + clearGeneratedImages(); + }); }); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 68a9ac00b1..f72e6b50dd 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -1,5 +1,8 @@ -import type { AgentMessage, PlanItem } from '@/agent/types'; import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; +import type { AgentMessage, PlanItem } from '@/agent/types'; +import { registerGeneratedImageFromAcpBlock } from '@/modules/common/generatedImages'; +import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; import { asString, isObject } from '@hapi/protocol'; import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils'; import { parseRateLimitText } from '@/agent/rateLimitParser'; @@ -381,7 +384,10 @@ export class AcpMessageHandler { private lastReasoningSnapshotText = ''; private reasoningSnapshotEmitted = false; - constructor(private readonly onMessage: (message: AgentMessage) => void) {} + constructor( + private readonly onMessage: (message: AgentMessage) => void, + private readonly options?: { flavor?: string } + ) {} /** * Emits any buffered assistant text as a single message and clears the @@ -528,7 +534,7 @@ export class AcpMessageHandler { this.reasoningSnapshotEmitted = false; } - handleUpdate(update: unknown): void { + async handleUpdate(update: unknown): Promise { if (!isObject(update)) return; const updateType = asString(update.sessionUpdate); if (!updateType) return; @@ -554,6 +560,12 @@ export class AcpMessageHandler { if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) { const content = update.content; + if (isObject(content) && content.type === 'image') { + this.flushReasoning(); + this.flushText(); + await this.emitGeneratedImageFromAcpContent(content); + return; + } const text = extractTextContent(content); if (text) { // Check once whether the buffered text is a prefix of this @@ -629,6 +641,35 @@ export class AcpMessageHandler { } } + private async emitGeneratedImageFromAcpContent(content: Record): Promise { + try { + const image = await registerGeneratedImageFromAcpBlock(content); + if (!image) { + return; + } + this.onMessage({ + type: 'generated_image', + imageId: image.id, + fileName: image.fileName, + mimeType: image.mimeType, + source: this.buildAcpInlineMediaSource(), + }); + } catch (error) { + logger.debug( + '[AcpMessageHandler] Failed to register ACP image block:', + error instanceof Error ? error.message : String(error) + ); + } + } + + private buildAcpInlineMediaSource(): InlineMediaSource { + const source: InlineMediaSource = { ingress: 'acp' }; + if (this.options?.flavor) { + source.flavor = this.options.flavor; + } + return source; + } + private handleToolCall(update: Record): void { const toolCallId = asString(update.toolCallId); if (!toolCallId) return; diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 13191a5a4c..3c7d5acb88 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -70,6 +70,7 @@ export class AcpSdkBackend implements AgentBackend { private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; + private sessionUpdateQueue: Promise = Promise.resolve(); /** Retry configuration for ACP initialization */ private static readonly INIT_RETRY_OPTIONS = { @@ -101,7 +102,12 @@ export class AcpSdkBackend implements AgentBackend { private static readonly LATE_FLUSH_QUIET_PERIOD_MS = 250; private static readonly LATE_FLUSH_WINDOW_MS = 6000; - constructor(private readonly options: { command: string; args?: string[]; env?: Record }) {} + constructor(private readonly options: { + command: string; + args?: string[]; + env?: Record; + flavor?: AgentFlavor; + }) {} async initialize(): Promise { if (this.transport) return; @@ -394,8 +400,9 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS ); + await this.sessionUpdateQueue; this.messageHandler?.drainBuffers(); - this.messageHandler = new AcpMessageHandler(onUpdate); + this.messageHandler = new AcpMessageHandler(onUpdate, { flavor: this.options.flavor }); this.isProcessingMessage = true; this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; @@ -419,12 +426,17 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS ); + await this.sessionUpdateQueue; this.messageHandler?.drainBuffers(); // Block here until the model truly stops streaming straggler // chunks (or LATE_FLUSH_WINDOW_MS elapses), so turn_complete and // the launcher's ready signal only fire once every chunk has been // emitted to this turn's onUpdate. await this.drainLateBuffers(); + // Late window can enqueue async image registration; drain again + // before turn_complete so generated_image precedes turn boundary. + await this.sessionUpdateQueue; + this.messageHandler?.drainBuffers(); try { const latestUsageUpdate = this.readLatestUsageUpdate(); if (promptUsage) { @@ -537,6 +549,7 @@ export class AcpSdkBackend implements AgentBackend { async disconnect(): Promise { if (!this.transport) return; + await this.sessionUpdateQueue; this.messageHandler?.drainBuffers(); this.messageHandler = null; this.activeSessionId = null; @@ -555,8 +568,17 @@ export class AcpSdkBackend implements AgentBackend { } this.lastSessionUpdateAt = Date.now(); const update = params.update; - this.captureUsageUpdate(update); - this.messageHandler?.handleUpdate(update); + this.sessionUpdateQueue = this.sessionUpdateQueue + .then(async () => { + this.captureUsageUpdate(update); + await this.messageHandler?.handleUpdate(update); + }) + .catch((error) => { + logger.debug( + '[AcpSdkBackend] session update failed:', + error instanceof Error ? error.message : String(error) + ); + }); } private captureUsageUpdate(update: unknown): void { diff --git a/cli/src/agent/localHandoff.test.ts b/cli/src/agent/localHandoff.test.ts index 1405a9441c..d1c1f4be13 100644 --- a/cli/src/agent/localHandoff.test.ts +++ b/cli/src/agent/localHandoff.test.ts @@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => { const lifecycle = { setArchiveReason: vi.fn(), setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false), cleanupAndExit: vi.fn(async () => {}) } diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 0aeb31f3de..9acf60b9ad 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -50,6 +50,18 @@ describe('convertAgentMessage', () => { }); }); + it('converts agent errors into error wire payloads', () => { + const converted = convertAgentMessage({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }); + + expect(converted).toEqual({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }); + }); + it('converts usage messages into token_count payloads', () => { const converted = convertAgentMessage({ type: 'usage', @@ -77,4 +89,23 @@ describe('convertAgentMessage', () => { } }); }); + + it('converts generated_image messages into generated-image wire payloads', () => { + const converted = convertAgentMessage({ + type: 'generated_image', + imageId: 'img-1', + fileName: 'inline.png', + mimeType: 'image/png', + source: { ingress: 'mcp', toolName: 'display_image' }, + }); + + expect(converted).toMatchObject({ + type: 'generated-image', + imageId: 'img-1', + fileName: 'inline.png', + mimeType: 'image/png', + source: { ingress: 'mcp', toolName: 'display_image' }, + }); + expect(converted && 'id' in converted && typeof converted.id === 'string').toBe(true); + }); }); diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 4e1dbed50d..126874a0c5 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { AgentMessage, PlanItem } from './types'; +import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; export type CodexMessage = | { type: 'message'; message: string } @@ -32,7 +33,15 @@ export type CodexMessage = is_error?: boolean; } | { type: 'plan'; entries: PlanItem[] } - | { type: 'error'; message: string }; + | { type: 'error'; message: string } + | { + type: 'generated-image'; + imageId: string; + fileName: string; + mimeType: string; + id: string; + source?: InlineMediaSource; + }; export function convertAgentMessage(message: AgentMessage): CodexMessage | null { switch (message.type) { @@ -78,6 +87,15 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null type: 'plan', entries: message.items }; + case 'generated_image': + return { + type: 'generated-image', + imageId: message.imageId, + fileName: message.fileName, + mimeType: message.mimeType, + id: randomUUID(), + source: message.source, + }; case 'error': return { type: 'error', message: message.message }; case 'turn_complete': diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts new file mode 100644 index 0000000000..172dca0aef --- /dev/null +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRunnerLifecycle } from './runnerLifecycle'; +import type { RunnerLifecycle } from './runnerLifecycle'; + +// Mock heavy deps +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + getLogPath: vi.fn(() => '/tmp/test.log'), + }, +})); + +vi.mock('@/ui/terminalState', () => ({ + restoreTerminalState: vi.fn(), +})); + +function createMockApiSession() { + return { + updateMetadata: vi.fn(), + sendSessionDeath: vi.fn(), + flush: vi.fn(), + close: vi.fn(), + } as unknown as Parameters[0]['session']; +} + +function createMockApiSessionWithMetadataCapture() { + const metadataWrites: Array> = [] + return { + updateMetadata: vi.fn((handler: (m: Record) => Record) => { + const next = handler({}) + metadataWrites.push(next) + return next + }), + sendSessionDeath: vi.fn(), + flush: vi.fn(async () => {}), + close: vi.fn(async () => {}), + metadataWrites + } as unknown as Parameters[0]['session'] & { + metadataWrites: Array> + } +} + +describe('createRunnerLifecycle', () => { + let lifecycle: RunnerLifecycle; + + beforeEach(() => { + vi.clearAllMocks(); + lifecycle = createRunnerLifecycle({ + session: createMockApiSession(), + logTag: 'test', + }); + }); + + // --- D-9: hasExplicitSessionEndReason --- + + describe('hasExplicitSessionEndReason', () => { + it('returns false initially', () => { + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('returns true after setSessionEndReason is called', () => { + lifecycle.setSessionEndReason('completed'); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + + it('returns false after markCrash — markCrash does NOT set explicit flag', () => { + lifecycle.markCrash(new Error('boom')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('stays true once set — subsequent markCrash does not clear it', () => { + lifecycle.setSessionEndReason('handoff'); + lifecycle.markCrash(new Error('late crash')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + }); + + // --- markCrash sets reason to 'error' but not explicit --- + + describe('markCrash', () => { + it('sets sessionEndReason to error via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.markCrash(new Error('fatal')); + + // cleanup triggers sendSessionDeath — verify 'error' reason + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('error'); + }); + }); + + // --- setSessionEndReason + cleanup propagates correct reason --- + + describe('setSessionEndReason + cleanup', () => { + it('sends explicit reason via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.setSessionEndReason('completed'); + + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('completed'); + }); + }); +}); + +// tiann/hapi#914: the runnerLifecycle's default archiveReason is now +// 'Hub restart' (was 'User terminated'). Out-of-band SIGTERM from the +// hub-restart cascade keeps that default. Explicit user actions +// (clicking Archive in the web UI, Ctrl-C in a local terminal, +// uncaught exception) reassign the reason before archive metadata is +// written. +describe('createRunnerLifecycle archiveReason defaults (tiann/hapi#914)', () => { + it('uses Hub restart as the default archiveReason when no override is applied', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + await lifecycle.cleanup() + + expect(session.metadataWrites).toHaveLength(1) + expect(session.metadataWrites[0]).toMatchObject({ + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Hub restart' + }) + }) + + it('writes the operator-supplied reason when setArchiveReason is called (e.g. KillSession RPC)', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setArchiveReason('User terminated') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'User terminated' + }) + }) + + it('markCrash overrides the default reason to "Session crashed"', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.markCrash(new Error('boom')) + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'Session crashed' + }) + }) + + // tiann/hapi#914 review round 4: clean agent-loop completions + // (runClaude / runCodex / runCursor / runGemini / runKimi / + // runOpencode all call setSessionEndReason('completed') without + // touching archiveReason) must not be archived as 'Hub restart'. + // The setSessionEndReason setter flips the default when the runner + // transitions to 'completed'. + it('setSessionEndReason("completed") flips the default reason to "Session completed"', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setSessionEndReason('completed') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'Session completed' + }) + }) + + it('an explicit setArchiveReason before setSessionEndReason("completed") still wins', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setArchiveReason('User terminated') + lifecycle.setSessionEndReason('completed') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'User terminated' + }) + }) +}) diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index 0ae8faa9e2..16a0495120 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -15,6 +15,7 @@ export type RunnerLifecycle = { setExitCode: (code: number) => void setArchiveReason: (reason: string) => void setSessionEndReason: (reason: SessionEndReason) => void + hasExplicitSessionEndReason: () => boolean markCrash: (error: unknown) => void cleanup: () => Promise cleanupAndExit: (codeOverride?: number) => Promise @@ -23,8 +24,29 @@ export type RunnerLifecycle = { export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLifecycle { let exitCode = 0 - let archiveReason = 'User terminated' + // tiann/hapi#914: default reason is 'Hub restart' (parent-driven SIGTERM + // is the most common non-user cause). Genuine user actions (clicking + // Archive in the web UI, or Ctrl-C in a local terminal) explicitly + // reassign this via `setArchiveReason` BEFORE `cleanupAndExit` runs: + // - KillSession RPC handler → 'User terminated' (see registerKillSessionHandler) + // - SIGINT handler → 'User terminated' (Ctrl-C in local terminal) + // - uncaughtException/Reject → 'Session crashed' (via markCrash) + // + // Out-of-band SIGTERM (hub-restart cascade, systemd cgroup kill on + // hapi-runner.service stop, `kill ` from the operator) keeps the + // default and is correctly labelled 'Hub restart' on the audit trail. + // + // Runner-internal stop paths (`hapi runner stop-session`, webhook-timeout + // cleanup at run.ts:587, orphan cleanup at run.ts:267) also currently + // hit this default - that is technically inaccurate but follows the + // friction-mode "smallest defensible change" rule for this PR. Finer + // attribution would require an IPC channel (stdio: 'ipc' on spawn) so + // the runner can stamp `setArchiveReason` before SIGTERMing; tracked as + // a follow-up to keep this PR focussed on the user-action lie that + // motivated #914. + let archiveReason = 'Hub restart' let sessionEndReason: SessionEndReason = 'terminated' + let sessionEndReasonExplicit = false let cleanupStarted = false let cleanupPromise: Promise | null = null @@ -95,8 +117,23 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi const setSessionEndReason = (reason: SessionEndReason) => { sessionEndReason = reason + sessionEndReasonExplicit = true + // tiann/hapi#914 review round 4: every agent runner + // (runClaude / runCodex / runCursor / runGemini / runKimi / + // runOpencode) calls setSessionEndReason('completed') before + // cleanupAndExit() on the natural-exit path without setting an + // archive reason. With the SIGTERM-driven default of 'Hub restart', + // clean completions would otherwise be audit-trailed as restart + // cascades. Flip the default to 'Session completed' when the end + // reason transitions to 'completed' AND no caller has already + // overridden the archive reason. + if (reason === 'completed' && archiveReason === 'Hub restart') { + archiveReason = 'Session completed' + } } + const hasExplicitSessionEndReason = () => sessionEndReasonExplicit + const markCrash = (error: unknown) => { logger.debug(`${logPrefix} Unhandled error:`, error) exitCode = 1 @@ -105,11 +142,19 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi } const registerProcessHandlers = () => { + // tiann/hapi#914: SIGTERM is treated as the default reason ('Hub restart') + // because the runner is restarted by systemd as part of hub restart in + // production. If a future code path needs to distinguish "operator + // killed the host process" from "hub restart", it can call + // setArchiveReason() before the runner exits. process.on('SIGTERM', () => { void cleanupAndExit() }) + // Ctrl-C in a local terminal is genuine user intent — keep the + // pre-#914 label so the audit trail still shows it. process.on('SIGINT', () => { + archiveReason = 'User terminated' void cleanupAndExit() }) @@ -128,6 +173,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi setExitCode, setArchiveReason, setSessionEndReason, + hasExplicitSessionEndReason, markCrash, cleanup, cleanupAndExit, diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index c8e72e795f..6795e5e74c 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -31,10 +31,22 @@ export function resolveSessionConfigPermissionMode 0) { + return modelObj.modelId.trim() + } + throw new Error('Invalid model') + } if (typeof value !== 'string' || value.trim().length === 0) { throw new Error('Invalid model') } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c6e2643125..fcc2629648 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -100,9 +100,15 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId + if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree + // Preserve cached Pi model list so the web can show models immediately + // on inactive-session view without waiting for an RPC round-trip. + if (metadata.piAvailableModels !== undefined) preserved.piAvailableModels = metadata.piAvailableModels + // Preserve provider-qualified Pi model selection (disambiguates duplicate modelIds). + if (metadata.piSelectedModel !== undefined) preserved.piSelectedModel = metadata.piSelectedModel return preserved } diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 03d8543187..e867f55667 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -1,4 +1,5 @@ import type { AgentFlavor } from '@hapi/protocol'; +import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; export type McpEnvVar = { name: string; @@ -44,6 +45,7 @@ export type AgentMessage = contextWindow?: number; } | { type: 'plan'; items: PlanItem[] } + | { type: 'generated_image'; imageId: string; fileName: string; mimeType: string; source?: InlineMediaSource } | { type: 'turn_complete'; stopReason: string } | { type: 'error'; message: string }; diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 9560394118..5af3186cee 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -135,7 +135,9 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { availableModels: [{ modelId: 'x/y' }], currentModelId: 'x/y' }) - expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot) + // The handler realpaths the cwd (security: prevents symlink escape), + // so on macOS /var/folders/... resolves to /private/var/folders/... + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot)) } finally { rmSync(secondWorkspaceRoot, { recursive: true, force: true }) client.shutdown() diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 370239b5e9..cd98d69a51 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -586,6 +586,14 @@ export class ApiSessionClient extends EventEmitter { }) } + /** Hub waits for this before mergeSessions on Cursor ACP reopen (tiann/hapi#939). */ + emitSessionReady(): void { + this.socket.emit('session-ready', { + sid: this.sessionId, + time: Date.now() + }) + } + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { if (localIds.length === 0) return // `clearQueuedThinkingGrace` is an opt-in signal for the hub to drop @@ -740,6 +748,21 @@ export class ApiSessionClient extends EventEmitter { }) } + /** + * tiann/hapi#913: wait until any pending `update-metadata` writes have + * been acked by the hub (or the timeout elapses). `updateMetadata` is + * fire-and-forget at the call site because it's invoked on the hot path + * for every turn; this helper lets the few callers who actually need + * durability — fresh ACP session-id pre-registration is the canonical + * case — synchronously gate on persistence without changing every + * caller's signature. + * + * Returns true when the lock drained, false when the timeout fired. + */ + async flushMetadata(timeoutMs: number = 5_000): Promise { + return await this.drainLock(this.metadataLock, timeoutMs) + } + async flush(options?: { timeoutMs?: number }): Promise { const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f4..f3ca0eb702 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -306,6 +306,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { return permissionHandler.isAborted(toolCallId); }, nextMessage: async () => { + // Flush any pending outgoing messages before consuming the next user + // turn. Without this, scheduleProcessing()'s setTimeout(fn,0) fires + // after the microtask that sends messages-consumed, causing the hub + // to stamp invokedAt on the next user message before it stores the + // current turn's queued agent messages — making them sort permanently + // below the next user message. + await messageQueue.flush(); + if (pending) { let p = pending; pending = null; diff --git a/cli/src/claude/registerKillSessionHandler.test.ts b/cli/src/claude/registerKillSessionHandler.test.ts new file mode 100644 index 0000000000..b293172955 --- /dev/null +++ b/cli/src/claude/registerKillSessionHandler.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { registerKillSessionHandler } from './registerKillSessionHandler' + +// tiann/hapi#914: the KillSession RPC is the authoritative "user-terminated" +// signal because the hub only sends it when the operator clicks Archive in +// the web UI. Out-of-band SIGTERM (hub-restart cascade, host-level `kill`) +// hits the SIGTERM signal handler in runnerLifecycle, which now keeps the +// default reason 'Hub restart' so the audit trail stays correct. +describe('registerKillSessionHandler (tiann/hapi#914)', () => { + function makeRegistry() { + const handlers = new Map unknown>() + return { + registerHandler: (method: string, handler: (params: unknown) => unknown) => { + handlers.set(method, handler as (params?: unknown) => unknown) + }, + handlers + } + } + + it('stamps archiveReason=User terminated before triggering cleanupAndExit', async () => { + const registry = makeRegistry() + const lifecycle = { + setArchiveReason: vi.fn(), + cleanupAndExit: vi.fn(async () => {}) + } + + registerKillSessionHandler( + registry as unknown as Parameters[0], + lifecycle + ) + + const handler = registry.handlers.get(RPC_METHODS.KillSession) + expect(handler).toBeDefined() + + const result = await handler?.() + expect(result).toEqual({ success: true, message: 'Killing hapi CLI process' }) + + // setArchiveReason MUST be called BEFORE cleanupAndExit so the archive + // metadata write reads the correct reason. + const setReasonOrder = lifecycle.setArchiveReason.mock.invocationCallOrder[0] + const cleanupOrder = lifecycle.cleanupAndExit.mock.invocationCallOrder[0] + expect(setReasonOrder).toBeLessThan(cleanupOrder) + expect(lifecycle.setArchiveReason).toHaveBeenCalledWith('User terminated') + expect(lifecycle.cleanupAndExit).toHaveBeenCalled() + }) + + it('still works with the legacy `(cleanupAndExit: () => Promise)` call shape', async () => { + // Back-compat: runAgentSession.ts passes a bare closure as the second + // argument instead of a lifecycle object. The handler should not crash + // when setArchiveReason is absent. + const registry = makeRegistry() + const cleanupAndExit = vi.fn(async () => {}) + + registerKillSessionHandler( + registry as unknown as Parameters[0], + cleanupAndExit + ) + + const handler = registry.handlers.get(RPC_METHODS.KillSession) + await handler?.() + + expect(cleanupAndExit).toHaveBeenCalled() + }) +}) diff --git a/cli/src/claude/registerKillSessionHandler.ts b/cli/src/claude/registerKillSessionHandler.ts index 37936b79bc..b42b9b49fc 100644 --- a/cli/src/claude/registerKillSessionHandler.ts +++ b/cli/src/claude/registerKillSessionHandler.ts @@ -11,18 +11,41 @@ interface KillSessionResponse { message: string; } +/** + * tiann/hapi#914: callers can pass either a bare `cleanupAndExit` closure + * (legacy) or an options object that lets the kill-RPC stamp an explicit + * `archiveReason` before the lifecycle teardown runs. The hub only sends + * KillSession when the operator clicked Archive in the UI, so this RPC is + * the authoritative "user-terminated" signal; out-of-band SIGTERM from a + * hub-restart cascade no longer collides with the default archive reason. + */ +export interface KillSessionLifecycle { + cleanupAndExit: () => Promise; + setArchiveReason?: (reason: string) => void; +} export function registerKillSessionHandler( rpcHandlerManager: RpcHandlerManager, - killThisHappy: () => Promise + lifecycleOrCleanup: KillSessionLifecycle | (() => Promise) ) { + const lifecycle: KillSessionLifecycle = typeof lifecycleOrCleanup === 'function' + ? { cleanupAndExit: lifecycleOrCleanup } + : lifecycleOrCleanup; + rpcHandlerManager.registerHandler(RPC_METHODS.KillSession, async () => { logger.debug('Kill session request received'); + // tiann/hapi#914: stamp the archive reason from the RPC path so the + // default in `runnerLifecycle.ts` can be reassigned away from + // 'User terminated'. A hub-restart-cascade SIGTERM does NOT go + // through this handler — it hits the SIGTERM signal handler — so + // those archives now stay labelled `'Hub restart'` (the new default). + lifecycle.setArchiveReason?.('User terminated'); + // This will start the cleanup process - void killThisHappy(); + void lifecycle.cleanupAndExit(); - // We should still be able to respond the the client, though they + // We should still be able to respond to the client, though they // should optimistically assume the session is dead. return { success: true, diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 4472aee92d..1ebb1601e5 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -145,7 +145,7 @@ export async function runClaude(options: StartOptions = {}): Promise { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); // Set initial agent state diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index df0b0fd47f..320d13d347 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -4,7 +4,7 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage } from "node:http"; import { lstat, readFile } from "node:fs/promises"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { AddressInfo } from "node:net"; @@ -12,44 +12,36 @@ import { z } from "zod"; import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -import { detectImageMimeType, registerGeneratedImage } from "@/modules/common/generatedImages"; +import { detectImageMimeType, detectVideoMimeType, registerGeneratedImage } from "@/modules/common/generatedImages"; +import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; type StartHappyServerOptions = { emitTitleSummary?: boolean; }; -export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { - const emitTitleSummary = options.emitTitleSummary ?? true; - - // Handler that sends title updates via the client +function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { if (emitTitleSummary) { - // Send title as a summary message, similar to title generator. client.sendClaudeSessionMessage({ type: 'summary', summary: title, leafUuid: randomUUID() }); } - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }; - // - // Create the MCP server - // - const mcp = new McpServer({ name: "HAPI MCP", version: "1.0.0", }); - // Avoid TS instantiation depth issues by widening the schema type. const changeTitleInputSchema: z.ZodTypeAny = z.object({ title: z.string().describe('The new title for the chat session'), }); @@ -59,6 +51,60 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH title: z.string().optional().describe('Optional display title or filename for the image'), }); + const displayVideoInputSchema: z.ZodTypeAny = z.object({ + path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'), + title: z.string().optional().describe('Optional display title or filename for the video'), + }); + + const maxInlineMediaBytes = 25 * 1024 * 1024; + + async function displayInlineMedia( + args: { path: string; title?: string }, + mediaKind: 'image' | 'video', + toolName: 'display_image' | 'display_video' + ) { + const info = await lstat(args.path); + if (!info.isFile()) { + throw new Error('Path is not a regular file'); + } + + if (info.size > maxInlineMediaBytes) { + throw new Error('File is too large to display inline'); + } + + const bytes = await readFile(args.path); + const mimeType = mediaKind === 'video' + ? detectVideoMimeType(bytes) + : detectImageMimeType(bytes); + if (!mimeType) { + throw new Error(mediaKind === 'video' ? 'Unsupported video content' : 'Unsupported image content'); + } + + const media = registerGeneratedImage({ + id: randomUUID(), + path: args.path, + fileName: args.title, + mimeType, + bytes + }); + + const source: InlineMediaSource = { + ingress: 'mcp', + toolName, + }; + + client.sendAgentMessage({ + type: 'generated-image', + imageId: media.id, + fileName: media.fileName, + mimeType: media.mimeType, + id: randomUUID(), + source, + }); + + return media; + } + mcp.registerTool('change_title', { description: 'Change the title of the current chat session', title: 'Change Chat Title', @@ -66,7 +112,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }, async (args: { title: string }) => { const response = await handler(args.title); logger.debug('[hapiMCP] Response:', response); - + if (response.success) { return { content: [ @@ -77,19 +123,18 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH ], isError: false, }; - } else { - return { - content: [ - { - type: 'text' as const, - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; } - }); + return { + content: [ + { + type: 'text' as const, + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, + }, + ], + isError: true, + }; + }); mcp.registerTool('display_image', { description: 'Display a local image file inline in the current HAPI chat session', @@ -99,37 +144,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH logger.debug('[hapiMCP] Display image:', args.path); try { - const info = await lstat(args.path); - if (!info.isFile()) { - throw new Error('Path is not a regular file'); - } - - const maxImageBytes = 25 * 1024 * 1024; - if (info.size > maxImageBytes) { - throw new Error('Image is too large to display inline'); - } - - const bytes = await readFile(args.path); - const mimeType = detectImageMimeType(bytes); - if (!mimeType) { - throw new Error('Unsupported image content'); - } - - const image = registerGeneratedImage({ - id: randomUUID(), - path: args.path, - fileName: args.title, - mimeType, - bytes - }); - - client.sendAgentMessage({ - type: 'generated-image', - imageId: image.id, - fileName: image.fileName, - mimeType: image.mimeType, - id: randomUUID() - }); + const image = await displayInlineMedia(args, 'image', 'display_image'); return { content: [ @@ -155,19 +170,92 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH } }); - const transport = new StreamableHTTPServerTransport({ - // NOTE: Returning session id here will result in claude - // sdk spawn to fail with `Invalid Request: Server already initialized` - sessionIdGenerator: undefined + mcp.registerTool('display_video', { + description: 'Display a local mp4 or webm file inline in the current HAPI chat session', + title: 'Display Video', + inputSchema: displayVideoInputSchema, + }, async (args: { path: string; title?: string }) => { + logger.debug('[hapiMCP] Display video:', args.path); + + try { + const video = await displayInlineMedia(args, 'video', 'display_video'); + + return { + content: [ + { + type: 'text' as const, + text: `Displayed video: ${video.fileName}`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.debug('[hapiMCP] Failed to display video:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to display video: ${message}`, + }, + ], + isError: true, + }; + } }); - await mcp.connect(transport); - // - // Create the HTTP server - // + return mcp; +} + +function readMcpSessionId(req: IncomingMessage): string | undefined { + const raw = req.headers['mcp-session-id']; + if (typeof raw === 'string') { + return raw; + } + if (Array.isArray(raw)) { + return raw[0]; + } + return undefined; +} + +export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { + const emitTitleSummary = options.emitTitleSummary ?? true; + const transports = new Map(); + const mcps = new Map(); + + const createMcpTransport = () => { + const mcp = createHapiMcpServer(client, emitTitleSummary); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports.set(sessionId, transport); + mcps.set(sessionId, mcp); + }, + onsessionclosed: (sessionId) => { + transports.delete(sessionId); + const server = mcps.get(sessionId); + mcps.delete(sessionId); + void server?.close(); + }, + }); + void mcp.connect(transport); + return transport; + }; const server = createServer(async (req, res) => { try { + const sessionId = readMcpSessionId(req); + const transport = sessionId + ? transports.get(sessionId) + : createMcpTransport(); + + if (!transport) { + if (!res.headersSent) { + res.writeHead(404).end(); + } + return; + } + await transport.handleRequest(req, res); } catch (error) { logger.debug("Error handling request:", error); @@ -184,13 +272,23 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }); }); + const mcpUrl = baseUrl.toString(); + client.updateMetadata((metadata) => ({ + ...metadata, + hapiMcpUrl: mcpUrl, + })); + return { - url: baseUrl.toString(), - toolNames: ['change_title', 'display_image'], + url: mcpUrl, + toolNames: ['change_title', 'display_image', 'display_video'], stop: () => { logger.debug('[hapiMCP] Stopping server'); - mcp.close(); + for (const mcp of mcps.values()) { + mcp.close(); + } + transports.clear(); + mcps.clear(); server.close(); } - } + }; } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 9e90179d22..eb0e07aef7 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,12 +1,14 @@ import { trimIdent } from "@/utils/trimIdent"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; +import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; /** * Base system prompt shared across all configurations */ const BASE_SYSTEM_PROMPT = (() => trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. + ${DISPLAY_IMAGE_PROMPT_CLAUDE} + ${DISPLAY_VIDEO_PROMPT_CLAUDE} `))(); /** diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index f0f8510fa8..0aa2e72bf0 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; import type { CollaborationModeListResponse, @@ -69,10 +70,9 @@ function createAbortError(): Error { return error; } -export class CodexAppServerClient { +export class CodexAppServerClient extends JsonLineParser { private process: ChildProcessWithoutNullStreams | null = null; private connected = false; - private buffer = ''; private nextId = 1; private readonly pending = new Map(); private readonly requestHandlers = new Map(); @@ -103,7 +103,7 @@ export class CodexAppServerClient { }); this.process.stdout.setEncoding('utf8'); - this.process.stdout.on('data', (chunk) => this.handleStdout(chunk)); + this.process.stdout.on('data', (chunk) => this.feed(chunk)); this.process.stderr.setEncoding('utf8'); this.process.stderr.on('data', (chunk) => { @@ -354,23 +354,7 @@ export class CodexAppServerClient { this.writePayload(payload); } - private handleStdout(chunk: string): void { - this.buffer += chunk; - let newlineIndex = this.buffer.indexOf('\n'); - - while (newlineIndex >= 0) { - const line = this.buffer.slice(0, newlineIndex).trim(); - this.buffer = this.buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - this.handleLine(line); - } - - newlineIndex = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string): void { + protected handleLine(line: string): void { if (this.protocolError) { return; } @@ -482,7 +466,7 @@ export class CodexAppServerClient { } private resetParserState(): void { - this.buffer = ''; + this.reset(); this.protocolError = null; } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2cb80fe17f..61a8d8846e 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -1,6 +1,5 @@ import React from 'react'; import { randomUUID } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; import { CodexAppServerClient } from './codexAppServerClient'; import { CodexPermissionHandler } from './utils/permissionHandler'; @@ -14,7 +13,7 @@ import type { CodexSession } from './session'; import type { EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; -import { detectImageMimeType, registerGeneratedImage } from '@/modules/common/generatedImages'; +import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes'; @@ -27,32 +26,17 @@ import { } from '@/modules/common/remote/RemoteLauncherBase'; -async function registerGeneratedImageFromPath(args: { id: string; path: string; fileName?: string | null }): Promise | null> { - try { - const info = await lstat(args.path); - if (!info.isFile()) { - throw new Error('Path is not a regular file'); - } - const maxImageBytes = 25 * 1024 * 1024; - if (info.size > maxImageBytes) { - throw new Error('Image is too large to display inline'); - } - const bytes = await readFile(args.path); - const mimeType = detectImageMimeType(bytes); - if (!mimeType) { - throw new Error('Unsupported image content'); - } - return registerGeneratedImage({ - id: args.id, - path: args.path, - fileName: args.fileName, - mimeType, - bytes - }); - } catch (error) { - logger.debug('[CodexRemoteLauncher] Failed to register generated image:', error instanceof Error ? error.message : String(error)); - return null; + +async function registerGeneratedImageFromPathWrapper(args: { id: string; path: string; fileName?: string | null }): Promise> | null> { + const image = await registerGeneratedImageFromPath({ + id: args.id, + path: args.path, + fileName: args.fileName + }); + if (!image) { + logger.debug('[CodexRemoteLauncher] Failed to register generated image from path'); } + return image; } type HappyServer = Awaited>['server']; @@ -2215,7 +2199,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const imageId = randomUUID(); const savedPath = asString(msg.saved_path ?? msg.savedPath); if (savedPath) { - const image = await registerGeneratedImageFromPath({ + const image = await registerGeneratedImageFromPathWrapper({ id: imageId, path: savedPath, fileName: asString(msg.file_name ?? msg.fileName) @@ -2229,7 +2213,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase { sourceImageId, fileName: image.fileName, mimeType: image.mimeType, - id: randomUUID() + id: randomUUID(), + source: { + ingress: 'tool_result', + flavor: 'codex', + toolCallId: asString(msg.call_id ?? msg.callId), + }, }); } } diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index 7c30617c2b..518eeffb9b 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -123,6 +123,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { } ); + const displayVideoInputSchema: z.ZodTypeAny = z.object({ + path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'), + title: z.string().optional().describe('Optional display title or filename for the video'), + }); + + server.registerTool( + 'display_video', + { + description: 'Display a local mp4 or webm file inline in the current HAPI chat session', + title: 'Display Video', + inputSchema: displayVideoInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'display_video', arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { type: 'text' as const, text: `Failed to display video: ${error instanceof Error ? error.message : String(error)}` }, + ], + isError: true, + }; + } + } + ); + // Start STDIO transport const stdio = new StdioServerTransport(); await server.connect(stdio); diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 0dca6b8f70..7c138bddb2 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -60,7 +60,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })) vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 4958b6acfc..de907c2271 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -98,7 +98,7 @@ export async function runCodex(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts new file mode 100644 index 0000000000..164cb88d53 --- /dev/null +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildHapiMcpBridge } from './buildHapiMcpBridge'; + +vi.mock('@/claude/utils/startHappyServer', () => ({ + startHappyServer: vi.fn(async () => ({ + url: 'http://127.0.0.1:63995/', + stop: vi.fn(), + toolNames: ['change_title', 'display_image', 'display_video'] + })) +})); + +vi.mock('@/utils/spawnHappyCLI', () => ({ + getHappyCliCommand: vi.fn(() => ({ + command: 'hapi', + args: ['mcp', '--url', 'http://127.0.0.1:63995/'] + })) +})); + +describe('buildHapiMcpBridge', () => { + it('auto-approves change_title, display_image, and display_video MCP tools', async () => { + const client = {} as never; + const bridge = await buildHapiMcpBridge(client); + + expect(bridge.mcpServers.hapi.tools).toEqual({ + change_title: { approval_mode: 'approve' }, + display_image: { approval_mode: 'approve' }, + display_video: { approval_mode: 'approve' } + }); + expect(bridge.server.url).toBe('http://127.0.0.1:63995/'); + }); +}); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 72ef85eaa1..7fd7cf329c 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -74,6 +74,12 @@ export async function buildHapiMcpBridge( tools: { change_title: { approval_mode: 'approve' + }, + display_image: { + approval_mode: 'approve' + }, + display_video: { + approval_mode: 'approve' } } } diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index 298f2dbd0e..6ced105183 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -31,6 +31,12 @@ describe('codexMcpConfig', () => { tools: { change_title: { approval_mode: 'approve' as const + }, + display_image: { + approval_mode: 'approve' as const + }, + display_video: { + approval_mode: 'approve' as const } } } @@ -39,6 +45,8 @@ describe('codexMcpConfig', () => { const args = buildMcpServerConfigArgs(mcpServers); expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); + expect(args).toContain('mcp_servers.hapi.tools.display_image.approval_mode="approve"'); + expect(args).toContain('mcp_servers.hapi.tools.display_video.approval_mode="approve"'); }); it('builds config args for multiple MCP servers', () => { diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index a6057be03b..82e3da28f3 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -6,6 +6,7 @@ */ import { trimIdent } from '@/utils/trimIdent'; +import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; /** * Title instruction for Codex to call the hapi MCP tool. @@ -18,7 +19,8 @@ export const TITLE_INSTRUCTION = trimIdent(` If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. + ${DISPLAY_IMAGE_PROMPT_CODEX} + ${DISPLAY_VIDEO_PROMPT_CODEX} `); /** diff --git a/cli/src/commands/agentCommandOptions.test.ts b/cli/src/commands/agentCommandOptions.test.ts index 7561774f28..f70c3e4ad3 100644 --- a/cli/src/commands/agentCommandOptions.test.ts +++ b/cli/src/commands/agentCommandOptions.test.ts @@ -69,3 +69,111 @@ describe('parseRemoteAgentCommandOptions', () => { expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value') }) }) + +describe('parseRemoteAgentCommandOptions — pi flavor', () => { + // Pi RPC mode has no permission switching, so the command passes an empty + // allow-list. These tests cover the non-permission flags using a non-empty + // allow-list purely as a parser fixture — the parser's behavior is + // independent of the modes' contents. + const ALLOWED = OPENCODE_PERMISSION_MODES + + it('accepts --model and stores it on options', () => { + const result = parseRemoteAgentCommandOptions( + ['--model', 'claude-sonnet-4-5'], + ALLOWED + ) + expect(result.model).toBe('claude-sonnet-4-5') + }) + + it('--session-id stores the value as resumeSessionId (Pi-specific flag)', () => { + // Pi uses --session-id for exact session resume (RPC mode), not the + // generic --resume that other flavors use. + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'pi-sess-123'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('pi-sess-123') + }) + + it('--resume is also accepted as an alias for session resume', () => { + // Some flavor paths pass --resume; the parser should accept it + // uniformly so callers do not need to branch on flavor. + const result = parseRemoteAgentCommandOptions( + ['--resume', 'sess-id'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('sess-id') + }) + + it('a later --resume overrides a prior --session-id (last-write-wins)', () => { + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'first', '--resume', 'second'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('second') + }) + + it('rejects --session-id with no value', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--session-id'], + ALLOWED + )).toThrow('Missing --session-id value') + }) + + it('parses --started-by runner', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'runner'], + ALLOWED + ) + expect(result.startedBy).toBe('runner') + }) + + it('parses --started-by terminal', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'terminal'], + ALLOWED + ) + expect(result.startedBy).toBe('terminal') + }) + + it('parses --hapi-starting-mode remote', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'remote'], + ALLOWED + ) + expect(result.startingMode).toBe('remote') + }) + + it('parses --hapi-starting-mode local', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'local'], + ALLOWED + ) + expect(result.startingMode).toBe('local') + }) + + it('rejects invalid --hapi-starting-mode', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'invalid'], + ALLOWED + )).toThrow('Invalid --hapi-starting-mode') + }) + + it('handles a full pi invocation end-to-end', () => { + const result = parseRemoteAgentCommandOptions( + [ + '--started-by', 'runner', + '--hapi-starting-mode', 'remote', + '--model', 'claude-sonnet-4-5', + '--session-id', 'pi-sess-full', + ], + ALLOWED + ) + expect(result).toEqual({ + startedBy: 'runner', + startingMode: 'remote', + model: 'claude-sonnet-4-5', + resumeSessionId: 'pi-sess-full', + }) + }) +}) diff --git a/cli/src/commands/agentCommandOptions.ts b/cli/src/commands/agentCommandOptions.ts index 0e2e271b8e..f7e8b29c5e 100644 --- a/cli/src/commands/agentCommandOptions.ts +++ b/cli/src/commands/agentCommandOptions.ts @@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions = startingMode?: 'local' | 'remote' permissionMode?: TPermissionMode model?: string + effort?: string modelReasoningEffort?: string resumeSessionId?: string } @@ -42,12 +43,25 @@ export function parseRemoteAgentCommandOptions { + try { + // Pi RPC mode has no runtime permission switching; pass an empty + // allow-list so --permission-mode is rejected and no permissionMode + // leaks into the session state. + const options = parseRemoteAgentCommandOptions(commandArgs, []) + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runPi } = await import('@/pi/runPi') + await runPi(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 6ff36916af..7e93ca4fca 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -9,6 +9,7 @@ import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' +import { piCommand } from './pi' import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' import { notifyCommand } from './notify' @@ -23,6 +24,7 @@ const COMMANDS: CommandDefinition[] = [ geminiCommand, kimiCommand, opencodeCommand, + piCommand, mcpCommand, hubCommand, { ...hubCommand, name: 'server' }, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index fa0f254d73..73b0de0a2a 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -10,6 +10,7 @@ const { renderMock, runCodexMock, runClaudeMock, + runPiMock, assertCodexLocalSupportedMock, existsSyncMock } = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ const { renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), + runPiMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) })) @@ -44,6 +46,7 @@ vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) +vi.mock('@/pi/runPi', () => ({ runPi: runPiMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) @@ -72,6 +75,7 @@ describe('resumeCommand', () => { }) runCodexMock.mockClear() runClaudeMock.mockClear() + runPiMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) }) @@ -247,6 +251,36 @@ describe('resumeCommand', () => { } }) + it('resumes a Pi target with effort', async () => { + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-pi', + flavor: 'pi', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'pi-session-123', + model: 'deepseek-v3', + effort: 'high', + permissionMode: 'yolo' + }) + + await resumeCommand.run(createContext(['hapi-session-pi'])) + + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(runPiMock).toHaveBeenCalledWith({ + existingSessionId: 'hapi-session-pi', + workingDirectory: '/tmp/project', + resumeSessionId: 'pi-session-123', + startedBy: 'terminal', + // Pi has no local TUI input path, so resume defaults to remote control. + startingMode: 'remote', + model: 'deepseek-v3', + effort: 'high' + }) + }) + it('keeps the non-TTY fallback and asks for an explicit session id', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 353f7cab57..cf2af593fd 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -145,6 +145,23 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'pi') { + const { runPi } = await import('@/pi/runPi') + await runPi({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + // Pi runs as `pi --mode rpc` with piped stdio and no local TUI input + // path, so 'local' would advertise local-control that cannot be used + // and hide/reject remote-only controls until a web switch. + startingMode: 'remote', + model: target.model ?? undefined, + effort: target.effort ?? undefined, + }) + return + } + const { runCursor } = await import('@/cursor/runCursor') await runCursor({ existingSessionId: base.existingSessionId, diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts index 3cc404c40c..7a7d7bb97c 100644 --- a/cli/src/commands/runCli.ts +++ b/cli/src/commands/runCli.ts @@ -1,5 +1,4 @@ import packageJson from '../../package.json' -import { ensureRuntimeAssets } from '@/runtime/assets' import { isBunCompiled } from '@/projectPath' import { logger } from '@/ui/logger' import { getCliArgs } from '@/utils/cliArgs' @@ -23,6 +22,7 @@ export async function runCli(): Promise { const { command, context } = resolveCommand(args) if (command.requiresRuntimeAssets) { + const { ensureRuntimeAssets } = await import('@/runtime/assets') await ensureRuntimeAssets() logger.debug('Starting hapi CLI with args: ', process.argv) } diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 3aeb747389..761e86c387 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -138,15 +138,7 @@ import { ApiSessionClient } from '@/api/apiSession'; function makeSession(sessionId: string | null): CursorSession { const queue = new MessageQueue2(() => 'mode'); - const client = { - rpcHandlerManager: { - registerHandler: vi.fn() - }, - updateMetadata: vi.fn(), - sendSessionEvent: vi.fn(), - sendAgentMessage: vi.fn(), - keepAlive: vi.fn() - } as unknown as ApiSessionClient; + const client = makeClient(); const session = new CursorSession({ api: {} as never, @@ -168,6 +160,20 @@ function makeSession(sessionId: string | null): CursorSession { return session; } +function makeClient() { + return { + rpcHandlerManager: { + registerHandler: vi.fn() + }, + updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitSessionReady: vi.fn() + } as unknown as ApiSessionClient; +} + describe('cursorAcpRemoteLauncher', () => { beforeEach(() => { harness.initializeError = null; @@ -202,11 +208,16 @@ describe('cursorAcpRemoteLauncher', () => { it('throws on initialize failure without invoking legacy launcher', async () => { harness.initializeError = new Error('agent acp not found'); const session = makeSession(null); + const client = session.client as unknown as { sendAgentMessage: ReturnType }; await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( /Cursor ACP mode is required for new Cursor remote sessions/ ); + expect(client.sendAgentMessage).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('agent acp not found') + }); expect(legacyLauncher).not.toHaveBeenCalled(); expect(harness.newSessionCalled).toBe(false); }); @@ -265,6 +276,82 @@ describe('cursorAcpRemoteLauncher', () => { expect(harness.newSessionCalled).toBe(true); expect(harness.loadSessionCalled).toBe(false); expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('new-acp-session', 'acp'); + expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1); + }); + + it('emits session-ready after session/load succeeds', async () => { + const session = makeSession('resume-thread-ready'); + await cursorAcpRemoteLauncher(session); + + expect(harness.loadSessionCalled).toBe(true); + expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1); + }); + + it('does not emit session-ready when session/load fails', async () => { + harness.loadSessionError = new Error('session not found'); + const session = makeSession('old-stream-json-id'); + + await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( + /Legacy stream-json sessions cannot be loaded via ACP/ + ); + + expect(session.client.emitSessionReady).not.toHaveBeenCalled(); + }); + + // tiann/hapi#913: fresh ACP sessions previously persisted `cursorSessionId` + // via fire-and-forget `updateMetadata`. A SIGTERM within ~1s of the first + // turn (hub-restart cascade) could strand the session because the ACK + // never arrived. The fix awaits `client.flushMetadata()` between + // `onSessionFoundWithProtocol` and the main loop, gating turn processing + // on a durable persist. + it('awaits flushMetadata after registering a fresh cursorSessionId so SIGTERM cannot strand the session', async () => { + const session = makeSession(null); + const flushSpy = vi.fn(async () => true); + // Replace the mock fixture's flushMetadata so we can observe ordering. + (session.client as unknown as { flushMetadata: typeof flushSpy }).flushMetadata = flushSpy; + + let flushCalled = false; + flushSpy.mockImplementation(async () => { + flushCalled = true; + return true; + }); + + const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType; + let onSessionFoundCalledBeforeFlush = false; + onSessionFoundSpy.mockImplementation(() => { + if (!flushCalled) { + onSessionFoundCalledBeforeFlush = true; + } + }); + + await cursorAcpRemoteLauncher(session); + + expect(onSessionFoundCalledBeforeFlush).toBe(true); + expect(flushSpy).toHaveBeenCalled(); + }); + + it('preserves the #834 resume-path pre-registration shape (registration before backend.loadSession)', async () => { + // PR #834 pre-registers `cursorSessionId` BEFORE `backend.loadSession` + // so a load-session failure on a legacy store does not strand the + // session. The #913 fix must not relocate or remove that + // pre-registration. We verify by observing call ordering on the spy. + const session = makeSession('resume-acp-session'); + const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType; + + let preRegisterCalledBeforeLoadSession = false; + let preRegisterArgs: unknown[] | null = null; + onSessionFoundSpy.mockImplementation((id: string, protocol: string) => { + if (!harness.loadSessionCalled) { + preRegisterCalledBeforeLoadSession = true; + preRegisterArgs = [id, protocol]; + } + }); + + await cursorAcpRemoteLauncher(session); + + expect(preRegisterCalledBeforeLoadSession).toBe(true); + expect(preRegisterArgs).toEqual(['resume-acp-session', 'acp']); + expect(harness.loadSessionCalled).toBe(true); }); it('applies debug mode immediately when setPermissionMode is called', async () => { @@ -272,9 +359,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -316,9 +405,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -360,9 +451,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -407,9 +500,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -451,9 +546,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -507,9 +604,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -548,9 +647,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -593,9 +694,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -633,9 +736,11 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), + emitSessionReady: vi.fn(), emitMessagesConsumed: vi.fn() } as unknown as ApiSessionClient; diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 58b611bee1..add205fd8c 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -20,6 +20,7 @@ import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class CursorAcpRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; @@ -33,6 +34,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; private modelApplySeq = 0; + private instructionsSent = false; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -64,7 +66,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { backend.onStderrError((error) => { logger.debug('[cursor-acp] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); + const converted = convertAgentMessage({ type: 'error', message: error.message }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(error.message, 'status'); }); @@ -72,7 +77,13 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { await backend.initialize(); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - throw new Error(`${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`); + const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`; + const converted = convertAgentMessage({ type: 'error', message: fullMsg }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(fullMsg, 'status'); + throw new Error(fullMsg); } await backend.authenticateIfAvailable('cursor_login'); @@ -105,7 +116,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { mcpServers: mcpServerList }); } catch (error) { - logger.warn('[cursor-acp] session/load failed', error); + logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error)); throw new Error( 'Failed to resume Cursor ACP session. Legacy stream-json sessions cannot be loaded via ACP.' ); @@ -123,8 +134,22 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { if (acpSessionId !== resumeSessionId) { session.onSessionFoundWithProtocol(acpSessionId, 'acp'); + // tiann/hapi#913: block until the metadata write that pins + // `cursorSessionId` reaches the hub DB before we drop into + // `runMainLoop`. If SIGTERM (hub-restart cascade) lands during + // the first turn without this gate, the only durable handle + // linking the session to its on-disk ACP store is lost and the + // session strands. The resume path at lines 98-100 already + // relies on the latency of `backend.loadSession()` to flush the + // same write; the fresh-session path has no such cover. + const flushed = await session.client.flushMetadata(); + if (!flushed) { + logger.warn(`[cursor-acp] cursorSessionId metadata write did not ACK within 5s; session may be unrecoverable if killed before the lock drains (acpSessionId=${acpSessionId})`); + } } + session.client.emitSessionReady(); + syncCursorModelsFromAcp(backend, acpSessionId); const initialMetadata = backend.getSessionModelsMetadata(acpSessionId); @@ -188,9 +213,15 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode as PermissionMode); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -202,11 +233,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } catch (error) { logger.warn('[cursor-acp] prompt failed', error); const errMsg = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ - type: 'message', - message: `Cursor Agent failed: ${errMsg}` - }); - messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + const message = `Cursor Agent failed: ${errMsg}`; + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(message, 'status'); } finally { session.onThinkingChange(false); await this.permissionAdapter?.cancelAll('Prompt finished'); @@ -269,6 +301,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { case 'plan': this.messageBuffer.addMessage('Plan updated', 'status'); break; + case 'error': + this.messageBuffer.addMessage(message.message, 'status'); + break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': break; default: @@ -436,6 +474,34 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } +function formatAcpLoadError(error: unknown): Record { + if (error instanceof Error) { + const record: Record = { + name: error.name, + message: error.message + }; + const code = (error as Error & { code?: unknown }).code; + if (code !== undefined) { + record.code = code; + } + const data = (error as Error & { data?: unknown }).data; + if (data !== undefined) { + record.data = data; + } + const cause = error.cause; + if (cause !== undefined) { + record.cause = cause instanceof Error + ? { name: cause.name, message: cause.message } + : cause; + } + return record; + } + if (typeof error === 'object' && error !== null) { + return { ...(error as Record) }; + } + return { message: String(error) }; +} + function isSpawnDefaultModel(modelId: string): boolean { const normalized = modelId.trim().toLowerCase(); return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 32d8116265..9b18c6c622 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -12,7 +12,12 @@ vi.mock('@/ui/logger', () => ({ })); vi.mock('@/agent/messageConverter', () => ({ - convertAgentMessage: () => null + convertAgentMessage: (message: { type: string; message?: string }) => { + if (message.type === 'error' && typeof message.message === 'string') { + return { type: 'error', message: message.message }; + } + return null; + } })); vi.mock('@/ui/ink/OpencodeDisplay', () => ({ @@ -145,9 +150,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(2); - const messages = client.sendSessionEvent.mock.calls + const messages = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messages).toHaveLength(1); expect(messages[0].message).toContain('Cursor authentication expired'); expect(messages[0].message).toContain("'agent login'"); @@ -184,9 +189,9 @@ describe('cursorLegacyRemoteLauncher', () => { const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); await cursorLegacyRemoteLauncher(session); - const messages = client.sendSessionEvent.mock.calls + const messages = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messages).toHaveLength(1); expect(messages[0].message).toContain('rate limit'); expect(messages[0].message).toContain('queued and will retry'); @@ -209,9 +214,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(1); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messageEvents).toHaveLength(1); expect(messageEvents[0].message).toContain('Agent exited (134)'); expect(messageEvents[0].message).toContain('Segmentation fault'); @@ -242,9 +247,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(1); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messageEvents).toHaveLength(1); expect(messageEvents[0].message).toContain('Agent exited (143)'); expect(messageEvents[0].message).not.toContain('queued and will retry'); @@ -307,9 +312,9 @@ describe('cursorLegacyRemoteLauncher', () => { expect(spawnMock).toHaveBeenCalledTimes(5); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); // 4 transient retry banners + 1 drop banner = 5 expect(messageEvents).toHaveLength(5); const banners = messageEvents.map((e: any) => e.message); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index b8f80e856a..7e222886db 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -220,15 +220,22 @@ class CursorRemoteLauncher extends RemoteLauncherBase { this.consecutiveTransientFailures = 0; const errMsg = `Agent exited (${exitCode}): ${truncateStderrForDisplay(stderr)}`; logger.warn(`[cursor-remote] ${errMsg}`); - session.sendSessionEvent({ type: 'message', message: errMsg }); + const converted = convertAgentMessage({ type: 'error', message: errMsg }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(errMsg, 'status'); } } catch (error) { this.consecutiveTransientFailures = 0; logger.warn('[cursor-remote] Agent run failed', error); const errMsg = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); - messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + const message = `Cursor Agent failed: ${errMsg}`; + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(message, 'status'); } finally { session.onThinkingChange(false); if (session.queue.size() === 0 && !this.shouldExit) { @@ -317,7 +324,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase { `[cursor-remote] transient agent failures hit cap (${MAX_CONSECUTIVE_TRANSIENT_FAILURES}); dropping message`, { exitCode, stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) } ); - session.sendSessionEvent({ type: 'message', message: dropMsg }); + const converted = convertAgentMessage({ type: 'error', message: dropMsg }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(dropMsg, 'status'); this.consecutiveTransientFailures = 0; return; @@ -343,7 +353,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase { session.queue.unshift(message, mode); } const friendly = friendlyTransientMessage(exitCode, stderr); - session.sendSessionEvent({ type: 'message', message: friendly }); + const converted = convertAgentMessage({ type: 'error', message: friendly }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(friendly, 'status'); await this.transientBackoff(getTransientBackoffMs()); } diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index 2e26a24b61..b08ce2aa37 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -2,6 +2,7 @@ import { logger } from '@/ui/logger'; import { cursorLocal } from './cursorLocal'; import { CursorSession } from './session'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; +import { convertAgentMessage } from '@/agent/messageConverter'; function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } { if (mode === 'plan') { @@ -46,7 +47,10 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit }); }, sendFailureMessage: (message) => { - session.sendSessionEvent({ type: 'message', message }); + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } }, recordLocalLaunchFailure: (message, exitReason) => { session.recordLocalLaunchFailure(message, exitReason); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index f5508f3429..a855a071fa 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -81,7 +81,7 @@ export async function runCursor(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts index 119ab57feb..6b9d0f6423 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -25,7 +25,8 @@ export function createCursorAcpBackend(opts: { cwd: string; model?: string | nul return new AcpSdkBackend({ command: 'agent', args, - env: filterEnv(process.env) + env: filterEnv(process.env), + flavor: 'cursor', }); } diff --git a/cli/src/gemini/geminiRemoteLauncher.ts b/cli/src/gemini/geminiRemoteLauncher.ts index fa6e3cb5c8..83c70c86fa 100644 --- a/cli/src/gemini/geminiRemoteLauncher.ts +++ b/cli/src/gemini/geminiRemoteLauncher.ts @@ -10,6 +10,7 @@ import type { PermissionMode } from './types'; import { createGeminiBackend } from './utils/geminiBackend'; import { GeminiPermissionHandler } from './utils/permissionHandler'; import { resolveGeminiRuntimeConfig } from './utils/config'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class GeminiRemoteLauncher extends RemoteLauncherBase { private readonly session: GeminiSession; @@ -23,6 +24,7 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { private displayPermissionMode: PermissionMode | null = null; private currentBackendModel: string | null = null; private setModelSupported: boolean | undefined = undefined; + private instructionsSent = false; constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) { super(process.env.DEBUG ? session.logPath : undefined); @@ -162,9 +164,15 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -240,6 +248,9 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index b6ef8e58b3..9b9b3be4b5 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -54,7 +54,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 34b13026e2..c2e667225e 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -113,7 +113,7 @@ export async function runGemini(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/gemini/utils/geminiBackend.ts b/cli/src/gemini/utils/geminiBackend.ts index 392f8fc9a6..cdbc97cfd3 100644 --- a/cli/src/gemini/utils/geminiBackend.ts +++ b/cli/src/gemini/utils/geminiBackend.ts @@ -45,6 +45,7 @@ export function createGeminiBackend(opts: { return new AcpSdkBackend({ command: 'gemini', args, - env: filterEnv(env) + env: filterEnv(env), + flavor: 'gemini', }); } diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts index 21fb438f8a..c5ca62d2bb 100644 --- a/cli/src/kimi/kimiRemoteLauncher.ts +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -10,6 +10,7 @@ import type { PermissionMode } from './types'; import { createKimiBackend } from './utils/kimiBackend'; import { KimiPermissionHandler } from './utils/permissionHandler'; import { resolveKimiRuntimeConfig } from './utils/config'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class KimiRemoteLauncher extends RemoteLauncherBase { private readonly session: KimiSession; @@ -23,6 +24,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { private currentBackendModel: string | null = null; private setModelSupported: boolean | undefined = undefined; private lastDisplayedToolCall = new Map(); + private instructionsSent = false; constructor(session: KimiSession, opts: { model?: string }) { super(process.env.DEBUG ? session.logPath : undefined); @@ -156,9 +158,15 @@ class KimiRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -236,6 +244,9 @@ class KimiRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/kimi/runKimi.ts b/cli/src/kimi/runKimi.ts index 97cc3703bc..f148b880de 100644 --- a/cli/src/kimi/runKimi.ts +++ b/cli/src/kimi/runKimi.ts @@ -82,7 +82,7 @@ export async function runKimi(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/kimi/utils/kimiBackend.ts b/cli/src/kimi/utils/kimiBackend.ts index e99563b031..6705f30a11 100644 --- a/cli/src/kimi/utils/kimiBackend.ts +++ b/cli/src/kimi/utils/kimiBackend.ts @@ -25,6 +25,7 @@ export function createKimiBackend(opts: { return new AcpSdkBackend({ command: 'kimi', args: ['acp'], - env + env, + flavor: 'kimi', }); } diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts new file mode 100644 index 0000000000..d3953eff1b --- /dev/null +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -0,0 +1,29 @@ +import { trimIdent } from '@/utils/trimIdent'; + +/** + * Shared display_image MCP tool hints — one export per tool naming convention. + * Inject into flavor system prompts and first-prompt bridge instructions. + */ +export const DISPLAY_IMAGE_PROMPT_CLAUDE = trimIdent(` + When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. +`); + +export const DISPLAY_IMAGE_PROMPT_CODEX = trimIdent(` + When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. +`); + +export const DISPLAY_IMAGE_PROMPT_HAPI_MCP = trimIdent(` + When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. +`); + +export const DISPLAY_VIDEO_PROMPT_CLAUDE = trimIdent(` + When you create or find a local mp4 or webm recording the user should see, call the tool "mcp__hapi__display_video" with the file path so HAPI can show it inline. +`); + +export const DISPLAY_VIDEO_PROMPT_CODEX = trimIdent(` + When you create or find a local mp4 or webm file the user should see, call functions.hapi__display_video with the file path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_video, mcp__hapi__display_video, or hapi_display_video. +`); + +export const DISPLAY_VIDEO_PROMPT_HAPI_MCP = trimIdent(` + When you create or find a local mp4 or webm recording the user should see, call the tool "hapi_display_video" with the file path so HAPI can show it inline. +`); diff --git a/cli/src/modules/common/generatedImages.test.ts b/cli/src/modules/common/generatedImages.test.ts index 471539e41e..427fb32af8 100644 --- a/cli/src/modules/common/generatedImages.test.ts +++ b/cli/src/modules/common/generatedImages.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { clearGeneratedImages, detectImageMimeType, getGeneratedImage, registerGeneratedImage } from './generatedImages' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { clearGeneratedImages, detectImageMimeType, detectVideoMimeType, getGeneratedImage, registerGeneratedImage, registerGeneratedImageFromAcpBlock, registerGeneratedImageFromPath } from './generatedImages' describe('generatedImages', () => { it('detects supported image MIME types from file bytes', () => { @@ -10,6 +13,12 @@ describe('generatedImages', () => { expect(detectImageMimeType(Buffer.from([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]))).toBe('image/avif') }) + it('detects supported video MIME types from file bytes', () => { + expect(detectVideoMimeType(Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]))).toBe('video/mp4') + expect(detectVideoMimeType(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))).toBe('video/webm') + expect(detectVideoMimeType(Buffer.from([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]))).toBeNull() + }) + it('rejects non-image bytes even if the path has an image extension', () => { expect(detectImageMimeType(Buffer.from('not really a png'))).toBeNull() }) @@ -47,7 +56,7 @@ describe('generatedImages', () => { path: '/tmp/large.png', mimeType: 'image/png', bytes: new Uint8Array(25 * 1024 * 1024 + 1) - })).toThrow('Image is too large to display inline') + })).toThrow('File is too large to display inline') clearGeneratedImages() }) @@ -67,4 +76,41 @@ describe('generatedImages', () => { clearGeneratedImages() }) + it('registers images from ACP base64 image blocks after MIME sniffing', async () => { + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]) + const image = await registerGeneratedImageFromAcpBlock({ + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64') + }) + + expect(image?.mimeType).toBe('image/png') + expect(getGeneratedImage(image!.id)?.content.subarray(0, 8)).toEqual(pngHeader.subarray(0, 8)) + clearGeneratedImages() + }) + + it('registers images from local file paths in ACP uri blocks', async () => { + const dir = join(tmpdir(), `hapi-acp-image-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'inline.png') + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + writeFileSync(path, bytes) + + const image = await registerGeneratedImageFromPath({ path }) + expect(image?.mimeType).toBe('image/png') + clearGeneratedImages() + }) + + it('registers mp4 from local file paths after MIME sniffing', async () => { + const dir = join(tmpdir(), `hapi-inline-mp4-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'inline.mp4') + const bytes = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) + writeFileSync(path, bytes) + + const video = await registerGeneratedImageFromPath({ path }) + expect(video?.mimeType).toBe('video/mp4') + clearGeneratedImages() + }) + }) diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index fdf6cb9441..247f4608bd 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -1,4 +1,8 @@ -import { basename } from 'path' +import { basename } from 'node:path' +import { fileURLToPath } from 'node:url' +import { lstat, readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { asString, isObject } from '@hapi/protocol' export type GeneratedImageMetadata = { id: string @@ -8,7 +12,7 @@ export type GeneratedImageMetadata = { createdAt: number } -const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 +export const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_GENERATED_IMAGE_TOTAL_BYTES = 100 * 1024 * 1024 const MAX_GENERATED_IMAGE_COUNT = 100 @@ -55,6 +59,30 @@ export function detectImageMimeType(bytes: Uint8Array): string | null { return null } +export function detectVideoMimeType(bytes: Uint8Array): string | null { + if (bytes.length >= 12 && ascii(bytes, 4, 8) === 'ftyp') { + const brand = ascii(bytes, 8, 12) + if (brand === 'avif' || brand === 'avis') { + return null + } + return 'video/mp4' + } + + if (bytes.length >= 4 + && bytes[0] === 0x1a + && bytes[1] === 0x45 + && bytes[2] === 0xdf + && bytes[3] === 0xa3) { + return 'video/webm' + } + + return null +} + +export function isInlineMediaMimeType(mimeType: string): boolean { + return mimeType.startsWith('image/') || mimeType.startsWith('video/') +} + function ascii(bytes: Uint8Array, start: number, end: number): string { return String.fromCharCode(...bytes.subarray(start, end)) } @@ -62,7 +90,11 @@ function ascii(bytes: Uint8Array, start: number, end: number): string { export function registerGeneratedImage(args: { id: string; path: string; mimeType: string; bytes: Uint8Array; fileName?: string | null }): GeneratedImageMetadata { const content = Buffer.from(args.bytes) if (content.byteLength > MAX_GENERATED_IMAGE_BYTES) { - throw new Error('Image is too large to display inline') + throw new Error('File is too large to display inline') + } + + if (!isInlineMediaMimeType(args.mimeType)) { + throw new Error('Unsupported inline media MIME type') } const previous = generatedImages.get(args.id) @@ -105,3 +137,92 @@ export function clearGeneratedImages(): void { generatedImages.clear() generatedImageBytes = 0 } + +export async function registerGeneratedImageFromPath(args: { + id?: string + path: string + fileName?: string | null +}): Promise { + try { + const info = await lstat(args.path) + if (!info.isFile()) { + throw new Error('Path is not a regular file') + } + if (info.size > MAX_GENERATED_IMAGE_BYTES) { + throw new Error('Image is too large to display inline') + } + const bytes = await readFile(args.path) + const mimeType = detectImageMimeType(bytes) ?? detectVideoMimeType(bytes) + if (!mimeType) { + throw new Error('Unsupported inline media content') + } + return registerGeneratedImage({ + id: args.id ?? randomUUID(), + path: args.path, + fileName: args.fileName, + mimeType, + bytes + }) + } catch { + return null + } +} + +function parseAcpImageUri(uri: string): string | null { + if (uri.startsWith('file://')) { + try { + return fileURLToPath(uri) + } catch { + return null + } + } + if (/^https?:\/\//i.test(uri)) { + return null + } + return uri +} + +export async function registerGeneratedImageFromAcpBlock(block: unknown): Promise { + if (!isObject(block) || block.type !== 'image') { + return null + } + + const data = asString(block.data) + const declaredMimeType = asString(block.mimeType ?? block.mime_type) + const uri = asString(block.uri ?? block.url) + + if (data) { + const bytes = Buffer.from(data, 'base64') + if (bytes.byteLength > MAX_GENERATED_IMAGE_BYTES) { + return null + } + const sniffedMimeType = detectImageMimeType(bytes) + if (!sniffedMimeType) { + return null + } + if (declaredMimeType && declaredMimeType !== sniffedMimeType) { + return null + } + const path = uri ? parseAcpImageUri(uri) ?? uri : `${randomUUID()}.bin` + return registerGeneratedImage({ + id: randomUUID(), + path, + fileName: basename(path), + mimeType: sniffedMimeType, + bytes + }) + } + + if (uri) { + const path = parseAcpImageUri(uri) + if (!path) { + return null + } + return registerGeneratedImageFromPath({ + path, + fileName: basename(path) + }) + } + + return null +} diff --git a/cli/src/modules/common/hapiMcpBridgePrompt.ts b/cli/src/modules/common/hapiMcpBridgePrompt.ts new file mode 100644 index 0000000000..92f2af8697 --- /dev/null +++ b/cli/src/modules/common/hapiMcpBridgePrompt.ts @@ -0,0 +1,16 @@ +import { trimIdent } from '@/utils/trimIdent'; +import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP } from './displayImagePrompt'; + +/** + * Title + display_image / display_video instructions for ACP flavors wired through buildHapiMcpBridge + * (Gemini, Kimi, Cursor, OpenCode). Prepended on the first user prompt. + */ +export const HAPI_MCP_TITLE_INSTRUCTION = trimIdent(` + Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. +`); + +export const HAPI_MCP_BRIDGE_PROMPT = trimIdent(` + ${HAPI_MCP_TITLE_INSTRUCTION} + ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} + ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} +`); diff --git a/cli/src/modules/common/inlineMediaSource.test.ts b/cli/src/modules/common/inlineMediaSource.test.ts new file mode 100644 index 0000000000..abe470e5f5 --- /dev/null +++ b/cli/src/modules/common/inlineMediaSource.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { inlineMediaSourceFromWire } from './inlineMediaSource'; + +describe('inlineMediaSourceFromWire', () => { + it('parses ingress and snake_case tool fields', () => { + expect(inlineMediaSourceFromWire({ + ingress: 'mcp', + flavor: 'cursor', + tool_call_id: 'call-1', + tool_name: 'display_video', + })).toEqual({ + ingress: 'mcp', + flavor: 'cursor', + toolCallId: 'call-1', + toolName: 'display_video', + }); + }); + + it('accepts legacy path alias for ingress', () => { + expect(inlineMediaSourceFromWire({ path: 'acp' })).toEqual({ ingress: 'acp' }); + }); +}); diff --git a/cli/src/modules/common/inlineMediaSource.ts b/cli/src/modules/common/inlineMediaSource.ts new file mode 100644 index 0000000000..d3a96026f5 --- /dev/null +++ b/cli/src/modules/common/inlineMediaSource.ts @@ -0,0 +1,28 @@ +/** How inline image/video entered the session (v1 provenance seed for #956 / artifact follow-up). */ +export type InlineMediaIngress = 'mcp' | 'acp' | 'tool_result'; + +export type InlineMediaSource = { + ingress: InlineMediaIngress; + flavor?: string; + toolCallId?: string; + toolName?: string; +}; + +export function inlineMediaSourceFromWire(value: unknown): InlineMediaSource | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const ingress = record.ingress ?? record.path; + if (ingress !== 'mcp' && ingress !== 'acp' && ingress !== 'tool_result') return undefined; + const flavor = typeof record.flavor === 'string' ? record.flavor : undefined; + const toolCallId = typeof record.toolCallId === 'string' + ? record.toolCallId + : typeof record.tool_call_id === 'string' + ? record.tool_call_id + : undefined; + const toolName = typeof record.toolName === 'string' + ? record.toolName + : typeof record.tool_name === 'string' + ? record.tool_name + : undefined; + return { ingress, flavor, toolCallId, toolName }; +} diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index f70908f5eb..e03908405e 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -19,14 +19,18 @@ export type AutoApprovalRuleSet = { const AUTO_APPROVE_TOOL_NAME_HINTS = [ 'change_title', + 'display_image', + 'display_video', 'happy__change_title', 'hapi_change_title', // OpenCode MCP tool pattern + 'hapi_display_image', + 'hapi_display_video', 'geminireasoning', 'codexreasoning', 'think', 'save_memory' ]; -const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; +const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'display_image', 'display_video', 'save_memory']; const AUTO_APPROVE_WRITE_TOOL_HINTS = ['write', 'edit', 'create', 'delete', 'patch', 'fs-edit']; export function resolveToolAutoApprovalDecision( diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index b911fbca07..0abca7e06b 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -360,6 +360,9 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 41ef11adc9..4537ca0a06 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -58,7 +58,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index a78f931195..3e89e02a80 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -107,7 +107,7 @@ export async function runOpencode(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index b49a345596..988035a738 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -21,6 +21,7 @@ export function createOpencodeBackend(opts: { return new AcpSdkBackend({ command: 'opencode', args, - env: filterEnv(env) + env: filterEnv(env), + flavor: 'opencode', }); } diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index e968759626..feb6d02d29 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -1,19 +1,17 @@ /** - * OpenCode-specific system prompt for change_title tool. + * OpenCode-specific system prompt for hapi MCP tools (change_title, display_image, display_video). * * OpenCode exposes MCP tools with the naming pattern: _ - * The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`. + * The hapi MCP server exposes `change_title`, `display_image`, and `display_video`. */ import { trimIdent } from '@/utils/trimIdent'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; /** - * Title instruction for OpenCode to call the hapi MCP tool. + * Title and display_image instructions for OpenCode to call the hapi MCP tools. */ -export const TITLE_INSTRUCTION = trimIdent(` - Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. -`); +export const TITLE_INSTRUCTION = HAPI_MCP_BRIDGE_PROMPT; /** * The system prompt to inject for OpenCode sessions. diff --git a/cli/src/pi/loop.test.ts b/cli/src/pi/loop.test.ts new file mode 100644 index 0000000000..626abcd0c1 --- /dev/null +++ b/cli/src/pi/loop.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import type { PiResponseEvent } from './types'; +import { PiSession } from './session'; +import { PiTransport } from './piTransport'; +import type { PiThinkingLevel } from './types'; + +// Mock logger +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +// Mock message converter chain +vi.mock('@/agent/messageConverter', () => ({ + convertAgentMessage: vi.fn((msg) => msg), +})); + +vi.mock('./PiEventConverter', () => ({ + convertPiEvent: vi.fn(() => []), +})); + +vi.mock('./piMessageAccumulator', () => { + return { + PiMessageAccumulator: class { + handleEvent = vi.fn(() => []); + }, + }; +}); + +function createMockSession(): PiSession { + return new PiSession({ + api: {} as any, + client: { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + emitMessagesConsumed: vi.fn(), + sendSessionEvent: vi.fn(), + } as any, + path: '/tmp/test', + logPath: '/tmp/test.log', + startedBy: 'terminal', + startingMode: 'local', + }); +} + +// --- parsePiModels --- + +describe('parsePiModels', () => { + it('returns empty for non-array input', () => { + expect(parsePiModels(null)).toEqual([]); + expect(parsePiModels({})).toEqual([]); + expect(parsePiModels('not array')).toEqual([]); + }); + + it('parses valid model list', () => { + const data = { + models: [ + { id: 'gpt-4o', provider: 'openai', name: 'GPT-4o', contextWindow: 128000 }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { provider: 'openai', modelId: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('parses reasoning and thinkingLevelMap', () => { + const data = { + models: [ + { + id: 'claude-sonnet-4', + provider: 'anthropic', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { id: 'gpt-4o', provider: 'openai', reasoning: false }, + { id: 'deepseek-r1', provider: 'deepseek', thinkingLevelMap: {} }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { + provider: 'anthropic', + modelId: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { provider: 'openai', modelId: 'gpt-4o', reasoning: false }, + { provider: 'deepseek', modelId: 'deepseek-r1' }, + ]); + }); + + it('ignores non-boolean reasoning and invalid thinkingLevelMap', () => { + const data = { + models: [ + { id: 'm1', reasoning: 'yes', thinkingLevelMap: 'not-an-object' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); + + it('filters out models with empty id', () => { + const data = { + models: [ + { id: '', provider: 'openai' }, + { id: 'gpt-4o', provider: 'openai' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + ]); + }); + + it('defaults unknown provider', () => { + const data = { models: [{ id: 'model-1' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'model-1' }, + ]); + }); + + it('skips non-object entries', () => { + const data = { models: [null, 'string', 42, { id: 'valid' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'valid' }, + ]); + }); + + it('ignores non-string name and non-number contextWindow', () => { + const data = { + models: [ + { id: 'm1', name: 123, contextWindow: 'big' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); +}); + +// --- parsePiCommands --- + +describe('parsePiCommands', () => { + it('returns empty for non-array input', () => { + expect(parsePiCommands(null)).toEqual([]); + expect(parsePiCommands({})).toEqual([]); + }); + + it('parses valid command list', () => { + const data = { + commands: [ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ], + }; + const result = parsePiCommands(data); + expect(result).toEqual([ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ]); + }); + + it('defaults unknown source to skill', () => { + const data = { commands: [{ name: 'cmd', source: 'unknown_source' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'cmd', source: 'skill' }, + ]); + }); + + it('filters out commands with empty name', () => { + const data = { commands: [{ name: '', source: 'skill' }, { name: 'valid', source: 'skill' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'valid', source: 'skill' }, + ]); + }); + + it('omits non-string description', () => { + const data = { commands: [{ name: 'cmd', description: 123 }] }; + expect(parsePiCommands(data)).toEqual([{ name: 'cmd', source: 'skill' }]); + }); +}); + +// --- wireTransportEvents (integration) --- + +describe('wireTransportEvents', () => { + let session: PiSession; + let eventHandlers: Map void>; + + function createMockTransport(): PiTransport { + eventHandlers = new Map(); + return { + onEvent: vi.fn((handler) => { eventHandlers.set('event', handler); }), + send: vi.fn(), + } as unknown as PiTransport; + } + + beforeEach(() => { + vi.clearAllMocks(); + session = createMockSession(); + }); + + function emitEvent(event: Record): void { + const handler = eventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!(event); + } + + it('handles get_state response — updates model, provider, thinkingLevel', () => { + const transport = createMockTransport(); + const pendingLocalIds: string[] = []; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ + type: 'response', + command: 'get_state', + success: true, + data: { + model: { modelId: 'gpt-4o', provider: 'openai' }, + sessionId: 'pi-session-1', + thinkingLevel: 'high', + steeringMode: 'one-at-a-time', + }, + }); + + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + expect(session.currentThinkingLevel).toBe('high'); + expect(session.currentSteeringMode).toBe('one-at-a-time'); + expect(session.client.updateMetadata).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('handles error response — sends session event', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'prompt', + success: false, + error: 'Pi crashed', + }); + + expect(session.client.sendSessionEvent).toHaveBeenCalledWith({ + type: 'message', + message: 'Pi crashed', + }); + }); + + it('handles agent_start — sets thinking state, does NOT drain pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-1', 'id-2']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + + // agent_start precedes turn_start in a real Pi turn; draining here + // would double-pop the FIFO (see regression test below). + expect(pendingLocalIds).toEqual(['id-1', 'id-2']); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalled(); + }); + + it('handles turn_start — pops pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-turn-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['id-turn-1'], undefined); + }); + + it('regression: agent_start + turn_start in one turn drains exactly one localId', () => { + // Pi emits agent_start then turn_start back-to-back per prompt. + // Only turn_start should drain — agent_start must not. + const transport = createMockTransport(); + const pendingLocalIds = ['prompt-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + // Exactly one drain call with a real id — never an undefined. + expect(session.client.emitMessagesConsumed).toHaveBeenCalledTimes(1); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['prompt-1'], undefined); + }); + + it('handles turn_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'turn_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles agent_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'agent_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles get_available_models response — caches models', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_available_models', + success: true, + data: { + models: [ + { id: 'gpt-4o', provider: 'openai' }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }, + }); + + expect(session.cachedPiModels).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('handles get_commands response — caches commands', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_commands', + success: true, + data: { + commands: [ + { name: 'analyze', source: 'skill' }, + ], + }, + }); + + expect(session.cachedPiCommands).toEqual([ + { name: 'analyze', source: 'skill' }, + ]); + }); + + it('handles keep_alive — no side effects', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = false; + emitEvent({ type: 'keep_alive' }); + + // keep_alive should not trigger any session mutations + expect(session.client.sendAgentMessage).not.toHaveBeenCalled(); + expect(session.piIsStreaming).toBe(false); + }); + + it('handles set_model response — updates model and provider', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'set_model', + success: true, + data: { modelId: 'new-model', provider: 'new-provider' }, + }); + + expect(session.currentModel).toBe('new-model'); + expect(session.currentProvider).toBe('new-provider'); + }); +}); + +// --- sendPiRpcAndWait (contract: await <-> resolve symmetry) --- +// +// SetSessionConfig awaits set_model and set_thinking_level. Fix #9 was caused +// by a switch branch that updated state but never resolved the pending RPC - +// the promise hit the 10s timeout and /sessions/:id/model returned 409 even +// though Pi accepted the change. These tests pin the contract: every awaited +// command must resolve before the timeout when Pi emits a success response. + +describe('sendPiRpcAndWait', () => { + it('throws synchronously when resolver not initialized', () => { + // sendPiRpcAndWait is a sync wrapper (not async), so the guard at + // loop.ts throws before a promise is created — assert with toThrow, + // not rejects. + const mockTransport = { send: vi.fn(), onEvent: vi.fn() } as unknown as PiTransport; + const session = createMockSession(); + // No wireTransportEvents -> resolver is null + expect(() => sendPiRpcAndWait(session, mockTransport, { type: 'test' }, 100)) + .toThrow('Pi RPC resolver not initialized'); + }); + + // Helper: a transport whose send() captures the outgoing id so the test can + // emit the matching response, simulating Pi's reply. + function recordingTransport(onEventHandlers: Map void>) { + const sent: Array> = []; + return { + transport: { + onEvent: vi.fn((handler) => { onEventHandlers.set('event', handler); }), + send: vi.fn((msg: Record) => { sent.push(msg); }), + } as unknown as PiTransport, + sent, + // Emit the Pi response for the last sent command, echoing its id. + reply(response: { command: string; success: boolean; data?: unknown; error?: string }) { + const last = sent[sent.length - 1]; + const handler = onEventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!({ type: 'response', id: last.id, ...response }); + }, + }; + } + + it('set_model response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'openai', modelId: 'gpt-4o', + }, 10_000); + + // Simulate Pi confirming the model change. + reply({ command: 'set_model', success: true, data: { modelId: 'gpt-4o', provider: 'openai' } }); + + // Must resolve (not reject with 'timed out') - the contract Fix #9 restored. + await expect(promise).resolves.toEqual({ modelId: 'gpt-4o', provider: 'openai' }); + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + }); + + it('set_thinking_level response resolves the awaited promise before timeout', async () => { + // Fix #9 symmetry: set_thinking_level is awaited by SetSessionConfig. + // Without an explicit resolve it fell to the `default` branch; if anyone + // later adds business logic to a new case without resolving first, the + // effort switch would time out and /sessions/:id/effort would 409. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_thinking_level', level: 'high', + }, 10_000); + + reply({ command: 'set_thinking_level', success: true }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('get_available_models response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { type: 'get_available_models' }, 10_000); + + reply({ command: 'get_available_models', success: true, data: { models: [{ id: 'gpt-4o', provider: 'openai' }] } }); + + await expect(promise).resolves.toEqual({ models: [{ id: 'gpt-4o', provider: 'openai' }] }); + }); + + it('Pi error response rejects the awaited promise', async () => { + // SetSessionConfig awaits so a rejected set_model bubbles up to the web + // request (409) instead of reporting success while Pi kept old state. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'bad', modelId: 'nope', + }, 10_000); + + reply({ command: 'set_model', success: false, error: 'Unknown provider: bad' }); + + await expect(promise).rejects.toThrow('Unknown provider: bad'); + }); + + it('rejects with timeout when Pi never responds', async () => { + const handlers = new Map void>(); + const { transport } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + // No reply emitted -> must time out (guards against hangs). + await expect(sendPiRpcAndWait(session, transport, { type: 'test' }, 100)) + .rejects.toThrow('timed out'); + }); +}); diff --git a/cli/src/pi/loop.ts b/cli/src/pi/loop.ts new file mode 100644 index 0000000000..d4f48256f4 --- /dev/null +++ b/cli/src/pi/loop.ts @@ -0,0 +1,312 @@ +import { logger } from '@/ui/logger'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { PiTransport } from './piTransport'; +import { convertPiEvent } from './piEventConverter'; +import { PiMessageAccumulator } from './piMessageAccumulator'; +import { parsePiModels, parsePiCommands, PiResponseEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas'; +import type { PiResponseEvent, PiRpcCommand, PiThinkingLevel } from './types'; +import type { PiSession } from './session'; + +// --- Response parsers: re-exported from schemas.ts --- +export { parsePiModels, parsePiCommands } from './schemas'; + +// --- Pending RPC resolver --- +// Instance-scoped: created once by wireTransportEvents, stored on PiSession. +export class PiRpcResolver { + private idCounter = 0; + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + + sendAndWait(transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + const id = ++this.idCounter; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${command.type} (id=${id}) timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (data) => { clearTimeout(timer); this.pending.delete(id); resolve(data); }, + reject: (error) => { clearTimeout(timer); this.pending.delete(id); reject(error); }, + }); + + transport.send({ ...command, id: String(id) } as unknown as PiRpcCommand); + }); + } + + resolveResponse(raw: unknown): void { + const parsed = PiResponseEventSchema.safeParse(raw); + if (!parsed.success) return; + const response = parsed.data; + const rawId = response.id; + if (rawId !== undefined) { + const numericId = Number(rawId); + if (!Number.isNaN(numericId)) { + const resolver = this.pending.get(numericId); + if (resolver) { + if (response.success) { + resolver.resolve(response.data); + } else { + resolver.reject(new Error(response.error ?? 'Unknown error')); + } + } + } + } + } +} + +export function sendPiRpcAndWait(session: PiSession, transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + if (!session.rpcResolver) throw new Error('Pi RPC resolver not initialized'); + return session.rpcResolver.sendAndWait(transport, command, timeoutMs); +} + +function resolvePendingRpc(resolver: PiRpcResolver, response: PiResponseEvent): void { + resolver.resolveResponse(response); +} + +// Mirror the web picker's provider-qualified selection into metadata so the hub +// and web can disambiguate duplicate modelId values across providers. The web +// /sessions/:id/model path already writes piSelectedModel via persistPiSelectedModel; +// these runtime paths (get_state, startup set_model, successful set_model response) +// previously only keepAlive'd the bare modelId, so a Pi session on Pi's default model +// or started with --model could render/filter against the wrong provider. +function persistSelectedPiModel(session: PiSession): void { + const modelId = session.currentModel; + const provider = session.currentProvider; + if (!modelId || !provider) return; + session.updateMetadata((meta) => ({ + ...meta, + piSelectedModel: { provider, modelId }, + })); +} + +// --- Response handler --- + +function handleGetState( + rawData: unknown, + session: PiSession, +): void { + const parsed = PiStateDataSchema.safeParse(rawData); + if (!parsed.success) return; + const data = parsed.data; + + if (data.model) { + // Pi returns model.id (not modelId). Fallback to modelId for forward compat. + const newModel = data.model.id ?? data.model.modelId ?? session.currentModel; + if (data.model.provider && data.model.provider.length > 0) { + session.currentProvider = data.model.provider; + } + // Do NOT overwrite currentModel with the unconfirmed startup model here. + // The requested startup model is applied (and committed) only after + // get_available_models confirms it exists and Pi accepts set_model; + // reporting Pi's actual current model until then keeps the hub in sync + // if the requested model is unavailable or rejected. + session.currentModel = newModel ?? session.currentModel; + if (session.initialModel) { + logger.debug(`[pi] Startup model requested: ${session.initialModel} (will apply once available models arrive); Pi default model: ${newModel ?? 'unknown'}`); + } else if (newModel) { + logger.debug(`[pi] Initial model: ${newModel} (provider=${session.currentProvider ?? 'unknown'})`); + } + // Pi reported its actual model+provider; persist the provider-qualified + // selection so the web can disambiguate (a startup --model overrides this + // once get_available_models confirms and applies it below). + persistSelectedPiModel(session); + } + + if (data.sessionId) { + session.updateMetadata((meta) => ({ ...meta, piSessionId: data.sessionId })); + logger.debug(`[pi] Session ID persisted to metadata: ${data.sessionId}`); + } + + if (data.thinkingLevel) { + session.currentThinkingLevel = data.thinkingLevel as PiThinkingLevel; + logger.debug(`[pi] Initial thinking level: ${data.thinkingLevel}`); + } + + if (data.steeringMode) { + session.currentSteeringMode = data.steeringMode; + } +} + +function handleResponse( + response: PiResponseEvent, + session: PiSession, + pendingLocalIds: string[], + transport?: PiTransport, +): void { + const { command, success } = response; + const resolver = session.rpcResolver!; + + if (!success) { + const error = response.error ?? 'Unknown Pi error'; + logger.debug(`[pi] RPC error for ${command}: ${error}`); + resolvePendingRpc(resolver, response); + session.sendSessionEvent({ type: 'message', message: error }); + if (command === 'prompt' && pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true }); + } + return; + } + + switch (command) { + case 'get_state': { + handleGetState(response.data, session); + break; + } + case 'set_model': { + const parsed = PiSetModelDataSchema.safeParse(response.data); + if (parsed.success) { + const data = parsed.data; + const modelId = data.id ?? data.modelId; + if (modelId) { + session.currentModel = modelId; + } + if (data.provider && data.provider.length > 0) { + session.currentProvider = data.provider; + } + persistSelectedPiModel(session); + logger.debug(`[pi] Model changed to: ${modelId ?? session.currentModel}`); + } + // set_model is awaited by SetSessionConfig (Fix #9); without this + // the awaited RPC would time out and /sessions/:id/model return 409. + resolvePendingRpc(resolver, response); + break; + } + case 'set_thinking_level': { + // Awaited by SetSessionConfig (Fix #9 symmetry with set_model). + // currentThinkingLevel is maintained by the SetSessionConfig + // handler, so this branch only resolves the pending RPC — without + // it the awaited call times out and /sessions/:id/effort returns 409. + resolvePendingRpc(resolver, response); + break; + } + case 'get_available_models': { + const models = parsePiModels(response.data); + if (models.length > 0) { + session.cachedPiModels = models; + logger.debug(`[pi] Available models: ${models.map((m) => m.modelId).join(', ')}`); + session.updateMetadata((meta) => ({ + ...meta, + piAvailableModels: models, + })); + + // Apply the requested startup model only after confirming it exists + // in Pi's available models and Pi accepts set_model. Commit + // currentModel/currentProvider only on success so the hub does not + // persist a model Pi rejected or never had. Fire-and-forget the + // await so resolving the get_available_models RPC itself is not + // blocked (it may be awaited by ListPiModels). + if (session.initialModel && transport) { + const match = models.find((m) => m.modelId === session.initialModel); + if (match) { + void (async () => { + try { + await sendPiRpcAndWait(session, transport, { + type: 'set_model', + provider: match.provider, + modelId: match.modelId, + }); + session.currentModel = match.modelId; + session.currentProvider = match.provider; + persistSelectedPiModel(session); + logger.debug(`[pi] Startup model applied: ${match.provider}/${match.modelId}`); + } catch (error) { + logger.debug(`[pi] Startup model set_model rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } else { + logger.debug(`[pi] Startup model not found in available models: ${session.initialModel}`); + } + } + } + resolvePendingRpc(resolver, response); + break; + } + case 'get_commands': { + const commands = parsePiCommands(response.data); + if (commands.length > 0) { + session.cachedPiCommands = commands; + logger.debug(`[pi] Available commands: ${commands.map((c) => c.name).join(', ')}`); + } + resolvePendingRpc(resolver, response); + break; + } + case 'new_session': + logger.debug('[pi] Pi session initialized'); + break; + case 'abort': + logger.debug('[pi] Abort confirmed'); + break; + case 'prompt': + logger.debug('[pi] Prompt accepted'); + break; + default: + logger.debug(`[pi] Response for ${command}`); + resolvePendingRpc(resolver, response); + break; + } +} + +// --- Wire transport events to session --- + +export function wireTransportEvents( + transport: PiTransport, + session: PiSession, + pendingLocalIds: string[], +): void { + session.rpcResolver = new PiRpcResolver(); + const assistantMessageAccumulator = new PiMessageAccumulator(); + + transport.onEvent((event) => { + // Debug: log all event types to diagnose missing Pi output + if (event.type !== 'keep_alive') { + logger.debug(`[pi][event] ${event.type}`); + } + if (event.type === 'response') { + handleResponse(event as unknown as PiResponseEvent, session, pendingLocalIds, transport); + return; + } + + // Accumulate text/thinking deltas into snapshots, flush on message_end + const accumulated = assistantMessageAccumulator.handleEvent(event); + if (accumulated.length > 0) { + for (const msg of accumulated) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // message_start/update/end handled by accumulator — skip converter + if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') { + const messages = convertPiEvent(event); + for (const msg of messages) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // Keep-alive + streaming state tracking + // + // Pi emits agent_start and turn_start back-to-back for each prompt. + // Only turn_start marks "my prompt was accepted and a turn began", so + // the pending localId is drained there. Draining on both would pop the + // FIFO twice per prompt — once with the real id, then once with + // undefined — and ship a garbage localId to the hub. + if (event.type === 'agent_start') { + session.updateThinkingState(true); + } else if (event.type === 'turn_start') { + session.updateThinkingState(true); + if (pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId]); + } + } else if (event.type === 'turn_end') { + session.updateThinkingState(false); + } else if (event.type === 'agent_end') { + session.piIsStreaming = false; + } + }); +} diff --git a/cli/src/pi/piEventConverter.test.ts b/cli/src/pi/piEventConverter.test.ts new file mode 100644 index 0000000000..aaa0a5d088 --- /dev/null +++ b/cli/src/pi/piEventConverter.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { convertPiEvent } from './piEventConverter'; +import type { PiAgentEvent } from './types'; + +describe('convertPiEvent', () => { + it('should return empty for message_update with text_delta (accumulated in runPi)', () => { + // The converter intentionally emits nothing for message_update + // — runPi accumulates text/thinking deltas and flushes a single + // snapshot on `message_end`. This avoids the web UI rendering + // every delta as a separate block (character-by-character column) + // and the reducer's per-content streamId dedup showing only the + // last delta as the whole reasoning. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello world' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with thinking_delta (accumulated in runPi)', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me think...' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with start sub-type', () => { + // text_start/thinking_start carry the full partial state and + // would cause the web UI to render the same text multiple + // times. The accumulator only listens to deltas. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with start sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with done sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'done', reason: 'stop' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update without assistantMessageEvent', () => { + const result = convertPiEvent({ type: 'message_update' }); + expect(result).toEqual([]); + }); + + it('should convert tool_execution_start to tool_call AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_start', + toolCallId: 'tc-1', + toolName: 'read_file', + args: { path: '/foo.ts' } + }); + expect(result).toEqual([{ + type: 'tool_call', + id: 'tc-1', + name: 'read_file', + input: { path: '/foo.ts' }, + status: 'in_progress' + }]); + }); + + it('should convert tool_execution_end (success) to tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file content', + isError: false + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file content', + status: 'completed' + }]); + }); + + it('should convert tool_execution_end (error) to failed tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file not found', + isError: true + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file not found', + status: 'failed' + }]); + }); + + it('should handle tool_execution_end with missing result', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + isError: false + } as any); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: undefined, + status: 'completed' + }]); + }); + + it('should handle tool_execution_end with missing toolCallId', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolName: 'read_file', + result: 'ok', + isError: false + } as any); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_result'); + expect((result[0] as any).id).toBeUndefined(); + }); + + it('should convert turn_end to usage + turn_complete (2 messages)', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { + input: 100, + output: 200, + cacheRead: 10, + cacheWrite: 5, + totalTokens: 315 + }, + stopReason: 'stop' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'usage', + inputTokens: 100, + outputTokens: 200, + totalTokens: 315, + cacheReadTokens: 10 + }); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should convert turn_end with toolUse stopReason', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { input: 50, output: 100, cacheRead: 0, cacheWrite: 0, totalTokens: 150 }, + stopReason: 'toolUse' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'toolUse' + }); + }); + + it('should convert turn_end without usage data', () => { + const result = convertPiEvent({ + type: 'turn_end' + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should return empty array for agent_start', () => { + expect(convertPiEvent({ type: 'agent_start' })).toEqual([]); + }); + + it('should return empty array for agent_end', () => { + expect(convertPiEvent({ type: 'agent_end', messages: [] })).toEqual([]); + }); + + it('should return empty array for response events', () => { + // Response events use a different type, but we handle gracefully + expect(convertPiEvent({ type: 'response', command: 'prompt', success: true } as unknown as PiAgentEvent)).toEqual([]); + }); + + it('should return empty array for turn_start', () => { + expect(convertPiEvent({ type: 'turn_start' })).toEqual([]); + }); + + it('should return empty array for unknown event types', () => { + expect(convertPiEvent({ type: 'something_else' })).toEqual([]); + }); + + it('should not crash on unexpected data structure (safety net)', () => { + // Simulate a malformed event that somehow passes through + const weird = Object.create(null); + weird.type = 'message_update'; + weird.assistantMessageEvent = undefined; + // Should not throw + expect(() => convertPiEvent(weird as unknown as PiAgentEvent)).not.toThrow(); + expect(convertPiEvent(weird as unknown as PiAgentEvent)).toEqual([]); + }); +}); diff --git a/cli/src/pi/piEventConverter.ts b/cli/src/pi/piEventConverter.ts new file mode 100644 index 0000000000..360b83a9ba --- /dev/null +++ b/cli/src/pi/piEventConverter.ts @@ -0,0 +1,88 @@ +import { logger } from '@/ui/logger'; +import type { AgentMessage } from '@/agent/types'; +import type { + PiAgentEvent, + PiToolExecutionStartEvent, + PiToolExecutionEndEvent, + PiTurnEndEvent +} from './types'; + +/** + * Converts Pi AgentEvent to HAPI AgentMessage array. + * + * Pi events come from `pi --mode rpc` stdout as JSONL. + * Not all Pi events map to HAPI AgentMessages — response/ack events + * are handled directly by the runner, not by this converter. + */ +export function convertPiEvent(event: PiAgentEvent): AgentMessage[] { + try { + switch (event.type) { + case 'tool_execution_start': { + const e = event as PiToolExecutionStartEvent; + return [{ + type: 'tool_call', + id: e.toolCallId, + name: e.toolName, + input: e.args, + status: 'in_progress' + }]; + } + + case 'tool_execution_end': { + const e = event as PiToolExecutionEndEvent; + return [{ + type: 'tool_result', + id: e.toolCallId, + output: e.result, + status: e.isError ? 'failed' : 'completed' + }]; + } + + case 'turn_end': { + const e = event as PiTurnEndEvent; + const messages: AgentMessage[] = []; + const usage = e.message?.usage; + + if (usage) { + messages.push({ + type: 'usage', + inputTokens: usage.input ?? 0, + outputTokens: usage.output ?? 0, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheRead + }); + } + + messages.push({ + type: 'turn_complete', + stopReason: e.message?.stopReason ?? 'stop' + }); + + return messages; + } + + // Lifecycle and other events — not converted to AgentMessage. + // message_start/update/end are handled by PiMessageAccumulator + // in loop.ts before this converter is called — they never reach here, + // but are listed for exhaustive matching. + case 'agent_start': + case 'agent_end': + case 'turn_start': + case 'message_start': + case 'message_update': + case 'message_end': + case 'tool_execution_update': + case 'extension_ui_request': + case 'keep_alive': + case 'response': + return []; + + default: + logger.debug(`[pi] Unknown event type: ${event.type}`); + return []; + } + } catch (err) { + logger.debug(`[pi] convertPiEvent failed for type=${event.type}: ${err}`); + return []; + } +} diff --git a/cli/src/pi/piMessageAccumulator.test.ts b/cli/src/pi/piMessageAccumulator.test.ts new file mode 100644 index 0000000000..1631599aff --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { PiMessageAccumulator } from './piMessageAccumulator'; + +describe('PiMessageAccumulator', () => { + function makeEvent(type: string, extra: Record = {}): any { + return { type, ...extra }; + } + + it('returns empty for events that are not handled', () => { + const acc = new PiMessageAccumulator(); + expect(acc.handleEvent(makeEvent('agent_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_end'))).toEqual([]); + expect(acc.handleEvent(makeEvent('agent_end'))).toEqual([]); + }); + + it('accumulates text deltas and flushes one text message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'hello ' } + }))).toEqual([]); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'world' } + }))).toEqual([]); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'hello world' } + ]); + }); + + it('accumulates thinking deltas and flushes one reasoning message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me ' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'think...' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'let me think...', id: 'pi-stream' } + ]); + }); + + it('flushes both reasoning and text in order on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'reply' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'thinking', id: 'pi-stream' }, + { type: 'text', text: 'reply' } + ]); + }); + + it('skips empty content on flush', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'only text' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'only text' } + ]); + }); + + it('drops empty/missing deltas silently', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: '' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: ' ' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: ' ', id: 'pi-stream' } + ]); + }); + + it('uses contentIndex as streamId when provided', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'x', contentIndex: 2 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'x' } + ]); + }); + + it('updates streamId from later deltas', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'a' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'b', contentIndex: 7 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'ab' } + ]); + }); + + it('resets state on the next message_start', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'first' } + })); + acc.handleEvent(makeEvent('message_end', { message: {} })); + + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'second' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'second' } + ]); + }); + + it('flushes on turn_end as a safety net (no message_end received)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'incomplete' } + })); + // No message_end — older Pi builds, partial streams, etc. + const flushed = acc.handleEvent(makeEvent('turn_end', { + message: { usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3 } } + })); + expect(flushed).toEqual([ + { type: 'text', text: 'incomplete' } + ]); + }); + + it('does not flush on turn_end if no message_start was seen', () => { + const acc = new PiMessageAccumulator(); + const flushed = acc.handleEvent(makeEvent('turn_end', { message: {} })); + expect(flushed).toEqual([]); + }); + + it('does not flush twice on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'once' } + })); + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([ + { type: 'text', text: 'once' } + ]); + // Second message_end with no content buffered — must be empty, + // not a duplicate. + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([]); + }); + + it('ignores text_start / thinking_start / text_end / thinking_end (full snapshots cause duplicates)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'real content' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'real content' } + ]); + }); + + it('handles message_update without assistantMessageEvent', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(() => acc.handleEvent(makeEvent('message_update'))).not.toThrow(); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([]); + }); +}); diff --git a/cli/src/pi/piMessageAccumulator.ts b/cli/src/pi/piMessageAccumulator.ts new file mode 100644 index 0000000000..c8ffc1898f --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.ts @@ -0,0 +1,96 @@ +import type { AgentMessage } from '@/agent/types' +import type { PiAgentEvent, PiAssistantMessageEvent } from './types' +import { PiAssistantMessageEventSchema } from './schemas' + +/** + * Accumulates Pi assistant-message text/thinking deltas into a single + * snapshot, flushed on `message_end` (with a `turn_end` safety net). + * + * Without this, every delta would become a separate hub message, and + * the web's reducer would render the last delta as the whole reasoning + * block (the per-message content-array dedup by streamId would only + * see one snapshot) while stacking every text delta as a new agent-text + * block, producing a character-by-character column. + * + * Mirrors codex's `ReasoningProcessor`: accumulate deltas locally, + * emit one reasoning + one text message per assistant message. + */ +export class PiMessageAccumulator { + private active = false + private text = '' + private reasoning = '' + private streamId = 'pi-stream' + + /** + * Apply a Pi event to the accumulator. + * + * @returns AgentMessages to forward to the hub, if this event + * represents a flush point (`message_end` or `turn_end` with + * pending content). Returns an empty array otherwise. + */ + handleEvent(event: PiAgentEvent): AgentMessage[] { + if (event.type === 'message_start') { + this.active = true + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + return [] + } + + if (event.type === 'message_update') { + const updateEvent = event as { assistantMessageEvent?: PiAssistantMessageEvent } + const rawAme = updateEvent.assistantMessageEvent + if (!rawAme) return [] + const ameResult = PiAssistantMessageEventSchema.safeParse(rawAme) + if (!ameResult.success) return [] + const ame = ameResult.data + const streamId = ame.contentIndex?.toString() ?? 'pi-stream' + this.streamId = streamId + if (ame.type === 'text_delta' && ame.delta) { + this.text += ame.delta + } else if (ame.type === 'thinking_delta' && ame.delta) { + this.reasoning += ame.delta + } + // Other assistant message events (text_start/thinking_start/ + // text_end/thinking_end) carry the full partial state — we + // already have the deltas, so we ignore them. + return [] + } + + if (event.type === 'message_end') { + if (this.active) return this.flush() + return [] + } + + // Safety net: turn_end with pending content means the assistant + // message ended without a clean `message_end` (older Pi builds, + // partial streams, or a stream that crashed mid-flight). + if (event.type === 'turn_end' && this.active) { + return this.flush() + } + + return [] + } + + private flush(): AgentMessage[] { + const streamId = this.streamId + const reasoning = this.reasoning + const text = this.text + this.active = false + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + + const out: AgentMessage[] = [] + // Reasoning comes before text in the Pi event sequence, so emit + // in that order. Empty content is dropped so the web doesn't + // render empty bubbles. + if (reasoning) { + out.push({ type: 'reasoning', text: reasoning, id: streamId }) + } + if (text) { + out.push({ type: 'text', text }) + } + return out + } +} diff --git a/cli/src/pi/piTransport.test.ts b/cli/src/pi/piTransport.test.ts new file mode 100644 index 0000000000..be9c8c0b26 --- /dev/null +++ b/cli/src/pi/piTransport.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +const mockSpawn = vi.fn(); +vi.mock('node:child_process', () => ({ + get spawn() { return mockSpawn; } +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn() + } +})); + +function createMockProcess(): ChildProcessWithoutNullStreams & EventEmitter { + const emitter = new EventEmitter() as ChildProcessWithoutNullStreams & EventEmitter; + const stdin = new EventEmitter() as any; + stdin.write = vi.fn().mockReturnValue(true); + stdin.end = vi.fn(); + const stdout = new EventEmitter() as any; + stdout.setEncoding = vi.fn(); + const stderr = new EventEmitter() as any; + stderr.setEncoding = vi.fn(); + + emitter.stdin = stdin; + emitter.stdout = stdout; + emitter.stderr = stderr; + emitter.kill = vi.fn().mockReturnValue(true); + // pid is read-only in ChildProcess, use type assertion for mock + (emitter as any).pid = 12345; + + return emitter; +} + +const { PiTransport } = await import('./piTransport'); + +describe('PiTransport', () => { + let mockProcess: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + }); + + describe('start()', () => { + it('should spawn pi with correct args', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith('pi', ['--mode', 'rpc'], expect.objectContaining({ + cwd: '/work', + stdio: ['pipe', 'pipe', 'pipe'] + })); + }); + + it('should emit error event on ENOENT', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const errorSpy = vi.fn(); + transport.onError(errorSpy); + + const spawnError = new Error('spawn pi ENOENT') as NodeJS.ErrnoException; + spawnError.code = 'ENOENT'; + mockProcess.emit('error', spawnError); + + expect(errorSpy).toHaveBeenCalledWith(expect.any(Error)); + expect(errorSpy.mock.calls[0][0].message).toContain('not found'); + }); + + it('should ignore double-start call', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + }); + + describe('send()', () => { + it('should write JSON to stdin', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.send({ type: 'prompt', message: 'hello' }); + expect(mockProcess.stdin.write).toHaveBeenCalledWith( + JSON.stringify({ type: 'prompt', message: 'hello' }) + '\n' + ); + }); + + it('should handle EPIPE gracefully without throwing', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + mockProcess.stdin.write = vi.fn().mockImplementation(() => { + const err = new Error('write EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + throw err; + }); + + expect(() => transport.send({ type: 'prompt', message: 'test' })).not.toThrow(); + }); + }); + + describe('onEvent()', () => { + it('should parse valid JSONL from stdout and call handler', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + mockProcess.stdout.emit('data', JSON.stringify(event) + '\n'); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should skip malformed JSON and not crash', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + mockProcess.stdout.emit('data', 'not-json\n'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should handle multiple JSONL lines in one chunk', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event1 = { type: 'turn_start' }; + const event2 = { type: 'turn_end', message: {} }; + mockProcess.stdout.emit('data', JSON.stringify(event1) + '\n' + JSON.stringify(event2) + '\n'); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(event1); + expect(handler).toHaveBeenCalledWith(event2); + }); + + it('should buffer and reassemble split JSONL across chunks', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + const fullLine = JSON.stringify(event) + '\n'; + + // Split the line into two chunks — no newline in first chunk + mockProcess.stdout.emit('data', fullLine.slice(0, 20)); + expect(handler).not.toHaveBeenCalled(); + + // Send the rest with newline + mockProcess.stdout.emit('data', fullLine.slice(20)); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('kill()', () => { + it('should send SIGTERM to the process', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.kill(); + expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should be a no-op when process is not running', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + expect(() => transport.kill()).not.toThrow(); + }); + }); + + describe('onClose()', () => { + it('should call handler when subprocess exits', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', 1, null); + expect(closeHandler).toHaveBeenCalledWith(1, null); + }); + + it('should call handler with signal when killed by signal', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', null, 'SIGTERM'); + expect(closeHandler).toHaveBeenCalledWith(null, 'SIGTERM'); + }); + }); +}); diff --git a/cli/src/pi/piTransport.ts b/cli/src/pi/piTransport.ts new file mode 100644 index 0000000000..8d4a99ce70 --- /dev/null +++ b/cli/src/pi/piTransport.ts @@ -0,0 +1,123 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; +import { PiAgentEventSchema } from './schemas'; +import type { PiAgentEvent, PiRpcCommand } from './types'; + +export interface PiTransportOptions { + command: string; + args: string[]; + cwd: string; +} + +export class PiTransport extends JsonLineParser { + private process: ChildProcessWithoutNullStreams | null = null; + private eventHandler: ((event: PiAgentEvent) => void) | null = null; + private closeHandler: ((code: number | null, signal: string | null) => void) | null = null; + private errorHandler: ((error: Error) => void) | null = null; + private killed = false; + private started = false; + private exited = false; + private readonly options: PiTransportOptions; + + constructor(options: PiTransportOptions) { + super(); + this.options = options; + } + + start(): void { + if (this.started) { + logger.warn('[pi] PiTransport.start() called twice — ignoring'); + return; + } + this.started = true; + + logger.debug(`[pi] Starting Pi process: ${this.options.command} ${this.options.args.join(' ')}`); + + this.process = spawn(this.options.command, this.options.args, { + cwd: this.options.cwd, + stdio: ['pipe', 'pipe', 'pipe'] + }) as ChildProcessWithoutNullStreams; + + this.process.stdout.setEncoding('utf8'); + this.process.stdout.on('data', (chunk: string) => this.feed(chunk)); + this.process.stdout.on('end', () => { + if (!this.exited && !this.killed) { + logger.debug('[pi] stdout ended before process close — treating as exit'); + this.exited = true; + this.closeHandler?.(null, null); + } + }); + + this.process.stderr.setEncoding('utf8'); + this.process.stderr.on('data', (chunk: string) => { + logger.debug(`[pi][stderr] ${chunk.toString().trim()}`); + }); + + this.process.on('close', (code, signal) => { + logger.debug(`[pi] Process exited (code=${code}, signal=${signal})`); + this.exited = true; + this.closeHandler?.(code, signal); + }); + + this.process.on('error', (err) => { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ENOENT') { + this.errorHandler?.(new Error( + `Pi was not found on PATH. Please install Pi and retry.` + )); + } else { + this.errorHandler?.(new Error( + `Failed to start Pi: ${nodeErr.message}` + )); + } + }); + } + + send(message: PiRpcCommand): void { + if (!this.process || this.killed) { + logger.debug('[pi] Dropping message: transport not running'); + return; + } + try { + this.process.stdin.write(JSON.stringify(message) + '\n'); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EPIPE') { + logger.debug('[pi] EPIPE on write — process likely exited'); + } else { + throw err; + } + } + } + + onEvent(handler: (event: PiAgentEvent) => void): void { + this.eventHandler = handler; + } + + onClose(handler: (code: number | null, signal: string | null) => void): void { + this.closeHandler = handler; + } + + onError(handler: (error: Error) => void): void { + this.errorHandler = handler; + } + + kill(): void { + if (!this.process || this.killed) return; + this.killed = true; + this.process.kill('SIGTERM'); + } + + protected handleLine(line: string): void { + try { + const parsed = JSON.parse(line); + const result = PiAgentEventSchema.safeParse(parsed); + if (result.success) { + this.eventHandler?.(result.data as PiAgentEvent); + } + } catch { + logger.debug(`[pi] Skipping malformed JSON: ${line.slice(0, 100)}`); + } + } +} diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts new file mode 100644 index 0000000000..f3417483df --- /dev/null +++ b/cli/src/pi/runPi.ts @@ -0,0 +1,403 @@ +import { logger } from '@/ui/logger'; +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { registerLocalHandoffHandler } from '@/agent/localHandoff'; +import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { PiTransport } from './piTransport'; +import { PiSession } from './session'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import { PiThinkingLevelSchema, SetSessionConfigPayloadSchema } from './schemas'; +import type { PiThinkingLevel } from './types'; +import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes'; +import type { ListPiModelsResponse } from '@hapi/protocol/apiTypes'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; + +export async function runPi(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + model?: string; + effort?: string; + resumeSessionId?: string; + existingSessionId?: string; + workingDirectory?: string; +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); + const startedBy = opts.startedBy ?? 'terminal'; + // Pi only runs as `pi --mode rpc` with piped stdio — there is no local + // terminal/TUI input path (unlike Claude/Codex). Defaulting a terminal + // launch to 'local' would mark the session local-controlled while the user + // cannot drive it from the terminal, leaving it stuck until a web switch. + // Default to 'remote' so the session is immediately drivable from the web; + // an explicit opts.startingMode (e.g. runner) still takes precedence. + const startingMode: 'local' | 'remote' = opts.startingMode ?? 'remote'; + + logger.debug(`[pi] Starting with options: startedBy=${startedBy}, startingMode=${startingMode}`); + + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'pi', + startedBy, + workingDirectory, + }) + : await bootstrapSession({ + flavor: 'pi', + startedBy, + workingDirectory, + // Do not seed the hub session model from opts.model: it is unconfirmed + // until get_available_models/set_model accept it. The hub's + // handleSessionAlive persists every non-undefined keepAlive model, so + // passing it here would store/show a model Pi may reject. PiSession + // carries opts.model as initialModel and applies it once confirmed. + model: undefined + }); + const { session: apiSession } = bootstrap; + + setControlledByUser(apiSession, startingMode); + + const piSession = new PiSession({ + api: bootstrap.api, + client: apiSession, + path: workingDirectory, + logPath: logger.getLogPath(), + startedBy, + startingMode, + model: opts.model, + }); + + const transportArgs = ['--mode', 'rpc']; + if (opts.resumeSessionId) { + transportArgs.push('--session-id', opts.resumeSessionId); + } + const transport = new PiTransport({ command: 'pi', args: transportArgs, cwd: workingDirectory }); + + piSession.startKeepAlive(); + + let killedByCleanup = false; + const lifecycle = createRunnerLifecycle({ + session: apiSession, + logTag: 'pi', + stopKeepAlive: () => piSession.stopKeepAlive(), + onAfterClose: () => { + piSession.stopKeepAlive(); + killedByCleanup = true; + transport.kill(); + } + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle); + registerLocalHandoffHandler(apiSession.rpcHandlerManager, lifecycle); + + let cleanupInitiated = false; + const safeCleanup = async () => { + if (cleanupInitiated) return; + cleanupInitiated = true; + await lifecycle.cleanupAndExit(); + }; + + // Pending user-message localIds in FIFO order + const pendingLocalIds: string[] = []; + + // --- Transport error/close handlers --- + transport.onError((error) => { + logger.debug(`[pi] Transport error: ${error.message}`); + lifecycle.markCrash(error); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(error.message.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + transport.onClose((code, signal) => { + if (killedByCleanup) { + logger.debug(`[pi] Pi process closed during lifecycle cleanup (code=${code}, signal=${signal})`); + void safeCleanup(); + return; + } + const reason = signal + ? `Pi process killed by signal ${signal}` + : `Pi process exited with code ${code ?? 'null'}`; + logger.debug(`[pi] ${reason}`); + lifecycle.markCrash(new Error(reason)); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(reason.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + // --- Wire transport events to session --- + // Capture the requested startup effort WITHOUT mutating currentThinkingLevel. + // It is applied (and committed) only after Pi confirms set_thinking_level, + // mirroring the startup-model contract; seeding it here would leak an + // unconfirmed/rejected value via the first keepAlive (pushKeepAlive persists + // effort) before the RPC runs. get_state's thinkingLevel is the authoritative + // source until set_thinking_level succeeds. + let startupThinkingLevel: PiThinkingLevel | null = null; + if (opts.effort) { + const result = PiThinkingLevelSchema.safeParse(opts.effort.trim().toLowerCase()); + if (result.success) { + startupThinkingLevel = result.data; + } else { + logger.debug(`[pi] Ignoring invalid effort value on resume: ${opts.effort}`); + } + } + + wireTransportEvents(transport, piSession, pendingLocalIds); + + // --- Session config RPC --- + // + // Pi manually registers SetSessionConfig instead of using + // registerSessionConfigRpc() because Pi's wire protocol requires + // separate provider + modelId fields (transport.send({ type: + // 'set_model', provider, modelId })), while registerSessionConfigRpc + // only handles model as a simple string. The hub sends model as + // { provider, modelId } for Pi sessions. + + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (rawPayload: unknown) => { + const parsed = SetSessionConfigPayloadSchema.safeParse(rawPayload); + if (!parsed.success) { + throw new Error('Invalid session config payload'); + } + const config = parsed.data; + logger.debug(`[pi] SetSessionConfig received: ${JSON.stringify(config)}`); + + // Resolve requested values WITHOUT mutating PiSession yet. Commit them + // only after Pi confirms via sendPiRpcAndWait, otherwise a rejected + // set_model/set_thinking_level would leave PiSession holding unconfirmed + // values that the 2s keepalive reports back to the hub, persisting a + // model/effort Pi never accepted. + let requestedModel: { modelId: string | null; provider: string | null } | undefined; + if (config.model !== undefined) { + const modelValue = config.model; + logger.debug(`[pi] SetSessionConfig model: ${JSON.stringify(modelValue)}`); + + if (modelValue === null) { + requestedModel = { modelId: null, provider: null }; + } else if (typeof modelValue === 'string') { + const trimmed = modelValue.trim(); + if (!trimmed) throw new Error('Invalid model'); + // Fallback: search cached models for provider + const cached = piSession.cachedPiModels.find(m => m.modelId === trimmed); + requestedModel = { modelId: trimmed, provider: cached?.provider ?? null }; + } else { + // { provider, modelId } form + requestedModel = { modelId: modelValue.modelId, provider: modelValue.provider }; + } + logger.debug(`[pi] SetSessionConfig resolved: model=${requestedModel.modelId}, provider=${requestedModel.provider}`); + } + let requestedThinkingLevel: PiThinkingLevel | null | undefined; + if (config.effort !== undefined) { + if (config.effort === null) { + requestedThinkingLevel = null; + } else { + const result = PiThinkingLevelSchema.safeParse( + typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort, + ); + if (!result.success) throw new Error('Invalid effort'); + requestedThinkingLevel = result.data; + } + } + + // Forward changes to Pi process — wait for Pi to confirm before + // committing to PiSession or reporting applied, so the hub does not + // persist a model/effort that Pi rejected (e.g. invalid provider/model + // or thinking level) or that the RPC timed out on. + if (requestedModel) { + if (requestedModel.modelId && requestedModel.provider) { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_model', + provider: requestedModel.provider, + modelId: requestedModel.modelId, + }); + piSession.currentModel = requestedModel.modelId; + piSession.currentProvider = requestedModel.provider; + } else if (requestedModel.modelId && !requestedModel.provider) { + // Provider is unknown until get_state/get_available_models resolve. + // Committing now would persist piSelectedModel while Pi never received + // set_model — contradicting the "await Pi confirmation" contract above. + // Throw so the hub returns 409 and the web client can retry once the + // provider is known. + logger.debug('[pi] set_model suppressed: provider unknown until get_state'); + throw new Error('Model cannot be applied yet: provider is not yet known'); + } else if (requestedModel.modelId === null) { + // Clearing the model needs no Pi RPC (nothing to confirm), so commit + // immediately. This path is not reachable from the web Pi picker today. + piSession.currentModel = null; + piSession.currentProvider = null; + } + } + if (requestedThinkingLevel !== undefined) { + const level = requestedThinkingLevel ?? 'off'; + await sendPiRpcAndWait(piSession, transport, { type: 'set_thinking_level', level }); + piSession.currentThinkingLevel = requestedThinkingLevel; + } + piSession.pushKeepAlive(); + + // Return provider-qualified model so the hub persists piSelectedModel. + // A bare modelId string would make applySessionConfig clear the + // provider metadata (object check fails), defeating Fix #3. + const appliedModel = piSession.currentModel && piSession.currentProvider + ? { provider: piSession.currentProvider, modelId: piSession.currentModel } + : piSession.currentModel; + + return { + applied: { + model: appliedModel, + effort: piSession.currentThinkingLevel, + }, + }; + }); + + // --- Pi model discovery RPC --- + apiSession.rpcHandlerManager.registerHandler, ListPiModelsResponse>( + RPC_METHODS.ListPiModels, + async () => { + if (piSession.cachedPiModels.length > 0) { + return { + success: true, + availableModels: piSession.cachedPiModels, + currentModelId: piSession.currentModel, + }; + } + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_available_models' }); + const models = parsePiModels(data); + if (models.length > 0) { + piSession.cachedPiModels = models; + piSession.updateMetadata(meta => ({ ...meta, piAvailableModels: models })); + } + return { success: true, availableModels: models, currentModelId: piSession.currentModel }; + } catch (error) { + logger.debug('[pi] ListPiModels RPC failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list Pi models', + }; + } + } + ); + + // --- Slash commands (Pi skills/commands) --- + apiSession.rpcHandlerManager.registerHandler<{ agent?: string }, SlashCommandsResponse>( + RPC_METHODS.ListSlashCommands, + async () => { + let commands = piSession.cachedPiCommands; + if (commands.length === 0) { + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' }); + commands = parsePiCommands(data); + if (commands.length > 0) { + piSession.cachedPiCommands = commands; + } + } catch { + // Fall through to return empty + } + } + return { + success: true, + commands: commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: cmd.source === 'skill' ? 'plugin' as const + : cmd.source === 'prompt' ? 'user' as const + : 'plugin' as const, + })), + }; + } + ); + + // --- User message handler --- + apiSession.onUserMessage((message, localId) => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + if (piSession.piIsStreaming) { + // Steer does not start a new turn, so the localId would never be + // drained by turn_start. Mark it consumed immediately so it does + // not poison the FIFO for the next real prompt. + transport.send({ type: 'steer', message: formattedText }); + if (localId) piSession.emitMessagesConsumed([localId]); + } else { + if (localId) pendingLocalIds.push(localId); + transport.send({ type: 'prompt', message: formattedText }); + } + }); + + // --- Abort handler --- + // Only cancel the current turn, keep session alive for next prompt. + // Pi's `abort` command cancels the active turn but the process stays in RPC mode. + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { + transport.send({ type: 'abort' }); + piSession.piIsStreaming = false; + piSession.updateThinkingState(false); + if (pendingLocalIds.length > 0) { + piSession.emitMessagesConsumed([pendingLocalIds.shift()!]); + } + return { success: true }; + }); + + // --- Switch handler --- + // Unlike Claude/Codex (which use BaseLocalLauncher's restart loop), Pi runs + // as a single long-lived subprocess. Switching mode should change control + // ownership without killing the process or archiving the session. + const handleModeChange = createModeChangeHandler(apiSession); + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Switch, async (payload: { to?: 'local' | 'remote' } = {}) => { + const mode = payload.to ?? 'remote'; + piSession.setMode(mode); + handleModeChange(mode); + return { success: true }; + }); + + // --- Run --- + let crashed = false; + try { + transport.start(); + transport.send({ type: 'new_session' }); + transport.send({ type: 'get_state' }); + transport.send({ type: 'get_available_models' }); + transport.send({ type: 'get_commands' }); + + // Apply the requested startup effort only after Pi confirms + // set_thinking_level. Commit currentThinkingLevel on success and push a + // keepAlive so the hub sees the accepted value; on rejection keep Pi's + // default (already reported by get_state). Detached so the run loop is + // not blocked; sent after get_state so the authoritative baseline lands + // first and a late get_state response does not clobber the confirmed + // value (get_state runs on the wire before this await resolves). + if (startupThinkingLevel) { + void (async () => { + try { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_thinking_level', + level: startupThinkingLevel, + }); + piSession.currentThinkingLevel = startupThinkingLevel; + piSession.pushKeepAlive(); + logger.debug(`[pi] Startup effort applied: ${startupThinkingLevel}`); + } catch (error) { + logger.debug(`[pi] Startup effort rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } + + // Block until cleanup is triggered by error/close handler + await new Promise((resolve) => { + const origCleanup = lifecycle.cleanupAndExit.bind(lifecycle); + lifecycle.cleanupAndExit = async (codeOverride?: number) => { + resolve(); + await origCleanup(codeOverride); + }; + }); + } catch (error) { + crashed = true; + lifecycle.markCrash(error); + lifecycle.setSessionEndReason('error'); + logger.debug('[pi] Loop error:', error); + } finally { + if (!crashed && !lifecycle.hasExplicitSessionEndReason()) { + lifecycle.setSessionEndReason('completed'); + } + await safeCleanup(); + } +} diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts new file mode 100644 index 0000000000..3532265af2 --- /dev/null +++ b/cli/src/pi/schemas.ts @@ -0,0 +1,203 @@ +/** + * Zod schemas for Pi RPC protocol parsing. + * + * All unknown→typed conversions happen here via Zod schemas, + * so downstream code works with validated data only. + * + * Pi 协议无版本保证 — 字段级容错策略: + * 用 z.unknown().transform() / .catch() 确保非法类型字段静默丢弃, + * 而非拒绝整个对象。 + */ + +import { z } from 'zod'; +import { PI_THINKING_LEVELS } from '@hapi/protocol'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; + +// ============================================================================ +// 字段级容错 schema +// ============================================================================ + +/** 提取 string 值,非 string 返回 undefined */ +const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); + +/** 提取 number 值,非 number 返回 undefined */ +const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); + +/** 提取 boolean 值,非 boolean 返回 undefined */ +const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); + +/** 提取 string 值,非 string 返回指定默认值 */ +const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); + +/** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ +const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { + if (typeof v !== 'object' || v === null) return undefined; + const map: Record = {}; + for (const [key, val] of Object.entries(v as Record)) { + if (typeof val === 'string') map[key] = val; + else if (val === null) map[key] = null; + } + return Object.keys(map).length > 0 ? map : undefined; +}); + +// ============================================================================ +// Pi Agent Event (stdin JSONL → event) +// ============================================================================ + +/** Minimal shape: must be an object with a string `type` field. */ +export const PiAgentEventSchema = z.object({ + type: z.string(), +}).passthrough(); + +// ============================================================================ +// Pi Response Event (stdout response) +// ============================================================================ + +export const PiResponseEventSchema = z.object({ + type: z.literal('response'), + command: z.string(), + success: z.boolean(), + error: z.string().optional(), + data: z.unknown().optional(), + // RPC correlation id (sent by PiRpcResolver as string) + id: z.string().optional(), +}); + +// ============================================================================ +// Pi Command Summary +// ============================================================================ + +const VALID_COMMAND_SOURCES = ['extension', 'prompt', 'skill'] as const; +type PiCommandSource = (typeof VALID_COMMAND_SOURCES)[number]; + +const PiCommandSummarySchema = z.object({ + name: z.string(), + description: z.string().optional(), + source: z.enum(VALID_COMMAND_SOURCES), +}); + +/** 单条 command 的容错 schema:非法字段静默修正,空 name 返回 null */ +const PiCommandEntrySchema = z.object({ + name: asStrOrDef(''), + description: asOptStr, + source: z.unknown().transform(v => + VALID_COMMAND_SOURCES.includes(v as PiCommandSource) + ? (v as PiCommandSource) + : ('skill' as const), + ), +}).passthrough().transform((c) => { + if (!c.name) return null; + const entry: { name: string; description?: string; source: PiCommandSource } = { + name: c.name, + source: c.source, + }; + if (c.description !== undefined) entry.description = c.description; + return entry; +}); + +const PiCommandsResponseDataSchema = z.object({ + commands: z.array(z.unknown()).default([]), +}).transform(data => + data.commands + .map(c => PiCommandEntrySchema.safeParse(c)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi Model Summary +// ============================================================================ + +/** 单条 model 的容错 schema:非法字段静默丢弃,空 id 返回 null */ +const PiModelEntrySchema = z.object({ + id: asStrOrDef(''), + provider: asStrOrDef('unknown'), + name: asOptStr, + contextWindow: asOptNum, + reasoning: asOptBool, + thinkingLevelMap: asOptThinkingLevelMap, +}).passthrough().transform((m): PiModelSummary | null => { + if (!m.id) return null; + const entry: PiModelSummary = { provider: m.provider, modelId: m.id }; + if (m.name !== undefined) entry.name = m.name; + if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow; + if (m.reasoning !== undefined) entry.reasoning = m.reasoning; + if (m.thinkingLevelMap !== undefined) entry.thinkingLevelMap = m.thinkingLevelMap; + return entry; +}); + +const PiModelsResponseDataSchema = z.object({ + models: z.array(z.unknown()).default([]), +}).transform(data => + data.models + .map(m => PiModelEntrySchema.safeParse(m)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi State (get_state response data) +// ============================================================================ + +export const PiStateDataSchema = z.object({ + model: z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), + }).passthrough().optional(), + sessionId: z.string().optional(), + thinkingLevel: z.string().optional(), + steeringMode: z.enum(['all', 'one-at-a-time']).optional(), +}).passthrough(); + +// ============================================================================ +// Pi set_model response data +// ============================================================================ + +export const PiSetModelDataSchema = z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), +}).passthrough(); + +// ============================================================================ +// SetSessionConfig RPC payload +// ============================================================================ + +export const SetSessionConfigPayloadSchema = z.object({ + permissionMode: z.unknown().optional(), + model: z.union([ + z.string(), + z.object({ provider: z.string(), modelId: z.string() }), + z.null(), + ]).optional(), + effort: z.unknown().optional(), +}).passthrough(); + +// ============================================================================ +// Pi thinking level — enum sourced from @hapi/protocol (single definition) +// ============================================================================ + +export const PiThinkingLevelSchema = z.enum(PI_THINKING_LEVELS); + +// ============================================================================ +// message_update assistant message event — delta extraction +// ============================================================================ + +export const PiAssistantMessageEventSchema = z.object({ + type: z.string(), + delta: z.string().optional(), + contentIndex: z.number().optional(), +}).passthrough(); + +// ============================================================================ +// Parse helpers — replace hand-written type guards in loop.ts +// ============================================================================ + +export function parsePiCommands(data: unknown) { + return PiCommandsResponseDataSchema.safeParse(data).data ?? []; +} + +export function parsePiModels(data: unknown) { + return PiModelsResponseDataSchema.safeParse(data).data ?? []; +} diff --git a/cli/src/pi/session.ts b/cli/src/pi/session.ts new file mode 100644 index 0000000000..e5740fe32a --- /dev/null +++ b/cli/src/pi/session.ts @@ -0,0 +1,127 @@ +import type { ApiClient, ApiSessionClient } from '@/lib'; +import type { Metadata } from '@/api/types'; +import type { PiCommandSummary, PiThinkingLevel } from './types'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; +import type { PiRpcResolver } from './loop'; + +/** + * Pi session state and hub communication wrapper. + * + * Unlike other agents that extend AgentSessionBase (which requires MessageQueue2), + * Pi sends messages directly via PiTransport RPC — no queue needed. + * This class manages Pi-specific runtime state and hub keepAlive. + */ +export class PiSession { + readonly api: ApiClient; + readonly client: ApiSessionClient; + readonly path: string; + readonly logPath: string; + readonly startedBy: 'runner' | 'terminal'; + // Mutable mode — updated by setMode() when the hub switches control + // (local ↔ remote). keepAlive reads this so the reported mode does not + // revert to the constructor-time startingMode every 2s tick. + mode: 'local' | 'remote'; + + // Config state — synced to hub via keepAlive. + // `undefined` means "not yet known" and is OMITTED from keepAlive so the hub + // does not clear a persisted value; `null` is an explicit clear. A value is + // only assigned once Pi confirms it (get_state / successful set_model / + // successful set_thinking_level). + currentModel: string | null | undefined; + currentThinkingLevel: PiThinkingLevel | null | undefined; + // Pi's set_model requires provider + modelId; learned from get_state + currentProvider: string | null = null; + // Startup model from opts.model — prevents get_state from overwriting it + // with Pi's default. Applied once when get_available_models returns. + readonly initialModel: string | null; + + // Streaming state + piIsStreaming = false; + currentSteeringMode: 'all' | 'one-at-a-time' = 'all'; + + // Cached data from Pi + cachedPiModels: PiModelSummary[] = []; + cachedPiCommands: PiCommandSummary[] = []; + + // RPC resolver — initialized by wireTransportEvents, session-scoped + rpcResolver: PiRpcResolver | null = null; + + private keepAliveInterval: NodeJS.Timeout | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + model?: string | null; + }) { + this.api = opts.api; + this.client = opts.client; + this.path = opts.path; + this.logPath = opts.logPath; + this.startedBy = opts.startedBy; + this.mode = opts.startingMode; + // currentModel/currentThinkingLevel start undefined ("not yet known") + // and are set only from Pi's confirmed state (get_state) or a successful + // set_model/set_thinking_level. Seeding from opts.model/opts.effort here + // would leak unconfirmed values via the first keepAlive; they are captured + // as initialModel/startupThinkingLevel and applied once Pi accepts them. + // undefined is distinct from null (explicit clear): keepAlive omits + // undefined fields so the hub does not wipe a persisted model/effort on + // resume before Pi reports its real state. + this.currentModel = undefined; + this.initialModel = opts.model?.trim() || null; + this.currentThinkingLevel = undefined; + } + + startKeepAlive(): void { + this.pushKeepAlive(); + this.keepAliveInterval = setInterval(() => this.pushKeepAlive(), 2000); + } + + stopKeepAlive(): void { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + this.keepAliveInterval = null; + } + } + + private getKeepAliveRuntime(): Parameters[2] { + const runtime: NonNullable[2]> = {}; + if (this.currentModel !== undefined) runtime.model = this.currentModel; + if (this.currentThinkingLevel !== undefined) runtime.effort = this.currentThinkingLevel; + return Object.keys(runtime).length > 0 ? runtime : undefined; + } + + pushKeepAlive(): void { + this.client.keepAlive(this.piIsStreaming, this.mode, this.getKeepAliveRuntime()); + } + + updateThinkingState(thinking: boolean): void { + this.piIsStreaming = thinking; + this.client.keepAlive(thinking, this.mode, this.getKeepAliveRuntime()); + } + + setMode(mode: 'local' | 'remote'): void { + this.mode = mode; + this.pushKeepAlive(); + } + + updateMetadata(updater: (meta: Metadata) => Metadata): void { + this.client.updateMetadata(updater); + } + + sendAgentMessage(message: unknown): void { + this.client.sendAgentMessage(message); + } + + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { + this.client.emitMessagesConsumed(localIds, options); + } + + sendSessionEvent(event: Parameters[0]): void { + this.client.sendSessionEvent(event); + } +} diff --git a/cli/src/pi/types.ts b/cli/src/pi/types.ts new file mode 100644 index 0000000000..ded9607bad --- /dev/null +++ b/cli/src/pi/types.ts @@ -0,0 +1,119 @@ +/** + * Pi RPC protocol type definitions. + * + * Commands are sent as JSON lines on stdin. + * Responses and events are emitted as JSON lines on stdout. + * Based on Pi coding-agent's rpc-types.ts and agent/types.ts. + */ + +// ============================================================================ +// Pi Agent Events (stdout) — discriminated union on `type` +// ============================================================================ + +export interface PiTextDeltaEvent { + type: 'text_delta'; + delta: string; +} + +export interface PiThinkingDeltaEvent { + type: 'thinking_delta'; + delta: string; +} + +export type PiAssistantMessageEvent = + | PiTextDeltaEvent + | PiThinkingDeltaEvent + | { type: 'start' } + | { type: 'done'; reason: string } + | { type: 'error'; reason: string; error: unknown } + // Catch-all for text_start, text_end, thinking_start, thinking_end, toolcall_* etc. + | { type: string; [key: string]: unknown }; + +export interface PiUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; +} + +// Individual event types for proper type narrowing +export interface PiAgentStartEvent { type: 'agent_start' } +export interface PiAgentEndEvent { type: 'agent_end'; messages: unknown[] } +export interface PiTurnStartEvent { type: 'turn_start' } +export interface PiTurnEndEvent { + type: 'turn_end'; + message?: { usage?: PiUsage; stopReason?: string }; + toolResults?: unknown[]; +} +export interface PiMessageStartEvent { type: 'message_start'; message: unknown } +export interface PiMessageUpdateEvent { + type: 'message_update'; + assistantMessageEvent?: PiAssistantMessageEvent; + message?: unknown; +} +export interface PiMessageEndEvent { type: 'message_end'; message: unknown } +export interface PiToolExecutionStartEvent { + type: 'tool_execution_start'; + toolCallId: string; + toolName: string; + args: unknown; +} +export interface PiToolExecutionUpdateEvent { + type: 'tool_execution_update'; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; +} +export interface PiToolExecutionEndEvent { + type: 'tool_execution_end'; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; +} + +export type PiAgentEvent = + | PiAgentStartEvent + | PiAgentEndEvent + | PiTurnStartEvent + | PiTurnEndEvent + | PiMessageStartEvent + | PiMessageUpdateEvent + | PiMessageEndEvent + | PiToolExecutionStartEvent + | PiToolExecutionUpdateEvent + | PiToolExecutionEndEvent + | { type: string }; // fallback for unknown events + +// ============================================================================ +// Pi RPC Commands (stdin) +// ============================================================================ + +import type { PiThinkingLevel } from '@hapi/protocol' +import type { PiCommandSummary } from '@hapi/protocol/apiTypes' +export type { PiThinkingLevel, PiCommandSummary } + +export type PiRpcCommand = + | { type: 'prompt'; message: string } + | { type: 'steer'; message: string } + | { type: 'abort' } + | { type: 'new_session' } + | { type: 'get_state' } + | { type: 'set_model'; provider: string; modelId: string } + | { type: 'get_available_models' } + | { type: 'set_thinking_level'; level: PiThinkingLevel } + | { type: 'get_commands' }; + +// ============================================================================ +// Pi RPC Responses (stdout) +// ============================================================================ + +export interface PiResponseEvent { + type: 'response'; + command: string; + success: boolean; + error?: string; + data?: unknown; +} diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index f80b6e2298..fb6d764895 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -98,4 +98,44 @@ describe('buildCliArgs', () => { expect(args).toContain(mode) } }) + + it('uses --session-id for pi resume (not --resume)', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + resumeSessionId: 'some-pi-session-id', + }) + expect(args).not.toContain('--resume') + expect(args).toContain('--session-id') + expect(args).toContain('some-pi-session-id') + expect(args[0]).toBe('pi') + }) + + it('still passes --resume for claude when resumeSessionId is provided', () => { + // Guard against accidentally swallowing claude's --resume when + // the pi branch was added. + const args = buildCliArgs('claude', { + directory: '/tmp', + resumeSessionId: 'some-claude-session-id', + }) + expect(args).toContain('--resume') + expect(args).toContain('some-claude-session-id') + }) + + it('passes --effort for pi agent', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) + + it('passes --effort for claude agent', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) }) diff --git a/cli/src/runner/controlClient.ts b/cli/src/runner/controlClient.ts index b1d8de3c80..fa9daa1d28 100644 --- a/cli/src/runner/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -10,7 +10,7 @@ import packageJson from '../../package.json'; import { existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { isBunCompiled, projectPath } from '@/projectPath'; -import { isProcessAlive, killProcess } from '@/utils/process'; +import { isProcessAlive, isHapiRunnerProcess, killProcess } from '@/utils/process'; import { configuration } from '@/configuration'; import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'; @@ -143,12 +143,12 @@ export async function checkIfRunnerRunningAndCleanupStaleState(): Promise | null = null; if (sessionType === 'simple') { - try { - await fs.access(directory); + const validation = await validateWorkspaceDirectory(directory, { + approvedNewDirectoryCreation + }); + if (validation.type === 'requestApproval') { + logger.debug(`[RUNNER RUN] Directory creation not approved for: ${directory}`); + return { + type: 'requestToApproveDirectoryCreation', + directory + }; + } + if (validation.type === 'error') { + logger.debug(`[RUNNER RUN] Workspace directory validation failed: ${validation.errorMessage}`); + return { + type: 'error', + errorMessage: validation.errorMessage + }; + } + directoryCreated = validation.created; + if (validation.created) { + logger.debug(`[RUNNER RUN] Successfully created directory: ${directory}`); + } else { logger.debug(`[RUNNER RUN] Directory exists: ${directory}`); - } catch (error) { - logger.debug(`[RUNNER RUN] Directory doesn't exist, creating: ${directory}`); - - // Check if directory creation is approved - if (!approvedNewDirectoryCreation) { - logger.debug(`[RUNNER RUN] Directory creation not approved for: ${directory}`); - return { - type: 'requestToApproveDirectoryCreation', - directory - }; - } - - try { - await fs.mkdir(directory, { recursive: true }); - logger.debug(`[RUNNER RUN] Successfully created directory: ${directory}`); - directoryCreated = true; - } catch (mkdirError: any) { - let errorMessage = `Unable to create directory at '${directory}'. `; - - // Provide more helpful error messages based on the error code - if (mkdirError.code === 'EACCES') { - errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`; - } else if (mkdirError.code === 'ENOTDIR') { - errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`; - } else if (mkdirError.code === 'ENOSPC') { - errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`; - } else if (mkdirError.code === 'EROFS') { - errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`; - } else { - errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`; - } - - logger.debug(`[RUNNER RUN] Directory creation failed: ${errorMessage}`); - return { - type: 'error', - errorMessage - }; - } } } else { try { @@ -1101,13 +1083,18 @@ export function buildCliArgs( ? 'kimi' : agent === 'opencode' ? 'opencode' - : 'claude'; + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); } else if (agent === 'cursor') { args.push('--resume', options.resumeSessionId); + } else if (agent === 'pi') { + // Pi uses --session-id for exact session resume (RPC mode) + args.push('--session-id', options.resumeSessionId); } else { args.push('--resume', options.resumeSessionId); } @@ -1116,7 +1103,7 @@ export function buildCliArgs( if (options.model) { args.push('--model', options.model); } - if (options.effort && agent === 'claude') { + if (options.effort && (agent === 'claude' || agent === 'pi')) { args.push('--effort', options.effort); } if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { @@ -1125,10 +1112,14 @@ export function buildCliArgs( if (options.serviceTier && agent === 'codex') { args.push('--service-tier', options.serviceTier); } - if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { - args.push('--permission-mode', options.permissionMode); - } else if (yolo) { - args.push('--yolo'); + // Pi RPC mode has no permission switching; never pass these flags to it + // (the Pi parser rejects --permission-mode and ignores --yolo). + if (agent !== 'pi') { + if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { + args.push('--permission-mode', options.permissionMode); + } else if (yolo) { + args.push('--yolo'); + } } return args; } diff --git a/cli/src/runner/validateWorkspaceDirectory.test.ts b/cli/src/runner/validateWorkspaceDirectory.test.ts new file mode 100644 index 0000000000..9798c97ac5 --- /dev/null +++ b/cli/src/runner/validateWorkspaceDirectory.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + describeMkdirError, + validateWorkspaceDirectory, +} from './validateWorkspaceDirectory'; + +let workRoot: string; + +beforeEach(async () => { + workRoot = await mkdtemp(join(tmpdir(), 'hapi-validate-workspace-')); +}); + +afterEach(async () => { + await rm(workRoot, { recursive: true, force: true }); +}); + +describe('validateWorkspaceDirectory', () => { + it('creates a missing directory when approved', async () => { + const target = join(workRoot, 'new-workspace'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: true }); + }); + + it('requests approval when path is missing and creation is not approved', async () => { + const target = join(workRoot, 'unapproved'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: false, + }); + expect(result).toEqual({ type: 'requestApproval' }); + }); + + it('returns ok without creating when path is already a directory', async () => { + const target = join(workRoot, 'existing-dir'); + await mkdir(target); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: false }); + }); + + it('returns an error when path is a regular file', async () => { + const target = join(workRoot, 'collision-file'); + await writeFile(target, 'hello'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain('non-directory file'); + expect(result.errorMessage).toContain(target); + } + }); + + it('preserves the ENOTDIR diagnostic when the parent path is a regular file', async () => { + const parentFile = join(workRoot, 'parent-file'); + await writeFile(parentFile, 'hello'); + const target = join(parentFile, 'child-dir'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(target); + expect(result.errorMessage).toMatch(/file already exists/i); + expect(result.errorMessage).not.toMatch(/Unable to inspect workspace path/); + } + }); + + it('returns ok when path is a symlink to an existing directory', async () => { + const realTarget = join(workRoot, 'real-target'); + await mkdir(realTarget); + const link = join(workRoot, 'good-symlink'); + await symlink(realTarget, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: false }); + }); + + it('returns a diagnostic error when path is a dangling symlink', async () => { + const missingTarget = join(workRoot, 'gone-target'); + const link = join(workRoot, 'dangling-symlink'); + await symlink(missingTarget, link); + // missingTarget is never created, so the link is dangling. + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(missingTarget); + expect(result.errorMessage).toMatch(/symbolic link/i); + expect(result.errorMessage).toMatch(/no longer exists/i); + expect(result.errorMessage).toMatch(/Recovery:/); + expect(result.errorMessage).not.toMatch(/EEXIST/); + // Regression: must not embed the user-controlled path inside a + // copy-pasteable shell command (`rm '...'`) - a path with a + // single quote would break the quoting and create an injection + // / accidental-delete vector. (Codex review on PR #892.) + expect(result.errorMessage).not.toMatch(/`rm /); + } + }); + + it('does not produce a copy-pasteable rm command when the path contains a single quote', async () => { + // Regression for the PR #892 Codex review Major: paths with + // single quotes used to break out of the literal `rm '...'` + // recovery hint and turn the diagnostic into a shell-injection + // / accidental-delete vector. + const trickyDir = join(workRoot, "weird'name"); + await mkdir(trickyDir); + const missingTarget = join(trickyDir, 'gone-target'); + const link = join(trickyDir, 'dangling-symlink'); + await symlink(missingTarget, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(missingTarget); + expect(result.errorMessage).not.toMatch(/`rm /); + } + }); + + it('returns an error when path is a symlink to a non-directory', async () => { + const targetFile = join(workRoot, 'target-file'); + await writeFile(targetFile, 'hello'); + const link = join(workRoot, 'symlink-to-file'); + await symlink(targetFile, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(targetFile); + expect(result.errorMessage).toMatch(/not a directory/i); + } + }); +}); + +describe('describeMkdirError', () => { + const directory = '/tmp/hapi-test-target'; + + it('produces a Permission denied message for EACCES', () => { + const msg = describeMkdirError(directory, { + code: 'EACCES', + message: 'permission denied', + }); + expect(msg).toContain(directory); + expect(msg).toContain('Permission denied'); + }); + + it('produces an ENOTDIR message for ENOTDIR', () => { + const msg = describeMkdirError(directory, { + code: 'ENOTDIR', + message: 'not a directory', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/file already exists/i); + }); + + it('produces a No space left on device message for ENOSPC', () => { + const msg = describeMkdirError(directory, { + code: 'ENOSPC', + message: 'no space left on device', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/No space left on device/i); + }); + + it('produces a read-only file system message for EROFS', () => { + const msg = describeMkdirError(directory, { + code: 'EROFS', + message: 'read-only file system', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/read-only/i); + }); + + it('produces a non-directory race message for EEXIST', () => { + const msg = describeMkdirError(directory, { + code: 'EEXIST', + message: 'file already exists', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/non-directory file/i); + expect(msg).not.toMatch(/EEXIST/); + }); + + it('falls back to System error for unknown codes', () => { + const msg = describeMkdirError(directory, { + code: 'EWEIRD', + message: 'something strange', + }); + expect(msg).toContain(directory); + expect(msg).toContain('System error: something strange'); + }); +}); diff --git a/cli/src/runner/validateWorkspaceDirectory.ts b/cli/src/runner/validateWorkspaceDirectory.ts new file mode 100644 index 0000000000..d33fcf98d0 --- /dev/null +++ b/cli/src/runner/validateWorkspaceDirectory.ts @@ -0,0 +1,237 @@ +import fs from 'fs/promises'; + +/** + * Result of validating (and optionally creating) a workspace directory before + * a session is spawned at it. + * + * - `ok`: the directory exists (or was just created) and is usable as a cwd. + * `created` distinguishes the just-created case so the runner can surface a + * user-visible "we created this folder for you" message. + * - `requestApproval`: the path does not exist and the caller has not approved + * new-directory creation. Surfaces back to the web UI as the existing + * `requestToApproveDirectoryCreation` flow. + * - `error`: validation failed. `errorMessage` is the user-facing string and + * is preferred over leaking raw kernel errors (EEXIST etc.). + */ +export type ValidateWorkspaceDirectoryResult = + | { type: 'ok'; created: boolean } + | { type: 'requestApproval' } + | { type: 'error'; errorMessage: string }; + +export interface ValidateWorkspaceDirectoryOptions { + approvedNewDirectoryCreation: boolean; +} + +/** + * Resolve a workspace directory before spawning a session at it. + * + * Replaces the historic `fs.access` + `fs.mkdir({ recursive: true })` pair in + * `run.ts`, which produced a misleading EEXIST error on dangling symlinks + * (symlink points at a deleted target, `fs.access` follows the link and + * throws ENOENT, then `mkdir` cannot tolerate the existing non-directory + * entry and surfaces `EEXIST: file already exists, mkdir '...'` to the user). + * + * The replacement uses `fs.lstat` so symlinks are inspected without being + * followed, distinguishes dangling symlinks from genuinely missing paths and + * from regular files squatting at the workspace path, and only attempts + * `mkdir` when the path truly does not exist. + */ +export async function validateWorkspaceDirectory( + directory: string, + options: ValidateWorkspaceDirectoryOptions +): Promise { + const { approvedNewDirectoryCreation } = options; + + let lstat: Awaited> | null = null; + try { + lstat = await fs.lstat(directory); + } catch (err: any) { + if (err?.code === 'ENOENT') { + // path does not exist - fall through to mkdir / approval flow + } else if (err?.code === 'ENOTDIR') { + // Parent path contains a regular file; preserve the historic + // mkdir ENOTDIR diagnostic instead of the generic inspect text. + return { + type: 'error', + errorMessage: describeMkdirError(directory, err), + }; + } else { + return { + type: 'error', + errorMessage: + `Unable to inspect workspace path '${directory}'. ` + + `System error: ${err?.message || err}. ` + + `Please verify the path is valid and you have the necessary permissions.`, + }; + } + } + + if (lstat) { + if (lstat.isSymbolicLink()) { + return await handleSymlink(directory); + } + if (lstat.isDirectory()) { + return { type: 'ok', created: false }; + } + return { + type: 'error', + errorMessage: + `A non-directory file already exists at '${directory}'. ` + + `Cannot use it as a workspace. Please move or remove the file, or pick a different workspace path.`, + }; + } + + if (!approvedNewDirectoryCreation) { + return { type: 'requestApproval' }; + } + + try { + await fs.mkdir(directory, { recursive: true }); + return { type: 'ok', created: true }; + } catch (err: any) { + return await buildMkdirError(directory, err); + } +} + +async function handleSymlink(directory: string): Promise { + let linkTarget = ''; + try { + linkTarget = await fs.readlink(directory); + } catch { + // Best-effort: if we can't read the link, we still report a useful error below. + } + + let realPath: string; + try { + realPath = await fs.realpath(directory); + } catch (err: any) { + if (err?.code === 'ENOENT') { + const targetDescription = linkTarget + ? `'${linkTarget}'` + : 'a target that no longer exists'; + // Deliberately do NOT embed `directory` inside a copy-pasteable + // shell command (e.g. `rm '...'`): a path containing a single + // quote would break the quoting and turn this diagnostic into + // a shell-injection / accidental-delete vector. Describe the + // recovery action in prose instead. (Codex review on PR #892.) + return { + type: 'error', + errorMessage: + `Workspace path '${directory}' is a symbolic link to ${targetDescription}, ` + + `which no longer exists. This usually means the target was deleted ` + + `(e.g. via \`git worktree remove\`) without removing the symlink. ` + + `Recovery: recreate the directory at the target path, remove the dangling symlink at '${directory}', ` + + `or archive this session.`, + }; + } + return { + type: 'error', + errorMessage: + `Unable to resolve symbolic link at '${directory}'. ` + + `System error: ${err?.message || err}. ` + + `Please verify the symlink target is reachable and you have the necessary permissions.`, + }; + } + + let resolvedStat; + try { + resolvedStat = await fs.stat(realPath); + } catch (err: any) { + return { + type: 'error', + errorMessage: + `Unable to stat resolved path '${realPath}' (symlinked from '${directory}'). ` + + `System error: ${err?.message || err}.`, + }; + } + + if (resolvedStat.isDirectory()) { + return { type: 'ok', created: false }; + } + + return { + type: 'error', + errorMessage: + `Workspace path '${directory}' is a symbolic link to '${realPath}', which is not a directory. ` + + `Please update the symlink to point at a directory, or pick a different workspace path.`, + }; +} + +/** + * Pure mapping of `mkdir` errno codes to user-facing messages. Exported for + * unit tests; production callers go through `buildMkdirError` which adds + * `EEXIST` race-handling on top. + */ +export function describeMkdirError( + directory: string, + err: { code?: string; message?: string } | undefined | null +): string { + const prefix = `Unable to create directory at '${directory}'. `; + switch (err?.code) { + case 'EACCES': + return ( + prefix + + `Permission denied. You don't have write access to create a folder at this location. ` + + `Try using a different path or check your permissions.` + ); + case 'ENOTDIR': + return ( + prefix + + `A file already exists at this path or in the parent path. ` + + `Cannot create a directory here. Please choose a different location.` + ); + case 'ENOSPC': + return ( + prefix + + `No space left on device. Your disk is full. Please free up some space and try again.` + ); + case 'EROFS': + return ( + prefix + + `The file system is read-only. Cannot create directories here. Please choose a writable location.` + ); + case 'EEXIST': + return ( + prefix + + `A non-directory file appeared at this path between the existence check ` + + `and directory creation. Please move or remove it, or pick a different path.` + ); + default: + return ( + prefix + + `System error: ${err?.message || err}. ` + + `Please verify the path is valid and you have the necessary permissions.` + ); + } +} + +async function buildMkdirError( + directory: string, + err: any +): Promise { + if (err?.code === 'EEXIST') { + // Race with a parallel writer between the initial lstat and mkdir, OR + // a non-directory entry that mkdir({ recursive: true }) refused to + // tolerate. lstat the path again to produce a targeted message + // instead of leaking the kernel error verbatim. + try { + const raceStat = await fs.lstat(directory); + if (raceStat.isDirectory()) { + // mkdir({ recursive: true }) should not throw EEXIST on an + // existing directory; if it did, treat the directory as good + // enough rather than failing the user. + return { type: 'ok', created: false }; + } + if (raceStat.isSymbolicLink()) { + return handleSymlink(directory); + } + } catch { + // Fall through to the message-only path below if we can't even + // lstat the path again (very unusual race). + } + } + return { + type: 'error', + errorMessage: describeMkdirError(directory, err), + }; +} diff --git a/cli/src/utils/jsonLineParser.ts b/cli/src/utils/jsonLineParser.ts new file mode 100644 index 0000000000..c4b08d294b --- /dev/null +++ b/cli/src/utils/jsonLineParser.ts @@ -0,0 +1,35 @@ +/** + * JSONL line parser — shared by all stdio-based agent transports. + * + * Buffers raw stdout chunks, splits on newlines, and emits complete lines. + * Each transport provides its own `handleLine` to parse the JSON and + * dispatch domain-specific events. + */ +export abstract class JsonLineParser { + private buffer = ''; + + /** Feed a raw stdout chunk into the parser. */ + feed(chunk: string): void { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf('\n'); + + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + this.handleLine(line); + } + + newlineIndex = this.buffer.indexOf('\n'); + } + } + + /** Reset internal buffer (e.g. on process restart). */ + reset(): void { + this.buffer = ''; + } + + /** Override to parse a complete JSON line and dispatch events. */ + protected abstract handleLine(line: string): void; +} diff --git a/cli/src/utils/process.ts b/cli/src/utils/process.ts index e2726bc28c..3c82d82c48 100644 --- a/cli/src/utils/process.ts +++ b/cli/src/utils/process.ts @@ -16,6 +16,32 @@ export function isProcessAlive(pid: number): boolean { } } +// ponytail: ps -p is cheap and avoids PID-reuse false positives after OS upgrades/reboots +function isRunnerCommand(commandLine: string): boolean { + return /(?:^|\s)runner(?:\s|$)/.test(commandLine) && /(?:^|\s)start-sync(?:\s|$)/.test(commandLine); +} + +export function isHapiRunnerProcess(pid: number): boolean { + if (!isProcessAlive(pid)) { + return false; + } + if (isWindows()) { + const result = spawn.sync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine'], { stdio: 'pipe' }); + if (result.error) { + return true; + } + if (result.status !== 0) { + return isProcessAlive(pid); + } + return isRunnerCommand(result.stdout?.toString() ?? ''); + } + const result = spawn.sync('ps', ['-p', String(pid), '-o', 'command='], { stdio: 'pipe' }); + if (result.error || result.status !== 0) { + return isProcessAlive(pid); + } + return isRunnerCommand(result.stdout?.toString() ?? ''); +} + function killProcessWindows(pid: number, force: boolean): boolean { if (!isProcessAlive(pid)) { return true; diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 612d92df6e..ae6e8ddcf9 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -536,6 +536,7 @@ After=network.target hapi-hub.service [Service] Type=simple +KillMode=process ExecStart=/usr/local/bin/hapi runner start-sync Restart=always RestartSec=5 @@ -544,6 +545,8 @@ RestartSec=5 WantedBy=default.target ``` +> **Why `KillMode=process`?** The runner spawns each agent session as a detached child process (`detached: true` in `cli/src/runner/run.ts`) so that sessions stay alive when the runner exits. Without `KillMode=process`, systemd's default `KillMode=control-group` sends SIGTERM to every PID in the runner's cgroup when the unit stops, defeating the detach and forcibly archiving every running session. `KillMode=process` preserves the contract: stopping or restarting the runner only signals the runner itself; agent sessions stay alive, and a fresh runner re-establishes control via the existing socket.io reconnect path. This applies to runner upgrades, manual restarts, and any reboot in which the runner unit is stopped before agents have finished. + Enable and start: ```bash diff --git a/docs/guide/pwa.md b/docs/guide/pwa.md index 5b6dfd5825..631795f046 100644 --- a/docs/guide/pwa.md +++ b/docs/guide/pwa.md @@ -56,11 +56,14 @@ An offline indicator appears when you lose connection. ### Auto-Update -HAPI automatically checks for updates: +HAPI checks for updates in the background and lets you choose when to reload: -- Updates are checked hourly in the background -- When a new version is available, you'll see a prompt -- Click "Reload" to get the latest version +- Updates are checked hourly and when you return to the tab +- When a new version is available, a persistent in-app banner appears at the top +- Tap **Reload** when you're ready to apply the update — the banner stays until you do +- Expand **"Why can't I dismiss this?"** on the banner for the rationale + +HAPI uses a user-controlled reload instead of forcing an automatic refresh, so you choose when to reload. The banner cannot be dismissed without upgrading, so you won't forget you're on an old build. ### Background Sync diff --git a/hub/README.md b/hub/README.md index faed729730..b224b5df78 100644 --- a/hub/README.md +++ b/hub/README.md @@ -152,6 +152,7 @@ Namespace: `/cli` - `update-metadata` - Update session metadata. - `update-state` - Update agent state. - `session-alive` - Keep session active. +- `session-ready` - Cursor ACP `session/load` (or `newSession`) succeeded; hub defers merge/dedup until this arrives on reopen. - `session-end` - Mark session ended. - `machine-alive` - Keep machine online. - `rpc-register` - Register RPC handler. diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index 223af96306..f39b510c7d 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -27,6 +27,11 @@ type SessionEndPayload = { time: number } +type SessionReadyPayload = { + sid: string + time: number +} + type MachineAlivePayload = { machineId: string time: number @@ -38,6 +43,7 @@ export type CliHandlersDeps = { rpcRegistry: RpcRegistry terminalRegistry: TerminalRegistry onSessionAlive?: (payload: SessionAlivePayload) => void + onSessionReady?: (payload: SessionReadyPayload) => void onSessionEnd?: (payload: SessionEndPayload) => void onMachineAlive?: (payload: MachineAlivePayload) => void onWebappEvent?: (event: SyncEvent) => void @@ -48,7 +54,7 @@ export type CliHandlersDeps = { } export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void { - const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps + const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionReady, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps const terminalNamespace = io.of('/terminal') const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null @@ -106,6 +112,7 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers resolveSessionAccess, emitAccessError, onSessionAlive, + onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 123af75a6d..7ffec6dbbe 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -32,6 +32,11 @@ type SessionEndPayload = { reason?: SessionEndReason } +type SessionReadyPayload = { + sid: string + time: number +} + type ResolveSessionAccess = (sessionId: string) => AccessResult type EmitAccessError = (scope: 'session' | 'machine', id: string, reason: AccessErrorReason) => void @@ -62,6 +67,7 @@ export type SessionHandlersDeps = { resolveSessionAccess: ResolveSessionAccess emitAccessError: EmitAccessError onSessionAlive?: (payload: SessionAlivePayload) => void + onSessionReady?: (payload: SessionReadyPayload) => void onSessionEnd?: (payload: SessionEndPayload) => void onWebappEvent?: (event: SyncEvent) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void @@ -74,7 +80,7 @@ export type SessionHandlersDeps = { } export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void { - const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps + const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps socket.on('message', (data: unknown) => { const parsed = messageSchema.safeParse(data) @@ -279,6 +285,18 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session onSessionAlive?.(data) }) + socket.on('session-ready', (data: SessionReadyPayload) => { + if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { + return + } + const sessionAccess = resolveSessionAccess(data.sid) + if (!sessionAccess.ok) { + emitAccessError('session', data.sid, sessionAccess.reason) + return + } + onSessionReady?.(data) + }) + socket.on('messages-consumed', (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean }) => { if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) { return diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index af7533e5c8..02728afaed 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -9,6 +9,7 @@ import { parseAccessToken } from '../utils/accessToken' import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' +import { SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' import type { SyncEvent } from '../sync/syncEngine' import { TerminalRegistry } from './terminalRegistry' import type { CliSocketWithData, SocketData, SocketServer } from './socketTypes' @@ -37,6 +38,7 @@ export type SocketServerDeps = { getSession?: (sessionId: string) => { active: boolean; namespace: string } | null onWebappEvent?: (event: SyncEvent) => void onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void + onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void onMachineAlive?: (payload: { machineId: string; time: number }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void @@ -61,12 +63,14 @@ export function createSocketServer(deps: SocketServerDeps): { } const io = new Server({ - cors: corsOptions + cors: corsOptions, + maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE }) const engine = new Engine({ path: '/socket.io/', cors: corsOptions, + maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE, allowRequest: async (req) => { const origin = req.headers.get('origin') if (!origin || allowAllOrigins || corsOrigins.includes(origin)) { @@ -116,6 +120,7 @@ export function createSocketServer(deps: SocketServerDeps): { rpcRegistry, terminalRegistry, onSessionAlive: deps.onSessionAlive, + onSessionReady: deps.onSessionReady, onSessionEnd: deps.onSessionEnd, onMachineAlive: deps.onMachineAlive, onWebappEvent: deps.onWebappEvent, diff --git a/hub/src/socket/socketLimits.test.ts b/hub/src/socket/socketLimits.test.ts new file mode 100644 index 0000000000..2a8d853de4 --- /dev/null +++ b/hub/src/socket/socketLimits.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test' +import { MAX_GENERATED_IMAGE_BYTES, SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' + +describe('socket limits', () => { + it('buffer size can carry the largest generated image after base64 + JSON-RPC framing', () => { + // Generated images cross the /cli socket as a base64 string inside a JSON-RPC envelope. + // base64 inflates the payload by ~4/3; the engine default (1e6) silently drops anything + // above ~750 KB raw (issue #927). The buffer must exceed the largest allowed image. + const base64Bytes = Math.ceil(MAX_GENERATED_IMAGE_BYTES / 3) * 4 + expect(SOCKET_MAX_HTTP_BUFFER_SIZE).toBeGreaterThan(base64Bytes) + // and must be well above the 1 MB engine default that caused the regression + expect(SOCKET_MAX_HTTP_BUFFER_SIZE).toBeGreaterThan(1e6) + }) +}) diff --git a/hub/src/socket/socketLimits.ts b/hub/src/socket/socketLimits.ts new file mode 100644 index 0000000000..c578c8c4dd --- /dev/null +++ b/hub/src/socket/socketLimits.ts @@ -0,0 +1,10 @@ +// The largest generated image the CLI will serve inline. Must stay in sync with the CLI-side +// limits in cli/src/claude/utils/startHappyServer.ts and cli/src/modules/common/generatedImages.ts. +export const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 + +// Generated images (and other large RPC payloads) cross the /cli socket as a base64 string wrapped +// in a JSON-RPC envelope, which inflates the payload by ~4/3. The engine.io default of 1e6 bytes +// silently drops the CLI -> hub ack frame for any image above ~750 KB raw, so a 25 MB image that +// the MCP tool happily accepts can never reach the browser (issue #927). Size the buffer to carry +// the largest allowed image after base64 + framing, with headroom. +export const SOCKET_MAX_HTTP_BUFFER_SIZE = 48 * 1024 * 1024 diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 2a07ad70f0..58a4734477 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -185,6 +185,7 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine?.handleRealtimeEvent(event), onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload), + onSessionReady: (payload) => syncEngine?.handleSessionReady(payload), onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload), onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta), diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 99060bc927..394a497208 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -15,6 +15,7 @@ import { getImmediateQueuedLocalMessages, countFutureScheduledBySessionIds, countFutureScheduledLocalMessages, + minFutureScheduledAtBySessionIds, countMessages, markMessagesInvoked, mergeSessionMessages, @@ -83,6 +84,10 @@ export class MessageStore { return countFutureScheduledBySessionIds(this.db, sessionIds, now) } + minFutureScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map { + return minFutureScheduledAtBySessionIds(this.db, sessionIds, now) + } + countMessages(sessionId: string): number { return countMessages(this.db, sessionId) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index e569473962..6c164b3475 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -342,5 +342,9 @@ describe('countFutureScheduledLocalMessages', () => { const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now) expect(counts.get(sessionA.id)).toBe(2) expect(counts.get(sessionB.id)).toBeUndefined() + + const nextAt = store.messages.minFutureScheduledAtBySessionIds([sessionA.id, sessionB.id], now) + expect(nextAt.get(sessionA.id)).toBe(now + 60_000) + expect(nextAt.get(sessionB.id)).toBeUndefined() }) }) diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bca78a0cc7..4747c81914 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -361,6 +361,35 @@ export function countFutureScheduledBySessionIds( return counts } +/** Earliest future scheduled_at per session (session-list clock tooltip). */ +export function minFutureScheduledAtBySessionIds( + db: Database, + sessionIds: string[], + now: number +): Map { + const nextAt = new Map() + if (sessionIds.length === 0) { + return nextAt + } + + const placeholders = sessionIds.map(() => '?').join(',') + const rows = db.prepare(` + SELECT session_id, MIN(scheduled_at) AS next_at + FROM messages + WHERE session_id IN (${placeholders}) + AND invoked_at IS NULL + AND local_id IS NOT NULL + AND scheduled_at IS NOT NULL + AND scheduled_at > ? + GROUP BY session_id + `).all(...sessionIds, now) as { session_id: string; next_at: number }[] + + for (const row of rows) { + nextAt.set(row.session_id, row.next_at) + } + return nextAt +} + export function getMaxSeq(db: Database, sessionId: string): number { const row = db.prepare( 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?' diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 8c98fad941..d0825c0d68 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' -import { RpcGateway } from './rpcGateway' +import { RpcGateway, RpcTargetMissingError } from './rpcGateway' function createGateway() { const timeouts: number[] = [] @@ -70,3 +70,48 @@ describe('RpcGateway RPC timeouts', () => { }) }) +// tiann/hapi#916: rpcCall throws a typed `RpcTargetMissingError` when the +// target CLI is unreachable, so syncEngine.archiveSession can narrow on it +// and treat the kill as a benign no-op. +describe('RpcGateway no-target diagnostics (tiann/hapi#916)', () => { + it('throws RpcTargetMissingError(handler-not-registered) when no socket is registered for the method', async () => { + const io = { + of() { + return { + sockets: { + get() { return undefined } + } + } + } + } as unknown as Server + const rpcRegistry = { + getSocketIdForMethod() { return undefined } + } as unknown as RpcRegistry + const gateway = new RpcGateway(io, rpcRegistry) + + const error = await gateway.killSession('session-1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(RpcTargetMissingError) + expect((error as RpcTargetMissingError).code).toBe('handler-not-registered') + }) + + it('throws RpcTargetMissingError(socket-disconnected) when the socket id is registered but no socket exists', async () => { + const io = { + of() { + return { + sockets: { + get() { return undefined } + } + } + } + } as unknown as Server + const rpcRegistry = { + getSocketIdForMethod() { return 'socket-1' } + } as unknown as RpcRegistry + const gateway = new RpcGateway(io, rpcRegistry) + + const error = await gateway.killSession('session-1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(RpcTargetMissingError) + expect((error as RpcTargetMissingError).code).toBe('socket-disconnected') + }) +}) + diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7811c90174..5a63f4547c 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -24,6 +24,27 @@ import type { RpcRegistry } from '../socket/rpcRegistry' const DEFAULT_RPC_TIMEOUT_MS = 30_000 const MODEL_LIST_RPC_TIMEOUT_MS = 120_000 +/** + * tiann/hapi#916: thrown by {@link RpcGateway.rpcCall} when the target CLI is + * unreachable (handler not registered or socket disconnected). Callers can + * narrow on this to treat "CLI gone" as a benign condition (e.g. archive + * still succeeds at the hub level) without swallowing real RPC errors like + * timeouts or protocol failures. + */ +export class RpcTargetMissingError extends Error { + readonly code: 'handler-not-registered' | 'socket-disconnected' + readonly method: string + + constructor(method: string, reason: 'handler-not-registered' | 'socket-disconnected') { + super(reason === 'handler-not-registered' + ? `RPC handler not registered: ${method}` + : `RPC socket disconnected: ${method}`) + this.name = 'RpcTargetMissingError' + this.code = reason + this.method = method + } +} + export type RpcCommandResponse = CommandResponse export type RpcReadFileResponse = FileReadResponse export type RpcGeneratedImageResponse = GeneratedImageResponse @@ -89,7 +110,7 @@ export class RpcGateway { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode @@ -261,6 +282,12 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + /** Generic Pi RPC call — routes all Pi-specific session RPCs through + * a single entry point instead of per-method wrappers. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.sessionRpc(sessionId, method, params ?? {}, timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS) as T + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse } @@ -286,12 +313,12 @@ export class RpcGateway { private async rpcCall(method: string, params: unknown, timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS): Promise { const socketId = this.rpcRegistry.getSocketIdForMethod(method) if (!socketId) { - throw new Error(`RPC handler not registered: ${method}`) + throw new RpcTargetMissingError(method, 'handler-not-registered') } const socket = this.io.of('/cli').sockets.get(socketId) if (!socket) { - throw new Error(`RPC socket disconnected: ${method}`) + throw new RpcTargetMissingError(method, 'socket-disconnected') } const response = await socket.timeout(timeoutMs).emitWithAck('rpc-request', { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index b498cac05a..304b01ccae 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -7,6 +7,11 @@ import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' import { extractBackgroundTaskDelta } from './backgroundTasks' const QUEUED_MESSAGE_THINKING_GRACE_MS = 15_000 +// tiann/hapi#919: metadata writers (renameSession, clearSessionArchiveMetadata, +// restoreSessionArchiveMetadata) retry on version-mismatch with a fresh cache +// snapshot. Cap retries so genuine concurrent contention still surfaces to the +// HTTP caller as 409 instead of spinning forever. +const METADATA_RETRY_ATTEMPTS = 5 type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode' export class SessionCache { @@ -417,7 +422,7 @@ export class SessionCache { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null @@ -436,15 +441,27 @@ export class SessionCache { this.markRuntimeConfigUpdated(sessionId, 'permissionMode', appliedAt) } if (config.model !== undefined) { - if (config.model !== session.model) { - const updated = this.store.sessions.setSessionModel(sessionId, config.model, session.namespace, { + const modelValue = config.model + // Normalize object form { provider, modelId } to plain string for DB storage + const piModelObject = modelValue !== null && typeof modelValue === 'object' + ? modelValue + : null + const normalizedModel: string | null = piModelObject ? piModelObject.modelId : modelValue as string | null + if (normalizedModel !== session.model) { + const updated = this.store.sessions.setSessionModel(sessionId, normalizedModel, session.namespace, { touchUpdatedAt: false }) if (!updated) { throw new Error('Failed to update session model') } } - session.model = config.model + session.model = normalizedModel + // Pi requires provider + modelId to uniquely identify a model. + // Persist the provider-qualified form in metadata so web can + // resolve the exact model even when two providers share a modelId. + if (session.metadata?.flavor === 'pi') { + this.persistPiSelectedModel(session, piModelObject) + } this.markRuntimeConfigUpdated(sessionId, 'model', appliedAt) } if (config.modelReasoningEffort !== undefined) { @@ -510,32 +527,105 @@ export class SessionCache { return updatedAt !== undefined && payloadTime < updatedAt } - async renameSession(sessionId: string, name: string): Promise { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error('Session not found') - } + /** + * tiann/hapi#916: hub-side write of the archive-metadata fields normally + * authored by the CLI's `archiveAndClose`. Called by `syncEngine.archiveSession` + * when the kill-RPC fails because the CLI is unreachable (e.g. the + * hub-restart cascade already killed it). Without this, the route would + * either 500 (pre-fix) or silently return ok=true while leaving + * `lifecycleState=running` on disk — both confuse the operator. + * + * Idempotent: if `lifecycleState` is already `archived` we return without + * touching the row to avoid resetting `lifecycleStateSince`. Best-effort: + * if every retry hits `version-mismatch` (genuine contention) the original + * `archiveSession` flow still marks the session inactive in cache via + * `handleSessionEnd`, just without flipping the persisted lifecycle. + */ + markSessionArchivedFromHub(sessionId: string, reason: string): void { + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + if (current.lifecycleState === 'archived') { + return + } - const currentMetadata = session.metadata ?? { path: '', host: '' } - const newMetadata = { ...currentMetadata, name } + const next: Record = { + ...current, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'hub', + archiveReason: reason + } - const result = this.store.sessions.updateSessionMetadata( - sessionId, - newMetadata, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - if (result.result === 'error') { - throw new Error('Failed to update session metadata') + if (result.result === 'error') { + // tiann/hapi#916 review feedback: persistence failure must + // surface so the route returns 5xx. Silently returning here + // would let `/archive` claim success while the row stays + // unarchived in the DB. + throw new Error('Failed to archive session metadata from hub') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } + + this.refreshSession(sessionId) } - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently. Please try again.') + // tiann/hapi#916 review feedback: exhausted retries means we never + // got a successful write. Match the renameSession / mergeSessions + // contract and surface this as an error so non-RPC failures stay + // 5xx per the issue's acceptance criteria. + throw new Error('Session was modified concurrently while archiving from hub') + } + + async renameSession(sessionId: string, name: string): Promise { + // tiann/hapi#919: retry-with-refresh on version-mismatch instead of + // throwing on the first contention. Mirrors the good pattern in + // mergeSessions (~L780) and in syncEngine's metadata helpers. Without + // this, a stale cache snapshot produces forever-409 on PATCH /sessions/:id + // until some unrelated event triggers a refresh. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) { + throw new Error('Session not found') + } + + const currentMetadata = session.metadata ?? { path: '', host: '' } + const newMetadata = { ...currentMetadata, name } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + newMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } + + this.refreshSession(sessionId) } - this.refreshSession(sessionId) + throw new Error('Session was modified concurrently. Please try again.') } /** @@ -551,52 +641,59 @@ export class SessionCache { * No-op when metadata is null (callers should pre-check). */ async clearSessionArchiveMetadata(sessionId: string): Promise<{ cursorSessionProtocol?: 'acp' | 'stream-json' }> { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error('Session not found') - } + // tiann/hapi#919: retry-with-refresh on version-mismatch. The reopen + // flow runs this on every archived-session resume — a stale snapshot + // here used to forever-409 the only reopen affordance. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) { + throw new Error('Session not found') + } - const currentMetadata = session.metadata - if (!currentMetadata) { - throw new Error('Session metadata missing') - } - - const next: Record = { ...currentMetadata } - delete next.lifecycleState - delete next.archivedBy - delete next.archiveReason - next.lifecycleStateSince = Date.now() - - let cursorSessionProtocol: 'acp' | 'stream-json' | undefined - if (currentMetadata.flavor === 'cursor') { - const existing = currentMetadata.cursorSessionProtocol - if (existing === 'acp' || existing === 'stream-json') { - cursorSessionProtocol = existing - } else if (currentMetadata.cursorSessionId) { - // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. - cursorSessionProtocol = 'stream-json' - next.cursorSessionProtocol = 'stream-json' + const currentMetadata = session.metadata + if (!currentMetadata) { + throw new Error('Session metadata missing') } - } - const result = this.store.sessions.updateSessionMetadata( - sessionId, - next, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + const next: Record = { ...currentMetadata } + delete next.lifecycleState + delete next.archivedBy + delete next.archiveReason + next.lifecycleStateSince = Date.now() + + let cursorSessionProtocol: 'acp' | 'stream-json' | undefined + if (currentMetadata.flavor === 'cursor') { + const existing = currentMetadata.cursorSessionProtocol + if (existing === 'acp' || existing === 'stream-json') { + cursorSessionProtocol = existing + } else if (currentMetadata.cursorSessionId) { + // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. + cursorSessionProtocol = 'stream-json' + next.cursorSessionProtocol = 'stream-json' + } + } - if (result.result === 'error') { - throw new Error('Failed to update session metadata') - } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently. Please try again.') + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return cursorSessionProtocol ? { cursorSessionProtocol } : {} + } + + this.refreshSession(sessionId) } - this.refreshSession(sessionId) - return cursorSessionProtocol ? { cursorSessionProtocol } : {} + throw new Error('Session was modified concurrently. Please try again.') } /** @@ -620,50 +717,59 @@ export class SessionCache { lifecycleStateSince?: number } ): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - const current = session.metadata - if (!current) return + // tiann/hapi#919: retry-with-refresh on version-mismatch. This is the + // /reopen rollback path — if it fails the session is left in a + // half-cleared archive state, so making it robust to a stale snapshot + // matters more here than for the other two. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + + const next: Record = { ...current } + if (snapshot.lifecycleState !== undefined) { + next.lifecycleState = snapshot.lifecycleState + } else { + delete next.lifecycleState + } + if (snapshot.archivedBy !== undefined) { + next.archivedBy = snapshot.archivedBy + } else { + delete next.archivedBy + } + if (snapshot.archiveReason !== undefined) { + next.archiveReason = snapshot.archiveReason + } else { + delete next.archiveReason + } + if (snapshot.lifecycleStateSince !== undefined) { + next.lifecycleStateSince = snapshot.lifecycleStateSince + } else { + delete next.lifecycleStateSince + } - const next: Record = { ...current } - if (snapshot.lifecycleState !== undefined) { - next.lifecycleState = snapshot.lifecycleState - } else { - delete next.lifecycleState - } - if (snapshot.archivedBy !== undefined) { - next.archivedBy = snapshot.archivedBy - } else { - delete next.archivedBy - } - if (snapshot.archiveReason !== undefined) { - next.archiveReason = snapshot.archiveReason - } else { - delete next.archiveReason - } - if (snapshot.lifecycleStateSince !== undefined) { - next.lifecycleStateSince = snapshot.lifecycleStateSince - } else { - delete next.lifecycleStateSince - } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - const result = this.store.sessions.updateSessionMetadata( - sessionId, - next, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + if (result.result === 'error') { + throw new Error('Failed to restore archive metadata') + } - if (result.result === 'error') { - throw new Error('Failed to restore archive metadata') - } + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently during reopen rollback') + this.refreshSession(sessionId) } - this.refreshSession(sessionId) + throw new Error('Session was modified concurrently during reopen rollback') } async deleteSession(sessionId: string): Promise { @@ -923,6 +1029,34 @@ export class SessionCache { session.metadataVersion = result.version } + private persistPiSelectedModel(session: Session, piSelected: { provider: string; modelId: string } | null): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.piSelectedModel === piSelected) { + return + } + + const nextMetadata = { ...currentMetadata, piSelectedModel: piSelected } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } + private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null { if (oldState === null) return newState if (newState === null) return oldState @@ -948,12 +1082,13 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId } if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId } if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId } if (metadata.opencodeSessionId) return { field: 'opencodeSessionId', value: metadata.opencodeSessionId } if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId } + if (metadata.piSessionId) return { field: 'piSessionId', value: metadata.piSessionId } return null } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 5de15ce2b1..8fa18c074a 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, spyOn } from 'bun:test' import { toSessionSummary } from '@hapi/protocol' import type { SyncEvent } from '@hapi/protocol/types' import { Store } from '../store' @@ -1128,6 +1128,276 @@ describe('session model', () => { } }) + it('defers mergeSessions for cursor reopen until session-ready (load failure leaves old row)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-reopen-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => 'ended' + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Session ended before Cursor ACP load completed', + code: 'resume_failed' + }) + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + + it('does not dedup-merge when ACP spawn ends without session-ready', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-acp-dedup-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-dedup-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSession = engine.getOrCreateSession( + 'cursor-acp-dedup-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-dedup-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + engine.handleSessionAlive({ sid: spawnedSession.id, time: Date.now() }) + engine.handleSessionEnd({ sid: spawnedSession.id, time: Date.now(), reason: 'error' }) + + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + + it('mergeSessions runs for cursor reopen after session-ready', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old-ready', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-ok', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-reopen-spawned-ready', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-ok', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + engine.handleSessionReady({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId }) + expect(mergeCalls).toBe(1) + expect(store.sessions.getSession(oldSession.id)).toBeNull() + } finally { + engine.stop() + } + }) + + it('does not wait for session-ready on cursor stream-json reopen', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-legacy-reopen-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'legacy-csid', + cursorSessionProtocol: 'stream-json' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-legacy-reopen-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'legacy-csid', + cursorSessionProtocol: 'stream-json' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let waitForSessionReadyCalls = 0 + ;(engine as any).waitForSessionReady = async () => { + waitForSessionReadyCalls += 1 + return 'timeout' + } + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId }) + expect(waitForSessionReadyCalls).toBe(0) + } finally { + engine.stop() + } + }) + it('resolves a local resume target for a Codex session', () => { const store = new Store(':memory:') const engine = new SyncEngine( @@ -1830,6 +2100,62 @@ describe('session model', () => { // completedRequests has req-1 expect(state.completedRequests?.['req-1']).toBeDefined() }) + + it('merges duplicate when piSessionId collides', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1') + + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + expect(s1.id).not.toBe(s2.id) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeUndefined() + expect(cache.getSession(s2.id)).toBeDefined() + + const messages = store.messages.getMessages(s2.id, 100) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('preserves sessions with different piSessionId', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-A' }, + null, + 'default' + ) + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-B' }, + null, + 'default' + ) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeDefined() + expect(cache.getSession(s2.id)).toBeDefined() + }) }) describe('clearSessionArchiveMetadata', () => { @@ -2140,4 +2466,265 @@ describe('session model', () => { })).resolves.toBeUndefined() }) }) + + // tiann/hapi#916: when the CLI is gone, the kill-RPC throws + // RpcTargetMissingError. markSessionArchivedFromHub writes the archive + // metadata directly so the row's lifecycleState still flips to 'archived'. + describe('markSessionArchivedFromHub (tiann/hapi#916)', () => { + it('flips lifecycleState to archived with archivedBy=hub and the supplied reason', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive', + { path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-1' }, + null, + 'default' + ) + + cache.markSessionArchivedFromHub(session.id, 'Archived from hub (CLI unreachable)') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('hub') + expect(meta?.archiveReason).toBe('Archived from hub (CLI unreachable)') + expect(typeof meta?.lifecycleStateSince).toBe('number') + }) + + it('is idempotent for already-archived sessions (does not reset lifecycleStateSince)', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const initialSince = 1700000000000 + const session = cache.getOrCreateSession( + 'session-already-archived', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: initialSince + }, + null, + 'default' + ) + + cache.markSessionArchivedFromHub(session.id, 'Should not overwrite') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('cli') + expect(meta?.archiveReason).toBe('User terminated') + expect(meta?.lifecycleStateSince).toBe(initialSince) + }) + + it('self-heals on version-mismatch via refresh-and-retry', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-stale', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'oob' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + cache.markSessionArchivedFromHub(session.id, 'CLI unreachable') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('hub') + expect(meta?.name).toBe('oob') + }) + + // tiann/hapi#916 review feedback: persistence failures must surface + // so the /archive route returns 5xx per the acceptance criteria + // "Non-RPC errors during archive still propagate as 5xx (DB write + // failure, etc.)" — silent return would let the route claim success + // while the row stays unarchived. + it('throws when the store reports a hard error on the metadata write', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-error', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({ + result: 'error', + error: new Error('simulated DB write failure') + } as ReturnType) + + try { + expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Failed to archive session metadata from hub/) + } finally { + updateSpy.mockRestore() + } + }) + + it('throws when retries are exhausted by sustained version-mismatch contention', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-exhausted', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({ + result: 'version-mismatch' + } as ReturnType) + + try { + expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Session was modified concurrently while archiving from hub/) + } finally { + updateSpy.mockRestore() + } + }) + }) + + // tiann/hapi#919: the three metadata writers must self-heal on + // version-mismatch instead of one-shot-throwing. The bug was that a + // stale cache snapshot produced forever-409 on the corresponding HTTP + // endpoints — the cache never refreshed, so the same retry hit the + // same mismatch. Pattern mirrors mergeSessions (line ~780). + describe('version-mismatch self-heal (tiann/hapi#919)', () => { + it('renameSession recovers after a stale cache snapshot is detected', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-rename-stale', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + // Simulate a concurrent writer bumping the DB version under our feet: + // write a metadata patch out-of-band via the store, leaving the cache + // snapshot stale. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'concurrent-rename' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + // Cache still holds the pre-OOB snapshot. Pre-fix, this call threw + // 'Session was modified concurrently'. Post-fix, it refreshes and + // succeeds. + await expect(cache.renameSession(session.id, 'final-name')).resolves.toBeUndefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.name).toBe('final-name') + }) + + it('clearSessionArchiveMetadata recovers after a stale cache snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-clear-stale', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-stale', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + + // Concurrent rename via the store bumps the DB version. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'oob-name' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + await expect(cache.clearSessionArchiveMetadata(session.id)).resolves.toBeDefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBeUndefined() + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.name).toBe('oob-name') + }) + + it('restoreSessionArchiveMetadata recovers after a stale cache snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore-stale', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-restore-stale' + // Started without archive metadata - simulates the post-clear state. + }, + null, + 'default' + ) + + // Concurrent unrelated write bumps DB version. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'parallel-rename' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + await expect(cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234 + })).resolves.toBeUndefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archiveReason).toBe('User terminated') + expect(meta?.lifecycleStateSince).toBe(1234) + expect(meta?.name).toBe('parallel-rename') + }) + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a1e7368619..d59f58fd47 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -23,6 +23,7 @@ import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' import { RpcGateway, + RpcTargetMissingError, type RpcCodexModel, type RpcCommandResponse, type RpcDeleteUploadResponse, @@ -133,6 +134,8 @@ export class SyncEngine { private readonly messageService: MessageService private readonly rpcGateway: RpcGateway private inactivityTimer: NodeJS.Timeout | null = null + /** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */ + private readonly sessionReadyIds = new Set() constructor( private readonly store: Store, @@ -190,6 +193,10 @@ export class SyncEngine { return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now) } + getNextScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map { + return this.store.messages.minFutureScheduledAtBySessionIds(sessionIds, now) + } + getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } @@ -269,6 +276,9 @@ export class SyncEngine { this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) { + if (!this.canRunCursorDedup(after)) { + return + } void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates }) @@ -306,11 +316,21 @@ export class SyncEngine { this.triggerDedupIfNeeded(payload.sid) } + handleSessionReady(payload: { sid: string; time: number }): void { + this.sessionReadyIds.add(payload.sid) + this.triggerDedupIfNeeded(payload.sid) + } + clearQueuedThinkingGrace(sessionId: string): void { this.sessionCache.clearQueuedThinkingGrace(sessionId) } handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void { + const before = this.sessionCache.getSession(payload.sid) + const isCursorAcp = before?.metadata?.flavor === 'cursor' + && before.metadata.cursorSessionProtocol === 'acp' + const shouldRetryDedup = !isCursorAcp || this.sessionReadyIds.has(payload.sid) + this.sessionCache.handleSessionEnd(payload) this.eventPublisher.emit({ type: 'session-ended', @@ -318,8 +338,12 @@ export class SyncEngine { reason: payload.reason }) // Retry dedup now that this session is inactive — a prior dedup may have - // skipped it because it was still active at the time. - this.triggerDedupIfNeeded(payload.sid) + // skipped it because it was still active at the time. Cursor ACP rows that + // never reached session-ready must not dedup-merge the original on failure. + if (shouldRetryDedup) { + this.triggerDedupIfNeeded(payload.sid) + } + this.sessionReadyIds.delete(payload.sid) } handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void { @@ -429,7 +453,24 @@ export class SyncEngine { } async archiveSession(sessionId: string): Promise { - await this.rpcGateway.killSession(sessionId) + // tiann/hapi#916: when the CLI is already gone (e.g. after a + // hub-restart cascade SIGTERMed the runner but the in-memory + // `active` flag has not been reconciled yet) the kill-RPC throws + // and the route used to surface that as HTTP 500. Treat the + // missing target as a benign condition: still flip the session's + // lifecycleState to `archived` in the hub-side metadata so the + // UI does not see a half-cleaned zombie, and continue to mark + // it inactive in the cache. Real RPC errors (timeout, protocol + // failure) still propagate as 5xx. + try { + await this.rpcGateway.killSession(sessionId) + } catch (error) { + if (error instanceof RpcTargetMissingError) { + this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)') + } else { + throw error + } + } this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } @@ -618,7 +659,7 @@ export class SyncEngine { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null @@ -634,7 +675,7 @@ export class SyncEngine { return } - const result = await this.rpcGateway.requestSessionConfig(sessionId, config) + const result = await this.rpcGateway.requestSessionConfig(sessionId, config) as Record if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } @@ -654,7 +695,7 @@ export class SyncEngine { } const applied = obj.applied if (!applied || typeof applied !== 'object') { - throw new Error('Missing applied session config') + throw new Error(`Missing applied session config, got: ${JSON.stringify(result)}`) } const requestedKeys = Object.keys(config) as Array @@ -714,6 +755,7 @@ export class SyncEngine { if (flavor === 'opencode') return metadata.opencodeSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null + if (flavor === 'pi') return metadata.piSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } @@ -1162,6 +1204,19 @@ export class SyncEngine { // permissionMode is passed to spawnSession above; do not call set-session-config here. // session-alive can arrive before the CLI registers that RPC handler, which caused resume_failed. + const needsReadyBeforeMerge = spawnResult.sessionId !== access.sessionId + && flavor === 'cursor' + && metadata.cursorSessionProtocol === 'acp' + if (needsReadyBeforeMerge) { + const readyResult = await this.waitForSessionReady(spawnResult.sessionId) + if (readyResult !== 'ready') { + const message = readyResult === 'ended' + ? 'Session ended before Cursor ACP load completed' + : 'Session failed to become ready' + return { type: 'error', message, code: 'resume_failed' } + } + } + if (spawnResult.sessionId !== access.sessionId) { // The old session may have already been merged by the automatic dedup path // (triggered when the spawned CLI sets its agent session ID in metadata). @@ -1405,11 +1460,26 @@ export class SyncEngine { && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) + && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) + && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) + } + + private canRunCursorDedup(session: Session): boolean { + if (session.metadata?.flavor !== 'cursor') { + return true + } + if (session.metadata?.cursorSessionProtocol !== 'acp') { + return true + } + return this.sessionReadyIds.has(session.id) } private triggerDedupIfNeeded(sessionId: string): void { const session = this.sessionCache.getSession(sessionId) if (session?.metadata) { + if (!this.canRunCursorDedup(session)) { + return + } void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => { // best-effort: web-side safety net hides remaining duplicates }) @@ -1428,6 +1498,21 @@ export class SyncEngine { return false } + async waitForSessionReady(sessionId: string, timeoutMs: number = 60_000): Promise<'ready' | 'ended' | 'timeout'> { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (this.sessionReadyIds.has(sessionId)) { + return 'ready' + } + const session = this.getSession(sessionId) + if (!session?.active) { + return 'ended' + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return 'timeout' + } + async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { @@ -1520,6 +1605,11 @@ export class SyncEngine { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId) } diff --git a/hub/src/web/routes/git.test.ts b/hub/src/web/routes/git.test.ts new file mode 100644 index 0000000000..320f1eb993 --- /dev/null +++ b/hub/src/web/routes/git.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createGitRoutes } from './git' + +function buildApp(engine: Partial): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createGitRoutes(() => engine as SyncEngine)) + return app +} + +describe('generated images route', () => { + it('serves generated images with an immutable cache header instead of no-store', async () => { + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const session = { id: 'session-1', namespace: 'default', active: true } as unknown as Session + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + readGeneratedImage: async () => ({ + success: true, + content: pngBytes.toString('base64'), + mimeType: 'image/png', + fileName: 'shot.png' + }) + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/generated-images/img-1') + + expect(response.status).toBe(200) + const cacheControl = response.headers.get('cache-control') ?? '' + // Generated images are content-addressed by an immutable random id, so they must be + // cacheable; `no-store` forces a full RPC round-trip on every remount (issue #927). + expect(cacheControl).toContain('immutable') + expect(cacheControl).not.toContain('no-store') + expect(response.headers.get('etag')).toBe('"img-1"') + }) + + it('returns 304 without an RPC round-trip when If-None-Match matches', async () => { + const session = { id: 'session-1', namespace: 'default', active: true } as unknown as Session + let rpcCalls = 0 + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + readGeneratedImage: async () => { + rpcCalls += 1 + return { success: true, content: '', mimeType: 'image/png', fileName: 'shot.png' } + } + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/generated-images/img-1', { + headers: { 'if-none-match': '"img-1"' } + }) + + expect(response.status).toBe(304) + // The whole point: a cache hit must not touch the CLI over the socket. + expect(rpcCalls).toBe(0) + }) +}) diff --git a/hub/src/web/routes/git.ts b/hub/src/web/routes/git.ts index 8cd27a1e01..08a889e313 100644 --- a/hub/src/web/routes/git.ts +++ b/hub/src/web/routes/git.ts @@ -35,6 +35,21 @@ async function runRpc(fn: () => Promise): Promise { + const trimmed = candidate.trim() + return trimmed === '*' || trimmed.replace(/^W\//, '') === normalized + }) +} + export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -150,16 +165,31 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono engine.readGeneratedImage(sessionResult.sessionId, parsed.data.imageId)) if (!result.success || !result.content) { return c.json({ success: false, error: result.error ?? 'Generated image not found' }, 404) } const bytes = Uint8Array.from(Buffer.from(result.content, 'base64')) + // Generated images are content-addressed by an immutable random id, so the bytes for a + // given id never change. Cache aggressively so remounts/scroll/session reopen don't + // re-run the full HTTP -> socket.io RPC -> base64 round-trip every time (issue #927). return c.body(bytes, 200, { 'Content-Type': result.mimeType ?? 'application/octet-stream', 'Content-Disposition': `inline; filename="${encodeURIComponent(result.fileName ?? 'generated-image')}"`, - 'Cache-Control': 'no-store' + 'Cache-Control': GENERATED_IMAGE_CACHE_CONTROL, + ETag: etag }) }) diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 0e17ec0770..9056111d6e 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -27,7 +27,11 @@ export function requireSession( return c.json({ error }, status) } if (options?.requireActive && !access.session.active) { - return c.json({ error: 'Session is inactive' }, 409) + // `code` lets the web client discriminate the inactive-session 409 from + // other 4xx without string-matching the human message (which is i18n'd + // by the consumer and may change). See web onError handler in + // router.tsx which surfaces a Reopen affordance on this code. + return c.json({ error: 'Session is inactive', code: 'session_inactive' }, 409) } return { sessionId: access.sessionId, session: access.session } } diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index fbf12f1e6e..64248810ff 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -217,3 +217,27 @@ describe('POST /api/sessions/:id/messages — scheduledAt + attachments rejected expect(sentMessages).toHaveLength(1) }) }) + +// --------------------------------------------------------------------------- +// #918: inactive session 409 carries a machine-readable code +// --------------------------------------------------------------------------- + +describe('POST /api/sessions/:id/messages — inactive session response shape', () => { + it('returns 409 with code "session_inactive" when sending to an inactive session', async () => { + const { app, sentMessages } = createApp({ active: false }) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello', localId: 'local-inactive' }) + }) + + expect(response.status).toBe(409) + const body = await response.json() as { error: string; code: string } + expect(body.error).toBe('Session is inactive') + // Web client discriminates this branch via `code` without string-matching + // the human message; see useSendMessage onError consumer in router.tsx. + expect(body.code).toBe('session_inactive') + expect(sentMessages).toHaveLength(0) + }) +}) diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fc1a7032c6..01f6d859c9 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -62,6 +62,7 @@ function createApp(session: Session, opts?: { listSlashCommands?: SyncEngine['listSlashCommands'] getSessionExport?: (sessionId: string, session: Session) => unknown sessionExists?: boolean + archiveSession?: (sessionId: string) => Promise }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -104,6 +105,7 @@ function createApp(session: Session, opts?: { resumed: true })) const sessionExists = opts?.sessionExists !== false + const archiveSessionMock = opts?.archiveSession ?? (async () => {}) const engine = { resolveSessionAccess: () => sessionExists ? { ok: true, sessionId: session.id, session } @@ -115,6 +117,7 @@ function createApp(session: Session, opts?: { listOpencodeReasoningEffortOptionsForSession, resumeSession, reopenSession, + archiveSession: archiveSessionMock, getSessionExport: opts?.getSessionExport ?? (() => ({ type: 'success', payload: { @@ -576,7 +579,7 @@ describe('sessions routes', () => { expect(response.status).toBe(400) expect(await response.json()).toEqual({ - error: 'Effort selection is only supported for Claude sessions' + error: 'Effort selection is not supported for this session type' }) expect(applySessionConfigCalls).toEqual([]) }) @@ -1014,4 +1017,124 @@ describe('sessions routes', () => { }) }) + // tiann/hapi#916: archive endpoint must be idempotent for already-archived + // rows and for split-brain rows whose CLI is gone but the in-memory `active` + // flag has not been reconciled to false yet. + describe('POST /sessions/:id/archive (tiann/hapi#916)', () => { + it('returns 2xx and calls archiveSession for an active session', async () => { + const calls: string[] = [] + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async (sessionId: string) => { calls.push(sessionId) } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(calls).toEqual(['session-1']) + }) + + it('returns 2xx and skips archiveSession when the row is already archived (idempotent)', async () => { + let called = false + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + } + }) + const { app } = createApp(session, { + archiveSession: async () => { called = true } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, alreadyArchived: true }) + expect(called).toBe(false) + }) + + it('returns 2xx when the active session\'s CLI is gone — engine.archiveSession swallows the missing-RPC error', async () => { + // Pre-fix this returned 500 because rpcGateway.killSession threw + // 'RPC handler not registered'. Post-fix the engine narrows on + // RpcTargetMissingError and still flips lifecycle to archived. + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async () => { + // Simulates the post-fix behavior: engine catches the + // RpcTargetMissingError, calls markSessionArchivedFromHub, + // and returns normally. + } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + }) + + it('still surfaces a 5xx for non-RPC errors (e.g. DB write failure)', async () => { + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async () => { + throw new Error('DB write failed') + } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + expect(response.status).toBe(500) + }) + + it('returns 404 when the session id is unknown', async () => { + const session = createSession() + const { app } = createApp(session, { sessionExists: false }) + + const response = await app.request('/api/sessions/missing-id/archive', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Session not found' }) + }) + + it('returns 409 for an inactive non-archived row whose lifecycle is not running', async () => { + let called = false + const session = createSession({ active: false }) + const { app } = createApp(session, { + archiveSession: async () => { called = true } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'Session is inactive' }) + expect(called).toBe(false) + }) + + it('returns 2xx for an inactive split-brain row still marked lifecycleState=running', async () => { + const calls: string[] = [] + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'running' + } + }) + const { app } = createApp(session, { + archiveSession: async (sessionId: string) => { calls.push(sessionId) } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(calls).toEqual(['session-1']) + }) + }) + }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b6f84e6657..f199a2938c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -12,11 +12,13 @@ import { SessionModelRequestSchema, SessionPermissionModeRequestSchema, supportsModelChange, + supportsEffort, toSessionSummary, UploadFileRequestSchema } from '@hapi/protocol' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { SlashCommand } from '@hapi/protocol/apiTypes' -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import type { SyncEngine, Session } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' @@ -82,11 +84,13 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return b.updatedAt - a.updatedAt }) const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) + const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) const sessions = sessionRecords.map((session) => { const summary = toSessionSummary(session) return { ...summary, - futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0 + futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0, + nextScheduledAt: nextScheduledAt.get(session.id) ?? null } }) @@ -291,16 +295,31 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho }) app.post('/sessions/:id/archive', async (c) => { + // tiann/hapi#916: relax the blanket `requireActive: true` guard so + // the endpoint is idempotent for already-archived rows AND can clean + // up split-brain rows after a hub-restart cascade (inactive in cache + // but metadata.lifecycleState still 'running'). Normal inactive rows + // that are not archived (completed stubs, UI Delete/Reopen targets) + // keep the old 409 contract. const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + const sessionResult = requireSessionFromParam(c, engine) if (sessionResult instanceof Response) { return sessionResult } + const lifecycleState = sessionResult.session.metadata?.lifecycleState + if (lifecycleState === 'archived') { + return c.json({ ok: true, alreadyArchived: true }) + } + + if (!sessionResult.session.active && lifecycleState !== 'running') { + return c.json({ error: 'Session is inactive' }, 409) + } + await engine.archiveSession(sessionResult.sessionId) return c.json({ ok: true }) }) @@ -536,8 +555,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude') { - return c.json({ error: 'Effort selection is only supported for Claude sessions' }, 400) + if (!supportsEffort(flavor)) { + return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } try { @@ -834,5 +853,38 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + // Helper: guard + flavor check + error handling for Pi session endpoints + async function withPiSession( + c: Context, + handler: (ctx: { sessionId: string; engine: SyncEngine }) => Promise + ): Promise { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'pi') { + return c.json({ success: false, error: 'Not a Pi session' }, 400) + } + + try { + return await handler({ sessionId: sessionResult.sessionId, engine }) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Internal error' + }, 500) + } + } + + // --- Pi models --- + app.get('/sessions/:id/pi-models', (c) => + withPiSession(c, async ({ sessionId, engine }) => + c.json(await engine.callPiRpc(sessionId, RPC_METHODS.ListPiModels, {}, 120_000)) + ) + ) + return app } diff --git a/package.json b/package.json index c33c2acc89..9bdde30e25 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "release-all": "cd cli && bun run release-all" }, "devDependencies": { - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.61.0", "concurrently": "^9.2.1", - "playwright": "1.60.0", + "playwright": "1.61.0", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0" } diff --git a/playwright.config.ts b/playwright.config.ts index 779efd169c..b3b0a41eb1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,21 +1,32 @@ import { defineConfig, devices } from '@playwright/test' +import { + annotatedVideoUseOption, + shouldRecordAnnotatedVideo, +} from './scripts/dev/playwright-annotated-video.mjs' const PORT = 5179 const BASE_URL = `http://localhost:${PORT}` +const peerWebUrl = process.env.HAPI_PEER_WEB_URL?.replace(/\/$/, '') +const usePeerStack = Boolean(peerWebUrl) +const baseURL = peerWebUrl ?? BASE_URL + export default defineConfig({ testDir: './e2e', - timeout: 30_000, - expect: { timeout: 5_000 }, + timeout: usePeerStack ? 60_000 : 30_000, + expect: { timeout: usePeerStack ? 10_000 : 5_000 }, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: 1, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { - baseURL: BASE_URL, + baseURL, trace: 'retain-on-failure', screenshot: 'only-on-failure', + video: shouldRecordAnnotatedVideo() + ? annotatedVideoUseOption('on', usePeerStack ? { width: 1440, height: 900 } : undefined) + : 'off', }, projects: [ { @@ -23,26 +34,21 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], launchOptions: { - // The CI runner and most sandboxed dev environments - // run as root or under restricted user namespaces; - // without --no-sandbox chromium silently exits 0 a - // few seconds after launch and the page handshake - // times out. Keep the flag scoped to launchOptions - // so this is the only place a future maintainer has - // to revisit if they harden the runner. - args: ['--no-sandbox'], + args: usePeerStack + ? ['--no-sandbox', '--disable-dev-shm-usage'] + : ['--no-sandbox'], }, }, }, ], - webServer: { - // The fixture page mounts ScratchlistPanel in isolation; no hub - // is required, which is why this dev server doesn't proxy /api. - command: `bun run --cwd web dev -- --port ${PORT} --strictPort`, - url: `${BASE_URL}/e2e-fixtures/scratchlist-fixture.html`, - timeout: 60_000, - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe', - }, + webServer: usePeerStack + ? undefined + : { + command: `bun run --cwd web dev -- --port ${PORT} --strictPort`, + url: `${BASE_URL}/e2e-fixtures/scratchlist-fixture.html`, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + stderr: 'pipe', + }, }) diff --git a/scripts/dev/playwright-annotated-video.mjs b/scripts/dev/playwright-annotated-video.mjs new file mode 100644 index 0000000000..74ae3af3e3 --- /dev/null +++ b/scripts/dev/playwright-annotated-video.mjs @@ -0,0 +1,70 @@ +/** + * Playwright screencast helpers — click highlights + animated pointer on recorded video. + * + * Requires Playwright >= 1.59 (screencast.showActions); cursor animation needs >= 1.61. + * + * @playwright/test fixtures: + * import { annotatedVideoUseOption } from './scripts/dev/playwright-annotated-video.mjs' + * use: { video: process.env.PLAYWRIGHT_RECORD_VIDEO === '1' ? annotatedVideoUseOption('on') : 'off' } + * + * Programmatic (handoff .mjs scripts): + * import { startAnnotatedScreencast, stopAnnotatedScreencast } from './playwright-annotated-video.mjs' + * await startAnnotatedScreencast(page, { path: 'localdocs/playwright-runs/demo.webm' }) + * // ... interactions ... + * await stopAnnotatedScreencast(page) + */ + +/** Default overlays: element outline, action title, pointer glide between clicks. */ +export const ANNOTATED_SHOW_ACTIONS = { + position: 'top-right', + cursor: 'pointer', + duration: 800, + fontSize: 22, +} + +/** + * `use.video` value for @playwright/test when recording with action annotations. + * @param {import('@playwright/test').VideoMode} mode + * @param {import('@playwright/test').ViewportSize | undefined} size + */ +export function annotatedVideoUseOption(mode = 'on', size) { + const option = { + mode, + show: { + actions: { + position: ANNOTATED_SHOW_ACTIONS.position, + duration: ANNOTATED_SHOW_ACTIONS.duration, + fontSize: ANNOTATED_SHOW_ACTIONS.fontSize, + }, + }, + } + if (size) option.size = size + return option +} + +export function shouldRecordAnnotatedVideo() { + return process.env.HAPI_PEER_RECORD_VIDEO === '1' || process.env.PLAYWRIGHT_RECORD_VIDEO === '1' +} + +/** + * Start annotated screencast on a page (replaces raw `recordVideo` on browser context). + * @param {import('playwright').Page} page + * @param {{ path: string, showActions?: typeof ANNOTATED_SHOW_ACTIONS, size?: { width: number, height: number } }} options + */ +export async function startAnnotatedScreencast(page, options) { + const { path, showActions = ANNOTATED_SHOW_ACTIONS, size } = options + await page.screencast.start({ path, size }) + await page.screencast.showActions(showActions) +} + +/** Stop screencast and finalize the file written by {@link startAnnotatedScreencast}. */ +export async function stopAnnotatedScreencast(page) { + await page.screencast.stop() +} + +/** Resolve webm/mp4 paths under a handoff output directory. */ +export function annotatedVideoPaths(dir, basename) { + const webm = `${dir.replace(/\/$/, '')}/${basename}.webm` + const mp4 = `${dir.replace(/\/$/, '')}/${basename}.mp4` + return { webm, mp4 } +} diff --git a/scripts/dev/session-view-toggles-handoff.mjs b/scripts/dev/session-view-toggles-handoff.mjs new file mode 100644 index 0000000000..e9a418560e --- /dev/null +++ b/scripts/dev/session-view-toggles-handoff.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Playwright handoff for session header view toggles (files + outline). + * Usage: node scripts/dev/session-view-toggles-handoff.mjs [screenshotPath] + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const sessionId = process.argv[2] +const cliToken = process.argv[3] +const screenshotPath = resolve(process.argv[4] ?? 'localdocs/playwright-runs/session-view-toggles-handoff.png') + +if (!sessionId || !cliToken) { + console.error('usage: session-view-toggles-handoff.mjs [screenshotPath]') + process.exit(2) +} + +function launchOptions() { + const chromePath = process.env.PLAYWRIGHT_CHROME_PATH?.trim() + if (chromePath) return { headless: true, executablePath: chromePath } + if (process.platform === 'linux' && !process.env.PLAYWRIGHT_BUNDLED_CHROMIUM) { + return { headless: true, channel: 'chrome' } + } + return { headless: true } +} + +const baseUrl = 'http://127.0.0.1:3006' +const storageKey = `hapi_access_token::${baseUrl}` +const url = `${baseUrl}/sessions/${sessionId}` +const browser = await chromium.launch(launchOptions()) +const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + serviceWorkers: 'block', +}) +const page = await context.newPage() +await page.addInitScript(({ key, token }) => { + localStorage.setItem(key, token) +}, { key: storageKey, token: cliToken }) +const consoleMessages = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('pageerror', (err) => consoleMessages.push(`pageerror: ${err.message}`)) + +try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + + const login = page.getByPlaceholder('Access token') + if (await login.isVisible({ timeout: 3000 }).catch(() => false)) { + await login.fill(cliToken) + await page.getByRole('button', { name: /sign in|login|connect/i }).click() + await page.waitForLoadState('domcontentloaded', { timeout: 60000 }) + } + + await page.getByRole('button', { name: 'Files' }).first().waitFor({ state: 'visible', timeout: 60000 }) + + // Toggle into files mode — button should become pressed. + await page.getByRole('button', { name: 'Files' }).first().click() + await page.getByPlaceholder('Search files').waitFor({ timeout: 30000 }) + await page.getByRole('button', { name: 'Refresh filesystem view' }).waitFor({ timeout: 10000 }) + + const filesBtn = page.getByRole('button', { name: 'Return to conversation' }) + await filesBtn.waitFor({ timeout: 5000 }) + const pressed = await filesBtn.getAttribute('aria-pressed') + if (pressed !== 'true') { + throw new Error(`Expected files toggle aria-pressed=true, got ${pressed}`) + } + + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }) + + console.log(JSON.stringify({ + ok: true, + screenshot: screenshotPath, + url: page.url().replace(/token=[^&]+/, 'token='), + filesTogglePressed: pressed, + }, null, 2)) +} catch (error) { + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {}) + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + screenshot: screenshotPath, + bodyText: (await page.locator('body').innerText().catch(() => '')).slice(0, 500), + consoleMessages, + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +} diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs new file mode 100644 index 0000000000..588490a609 --- /dev/null +++ b/scripts/tooling/hapi-display-image.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env bun +/** + * Post a local image or video inline to a HAPI session via display_image / display_video MCP. + * + * Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP + * endpoint, not the session hook server on another loopback port in the same process. + * + * Usage: + * bun scripts/tooling/hapi-display-image.mjs [title] + * + * Picks display_video for mp4/webm (ftyp / webm magic), else display_image. + */ + +import { readFileSync, lstatSync } from 'node:fs' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' +const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` + +const sessionArg = process.argv[2] +const imagePath = process.argv[3] +const title = process.argv[4] + +if (!sessionArg || !imagePath) { + console.error('usage: hapi-display-image.mjs [title]') + process.exit(2) +} + +function detectMediaTool(path) { + const head = readFileSync(path).subarray(0, 16) + if (head.length >= 12 && head.subarray(4, 8).toString('ascii') === 'ftyp') { + const brand = head.subarray(8, 12).toString('ascii') + return brand === 'avif' || brand === 'avis' ? 'display_image' : 'display_video' + } + if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) { + return 'display_video' + } + return 'display_image' +} + +if (!lstatSync(imagePath).isFile()) { + console.error(`not a file: ${imagePath}`) + process.exit(2) +} + +const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken +if (!token) { + console.error('missing CLI_API_TOKEN env and no cliApiToken in settings') + process.exit(2) +} +const authRes = await fetch(`${HAPI_HOST}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: token }), +}) +if (!authRes.ok) { + console.error('auth failed', authRes.status) + process.exit(3) +} +const { token: jwt } = await authRes.json() + +const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { + headers: { Authorization: `Bearer ${jwt}` }, +}) +const sessionsBody = await sessionsRes.json() +const sessions = sessionsBody.sessions ?? sessionsBody +const session = sessions.find((s) => s.id.startsWith(sessionArg)) +if (!session) { + console.error(`no session for prefix ${sessionArg}`) + process.exit(4) +} + +// List endpoint omits metadata; per-session GET includes hapiMcpUrl (#956 / PR #958). +let mcpUrl = session.metadata?.hapiMcpUrl +if (!mcpUrl) { + const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(session.id)}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + if (!detailRes.ok) { + console.error('session detail fetch failed', detailRes.status) + process.exit(5) + } + const detailBody = await detailRes.json() + const detail = detailBody.session ?? detailBody + mcpUrl = detail.metadata?.hapiMcpUrl +} +if (!mcpUrl) { + console.error('session has no hapiMcpUrl metadata (happy MCP not running in that session CLI — check GET /api/sessions/:id)') + process.exit(5) +} + +console.error(`hapi-display-image: session=${session.id} mcp=${mcpUrl}`) + +const mediaTool = detectMediaTool(imagePath) +const client = new Client({ name: 'hapi-display-image', version: '1.0.0' }, { capabilities: {} }) +const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) +await client.connect(transport) +const result = await client.callTool({ + name: mediaTool, + arguments: { path: imagePath, title: title ?? undefined }, +}) +await client.close() +console.log(JSON.stringify(result, null, 2)) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 7262c618d6..e6381da723 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -124,7 +124,13 @@ export const SessionCollaborationModeRequestSchema = z.object({ export type SessionCollaborationModeRequest = z.infer export const SessionModelRequestSchema = z.object({ - model: z.string().trim().min(1).nullable() + model: z.union([ + z.string().trim().min(1), + z.object({ + provider: z.string().trim().min(1), + modelId: z.string().trim().min(1), + }), + ]).nullable() }) export type SessionModelRequest = z.infer @@ -386,6 +392,41 @@ export type CursorModelsResponse = OpencodeModelsResponse export type ListCursorModelsResponse = CursorModelsResponse +/** Maps thinking levels to provider-specific values. null = unsupported. */ +export type PiThinkingLevelMap = Partial> + +export type PiModelSummary = { + provider: string + modelId: string + name?: string + contextWindow?: number + /** Whether the model supports reasoning/thinking */ + reasoning?: boolean + /** Maps Pi thinking levels to provider values; null = unsupported level */ + thinkingLevelMap?: PiThinkingLevelMap +} + +export type PiModelsResponse = { + success: boolean + availableModels?: PiModelSummary[] + currentModelId?: string | null + error?: string +} + +export type ListPiModelsResponse = PiModelsResponse + +export type PiCommandSummary = { + name: string + description?: string + source: 'extension' | 'prompt' | 'skill' +} + +export type PiCommandsResponse = { + success: boolean + commands?: PiCommandSummary[] + error?: string +} + export type SlashCommand = { name: string description?: string diff --git a/shared/src/cursorCliSku.test.ts b/shared/src/cursorCliSku.test.ts index 5f5cb7ca6c..270de3200d 100644 --- a/shared/src/cursorCliSku.test.ts +++ b/shared/src/cursorCliSku.test.ts @@ -34,8 +34,14 @@ describe('matchCliSkuToAcpWireId', () => { expect(matchCliSkuToAcpWireId('gpt-5.5-medium', available)).toBe('gpt-5.5[context=272k,reasoning=medium,fast=false]'); }); - it('picks the best wire when multiple ACP variants exist', () => { - expect(matchCliSkuToAcpWireId('composer-2.5', available)).toBe('composer-2.5[fast=true]'); + it('maps base-only SKU to fast=false when fast variants exist (cursor CLI convention)', () => { + expect(matchCliSkuToAcpWireId('composer-2.5', available)).toBe('composer-2.5[fast=false]'); + }); + + it('still maps base-only SKU when only one variant exists', () => { + expect(matchCliSkuToAcpWireId('composer-2.5', [{ modelId: 'composer-2.5[fast=true]' }])).toBe( + 'composer-2.5[fast=true]' + ); }); }); @@ -49,6 +55,55 @@ describe('findBestCliSkuForAcpWire', () => { ]); expect(best).toBe('gpt-5.5-medium'); }); + + it('prefers base-only sku for fast=false wire over -fast sku', () => { + const wire = 'composer-2.5[fast=false]'; + const best = findBestCliSkuForAcpWire(wire, ['composer-2.5', 'composer-2.5-fast']); + expect(best).toBe('composer-2.5'); + }); + + it('prefers -fast sku for fast=true wire over base-only sku', () => { + const wire = 'composer-2.5[fast=true]'; + const best = findBestCliSkuForAcpWire(wire, ['composer-2.5', 'composer-2.5-fast']); + expect(best).toBe('composer-2.5-fast'); + }); +}); + +describe('round-trip (regression for #883: "selected but no response")', () => { + const acpWires = [ + { modelId: 'composer-2.5[fast=true]' }, + { modelId: 'composer-2.5[fast=false]' } + ]; + const pickerSkus = ['composer-2.5', 'composer-2.5-fast']; + + function simulateRoundTrip(clickedSku: string): { sessionModel: string; radioOn: string | null } { + // CLI side: applyCursorAcpModel → resolveCursorAcpWireId → matchCliSkuToAcpWireId + const sessionModel = matchCliSkuToAcpWireId(clickedSku, acpWires); + if (!sessionModel) { + throw new Error('CLI rejected sku'); + } + // Web side after refetch: cursorVariantSelectValue uses findBestCliSkuForAcpWire + const radioOn = findBestCliSkuForAcpWire(sessionModel, pickerSkus); + return { sessionModel, radioOn }; + } + + it('clicking composer-2.5 (slow) lands on the slow radio, not the fast one', () => { + const result = simulateRoundTrip('composer-2.5'); + expect(result.sessionModel).toBe('composer-2.5[fast=false]'); + expect(result.radioOn).toBe('composer-2.5'); + }); + + it('clicking composer-2.5-fast lands on the fast radio', () => { + const result = simulateRoundTrip('composer-2.5-fast'); + expect(result.sessionModel).toBe('composer-2.5[fast=true]'); + expect(result.radioOn).toBe('composer-2.5-fast'); + }); + + it('clicking each picker option lands on a distinct session model (no collapse)', () => { + const slow = simulateRoundTrip('composer-2.5').sessionModel; + const fast = simulateRoundTrip('composer-2.5-fast').sessionModel; + expect(slow).not.toBe(fast); + }); }); describe('isCursorAcpWireModelId', () => { diff --git a/shared/src/cursorCliSku.ts b/shared/src/cursorCliSku.ts index f72d3eda6b..9d61f5c253 100644 --- a/shared/src/cursorCliSku.ts +++ b/shared/src/cursorCliSku.ts @@ -91,9 +91,10 @@ function inferSkuParamHints(slug: string): Record { hints.reasoning = 'none'; } - if (lower.endsWith('-fast') || lower.includes('-fast')) { - hints.fast = 'true'; - } + // Cursor CLI convention: `-fast` suffix means fast=true; absence means fast=false. + // Without an explicit hint, base-only SKUs (e.g. `composer-2.5`) would tie-break to the + // first wire and silently coerce to the fast variant. + hints.fast = lower.includes('-fast') ? 'true' : 'false'; if (lower.includes('thinking')) { hints.thinking = 'true'; diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a92595f195..0d74595000 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -37,6 +37,16 @@ describe('hasCapability', () => { expect(hasCapability('opencode', Capabilities.Effort)).toBe(false) }) + test('pi supports model-change and effort', () => { + expect(hasCapability('pi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('pi', Capabilities.Effort)).toBe(true) + }) + + test('kimi supports model-change but not effort', () => { + expect(hasCapability('kimi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) + }) + test('unknown flavor returns false', () => { expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false) }) @@ -54,6 +64,8 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('codex')).toBe('Codex') expect(getFlavorLabel('cursor')).toBe('Cursor') expect(getFlavorLabel('opencode')).toBe('OpenCode') + expect(getFlavorLabel('pi')).toBe('Pi') + expect(getFlavorLabel('kimi')).toBe('Kimi') }) test('unknown flavor returns Unknown', () => { @@ -73,6 +85,8 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('codex')).toBe(true) expect(isKnownFlavor('cursor')).toBe(true) expect(isKnownFlavor('opencode')).toBe(true) + expect(isKnownFlavor('pi')).toBe(true) + expect(isKnownFlavor('kimi')).toBe(true) }) test('returns false for unknown/null/undefined', () => { @@ -89,6 +103,8 @@ describe('convenience functions', () => { expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('opencode')).toBe(true) expect(supportsModelChange('cursor')).toBe(true) + expect(supportsModelChange('pi')).toBe(true) + expect(supportsModelChange('kimi')).toBe(true) expect(supportsModelChange(null)).toBe(false) }) @@ -96,6 +112,8 @@ describe('convenience functions', () => { expect(supportsEffort('claude')).toBe(true) expect(supportsEffort('codex')).toBe(false) expect(supportsEffort('gemini')).toBe(false) + expect(supportsEffort('pi')).toBe(true) + expect(supportsEffort('kimi')).toBe(false) expect(supportsEffort(null)).toBe(false) }) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index a4832e93cc..15c59df385 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -16,6 +16,7 @@ const FLAVOR_CAPS: Record> = { codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), + pi: new Set([Capabilities.ModelChange, Capabilities.Effort]), } // --- Flavor display names --- @@ -26,6 +27,7 @@ const FLAVOR_LABELS: Record = { codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', + pi: 'Pi', } // --- Query functions --- diff --git a/shared/src/index.ts b/shared/src/index.ts index b8f5e291db..0aaf85f2aa 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './rpcMethods' export * from './socket' export * from './sessionSummary' export * from './sessionExport' +export * from './piThinkingLevel' export * from './slashCommands' export * from './utils' export * from './version' diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 0b0a50c134..eb97fe10f5 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,10 +1,71 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, test } from 'bun:test' import { getPermissionModeLabel, + getPermissionModeOptionsForFlavor, getPermissionModeTone, - isPermissionModeAllowedForFlavor + getPermissionModesForFlavor, + isPermissionModeAllowedForFlavor, } from './modes' +describe('getPermissionModesForFlavor', () => { + test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { + expect(getPermissionModesForFlavor('pi')).toEqual([]) + }) + + test("returns [] for pi and does not fall back to Claude modes", () => { + // Ensure Pi is opt-in empty, not silently inheriting Claude defaults. + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor('claude')) + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor(null)) + }) + + test("unknown flavors fall back to Claude modes, not Pi's empty list", () => { + expect(getPermissionModesForFlavor(null)).not.toEqual([]) + expect(getPermissionModesForFlavor(undefined)).not.toEqual([]) + expect(getPermissionModesForFlavor('PI')).not.toEqual([]) + expect(getPermissionModesForFlavor('Pi')).not.toEqual([]) + }) +}) + +describe('getPermissionModeOptionsForFlavor', () => { + test("returns [] for pi (no permission options offered)", () => { + expect(getPermissionModeOptionsForFlavor('pi')).toEqual([]) + }) +}) + +describe('isPermissionModeAllowedForFlavor', () => { + test("no mode is allowed for pi", () => { + expect(isPermissionModeAllowedForFlavor('yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('default', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('plan', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('acceptEdits', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('bypassPermissions', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('read-only', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('safe-yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('ask', 'pi')).toBe(false) + }) +}) + +describe('getPermissionModeLabel', () => { + test("yolo label is 'Yolo'", () => { + expect(getPermissionModeLabel('yolo')).toBe('Yolo') + }) + + test("default label is 'Default'", () => { + expect(getPermissionModeLabel('default')).toBe('Default') + }) +}) + +describe('getPermissionModeTone', () => { + test("yolo tone is danger", () => { + expect(getPermissionModeTone('yolo')).toBe('danger') + }) + + test("default tone is neutral", () => { + expect(getPermissionModeTone('default')).toBe('neutral') + }) +}) + describe('claude auto permission mode', () => { it('is allowed for claude only', () => { expect(isPermissionModeAllowedForFlavor('auto', 'claude')).toBe(true) @@ -13,6 +74,7 @@ describe('claude auto permission mode', () => { expect(isPermissionModeAllowedForFlavor('auto', 'cursor')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'opencode')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'kimi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) }) it('has a label and tone', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 06007c5f2e..a8d1c6659c 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -119,6 +119,11 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'cursor') { return CURSOR_PERMISSION_MODES } + if (flavor === 'pi') { + // Pi RPC mode has no runtime permission switching (always auto-approve); + // no permission modes are offered. + return [] + } return CLAUDE_PERMISSION_MODES } diff --git a/shared/src/piThinkingLevel.ts b/shared/src/piThinkingLevel.ts new file mode 100644 index 0000000000..6f70b40cb5 --- /dev/null +++ b/shared/src/piThinkingLevel.ts @@ -0,0 +1,13 @@ +// Pi thinking levels (from Pi's rpc-types.ts ThinkingLevel) +// Controls how much reasoning/thinking the model performs. +export const PI_THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const +export type PiThinkingLevel = typeof PI_THINKING_LEVELS[number] + +export const PI_THINKING_LEVEL_LABELS: Record = { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh', +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 0ac67faccb..c4284451ca 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -27,6 +27,7 @@ export const RPC_METHODS = { ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', ListCursorModels: 'listCursorModels', + ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1c7f271a2..1b891fbef5 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -47,6 +47,7 @@ export const MetadataSchema = z.object({ // tiann/hapi#873. cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(), kimiSessionId: z.string().optional(), + piSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), @@ -55,6 +56,7 @@ export const MetadataSchema = z.object({ happyToolsDir: z.string().optional(), startedFromRunner: z.boolean().optional(), hostPid: z.number().optional(), + hapiMcpUrl: z.string().url().optional(), startedBy: z.enum(['runner', 'terminal']).optional(), lifecycleState: z.string().optional(), lifecycleStateSince: z.number().optional(), @@ -63,7 +65,15 @@ export const MetadataSchema = z.object({ preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), - worktree: WorktreeMetadataSchema.optional() + worktree: WorktreeMetadataSchema.optional(), + // Cached Pi model list — written by CLI, read by web (inactive session fallback). + // Minimal shape: each entry must have modelId; other fields (provider, name, etc.) pass through. + piAvailableModels: z.array(z.object({ modelId: z.string() }).passthrough()).optional(), + // Pi-selected model with provider identity. The legacy `session.model` + // field stores only modelId (shared across all flavors); this preserves + // the provider so web can resolve the exact model when two providers + // share a modelId. + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() }) export type Metadata = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 8698896f34..c490c1bc75 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test' import type { Session } from './schemas' -import { getPendingRequestKinds, toSessionSummary } from './sessionSummary' +import { + PENDING_REQUEST_SUMMARY_CAP, + getPendingRequestKinds, + getPendingRequests, + toSessionSummary +} from './sessionSummary' function makeSession(overrides: Partial = {}): Session { return { @@ -87,4 +92,101 @@ describe('toSessionSummary', () => { expect(summary.metadata?.lifecycleState).toBe('archived') }) + + it('includes structured pendingRequests for hover-tooltip copy', () => { + const summary = toSessionSummary(makeSession({ + updatedAt: 5000, + agentState: { + requests: { + req1: { tool: 'Bash', arguments: {}, createdAt: 100 }, + req2: { tool: 'AskUserQuestion', arguments: {}, createdAt: 50 }, + req3: { tool: 'Edit', arguments: {} } + } + } + })) + + expect(summary.pendingRequestsCount).toBe(3) + expect(summary.pendingRequestKinds).toEqual(['permission', 'input']) + expect(summary.pendingRequests).toHaveLength(3) + expect(summary.pendingRequests[0]).toEqual({ + id: 'req2', + kind: 'input', + tool: 'AskUserQuestion', + since: 50 + }) + expect(summary.pendingRequests[1]).toEqual({ + id: 'req1', + kind: 'permission', + tool: 'Bash', + since: 100 + }) + expect(summary.pendingRequests[2]).toEqual({ + id: 'req3', + kind: 'permission', + tool: 'Edit', + since: 5000 + }) + }) + + it('returns empty pendingRequests when agentState has no requests', () => { + const summary = toSessionSummary(makeSession({ agentState: null })) + expect(summary.pendingRequests).toEqual([]) + }) +}) + +describe('getPendingRequests', () => { + it('caps the array length while leaving pendingRequestsCount untouched', () => { + const requests: Record = {} + for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP + 3; i += 1) { + requests[`req-${i.toString().padStart(2, '0')}`] = { + tool: 'Bash', + arguments: {}, + createdAt: i + } + } + const session = makeSession({ agentState: { requests } }) + + const slice = getPendingRequests(session) + expect(slice).toHaveLength(PENDING_REQUEST_SUMMARY_CAP) + // Oldest-first → the first `cap` items by createdAt should win. + expect(slice.map(r => r.id)).toEqual( + Array.from({ length: PENDING_REQUEST_SUMMARY_CAP }, (_, i) => `req-${i.toString().padStart(2, '0')}`) + ) + + const summary = toSessionSummary(session) + expect(summary.pendingRequestsCount).toBe(PENDING_REQUEST_SUMMARY_CAP + 3) + expect(summary.pendingRequests).toHaveLength(PENDING_REQUEST_SUMMARY_CAP) + }) + + it('breaks ties on createdAt by id (stable across hub restarts)', () => { + const session = makeSession({ + agentState: { + requests: { + 'req-b': { tool: 'Bash', arguments: {}, createdAt: 100 }, + 'req-a': { tool: 'Edit', arguments: {}, createdAt: 100 } + } + } + }) + const slice = getPendingRequests(session) + expect(slice.map(r => r.id)).toEqual(['req-a', 'req-b']) + }) +}) + +describe('getPendingRequestKinds', () => { + it('reads from the FULL request set (not the capped pendingRequests slice)', () => { + const requests: Record = {} + // First CAP requests are all permission, last one is input — must still + // surface 'input' even though it would fall outside the capped slice. + for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP; i += 1) { + requests[`perm-${i}`] = { tool: 'Bash', arguments: {}, createdAt: i } + } + requests['ask'] = { + tool: 'AskUserQuestion', + arguments: {}, + createdAt: PENDING_REQUEST_SUMMARY_CAP + 100 + } + + const kinds = getPendingRequestKinds(makeSession({ agentState: { requests } })) + expect(kinds).toEqual(['permission', 'input']) + }) }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 16421c4bed..32a3a48c23 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -10,6 +10,25 @@ const INPUT_REQUEST_TOOLS = new Set([ 'request_user_input' ]) +/** Cap on `pendingRequests` carried in `SessionSummary`. The list is meant for + * per-row hover copy ("Approve `Bash`, `Edit` (+1 more)"); deep inspection + * should use `Session.agentState.requests`. The `pendingRequestsCount` field + * is the authoritative total — `pendingRequests.length` may be smaller. */ +export const PENDING_REQUEST_SUMMARY_CAP = 5 + +export type PendingRequest = { + id: string + kind: PendingRequestKind + tool: string + /** Epoch ms when the request was raised; falls back to `session.updatedAt` + * for older requests that were stored without `createdAt`. */ + since: number +} + +function classifyKind(tool: string): PendingRequestKind { + return INPUT_REQUEST_TOOLS.has(tool) ? 'input' : 'permission' +} + export type SessionSummaryMetadata = { name?: string path: string @@ -31,12 +50,45 @@ export type SessionSummary = { todoProgress: { completed: number; total: number } | null pendingRequestsCount: number pendingRequestKinds: PendingRequestKind[] + /** Capped, oldest-first slice of pending tool requests. Use this for tooltip + * / per-row UX. The full count (which may exceed the cap) is in + * `pendingRequestsCount`. */ + pendingRequests: PendingRequest[] backgroundTaskCount: number futureScheduledMessageCount: number + /** Epoch ms of the soonest uninvoked future scheduled message, or null. */ + nextScheduledAt: number | null model: string | null effort: string | null } +export function getPendingRequests( + session: Session, + cap: number = PENDING_REQUEST_SUMMARY_CAP +): PendingRequest[] { + const requests = session.agentState?.requests + if (!requests) { + return [] + } + + const items: PendingRequest[] = [] + for (const [id, request] of Object.entries(requests)) { + items.push({ + id, + kind: classifyKind(request.tool), + tool: request.tool, + since: typeof request.createdAt === 'number' ? request.createdAt : session.updatedAt + }) + } + + items.sort((a, b) => { + if (a.since !== b.since) return a.since - b.since + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + + return cap >= items.length ? items : items.slice(0, cap) +} + export function getPendingRequestKinds(session: Session): PendingRequestKind[] { const requests = session.agentState?.requests if (!requests) { @@ -45,7 +97,7 @@ export function getPendingRequestKinds(session: Session): PendingRequestKind[] { const kinds = new Set() for (const request of Object.values(requests)) { - kinds.add(INPUT_REQUEST_TOOLS.has(request.tool) ? 'input' : 'permission') + kinds.add(classifyKind(request.tool)) } return kinds.has('permission') && kinds.has('input') @@ -88,8 +140,10 @@ export function toSessionSummary(session: Session): SessionSummary { todoProgress, pendingRequestsCount, pendingRequestKinds: getPendingRequestKinds(session), + pendingRequests: getPendingRequests(session), backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, + nextScheduledAt: null, model: session.model, effort: session.effort } diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 9d6e2e087e..e050b0df98 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -213,6 +213,8 @@ export interface ClientToServerEvents { serviceTier?: string | null collaborationMode?: CodexCollaborationMode }) => void + /** CLI agent finished session/load (or equivalent) and can accept prompts. */ + 'session-ready': (data: { sid: string; time: number }) => void 'session-end': (data: { sid: string; time: number; reason?: SessionEndReason }) => void 'messages-consumed': (data: { sid: string; localIds: string[] }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void diff --git a/shared/src/types.ts b/shared/src/types.ts index b10060a40e..9c38fda221 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -24,7 +24,8 @@ export type { WorktreeMetadata } from './schemas' -export type { SessionSummary, SessionSummaryMetadata, PendingRequestKind } from './sessionSummary' +export type { SessionSummary, SessionSummaryMetadata, PendingRequest, PendingRequestKind } from './sessionSummary' +export { PENDING_REQUEST_SUMMARY_CAP } from './sessionSummary' export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' export type { diff --git a/web/src/App.tsx b/web/src/App.tsx index fc23635acf..b8a8f0101d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' import { initializeChatSurfaceColors } from '@/hooks/useChatSurfaceColors' import { initializeTheme } from '@/hooks/useTheme' +import { initializeThemeColors } from '@/hooks/useThemeColors' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' @@ -23,11 +24,13 @@ import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' +import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner' import { SyncingBanner } from '@/components/SyncingBanner' import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' +import { PwaUpdateProvider } from '@/lib/pwa-update-context' import { ToastProvider, useToast } from '@/lib/toast-context' import type { SyncEvent } from '@/types/api' @@ -35,10 +38,21 @@ type ToastEvent = Extract const REQUIRE_SERVER_URL = requireHubUrlForLogin() +function withPwaBanner(content: ReactNode) { + return ( + <> + + {content} + + ) +} + export function App() { return ( - + + + ) } @@ -59,6 +73,7 @@ function AppInner() { tg?.ready() tg?.expand() initializeTheme() + initializeThemeColors() initializeChatSurfaceColors() }, []) @@ -205,9 +220,12 @@ function AppInner() { } const invalidations = [ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - ...(selectedSessionId ? [ - queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) - ] : []) + // Invalidate ALL cached session-detail entries on reconnect, not just + // the selected one. With `SESSION_DETAIL_STALE_TIME_MS` extending the + // freshness window on `useSession`, a previously-viewed session that + // received updates during the SSE gap would otherwise serve stale + // cached data on remount. See tiann/hapi#884. + queryClient.invalidateQueries({ queryKey: ['session'] }) ] const refreshMessages = (selectedSessionId && api) ? fetchLatestMessages(api, selectedSessionId) @@ -338,16 +356,16 @@ function AppInner() { // Loading auth source if (isAuthSourceLoading) { - return ( + return withPwaBanner(
-
+ , ) } // No auth source (browser environment, not logged in) if (!authSource) { - return ( + return withPwaBanner( + />, ) } if (needsBinding) { - return ( + return withPwaBanner( + />, ) } // Authenticating (also covers the gap before useAuth effect starts) if (isAuthLoading || (authSource && !token && !authError)) { - return ( + return withPwaBanner(
-
+ , ) } @@ -387,7 +405,7 @@ function AppInner() { if (authError || !token || !api) { // If using access token and auth failed, show login again if (authSource.type === 'accessToken') { - return ( + return withPwaBanner( + />, ) } // Telegram auth failed - return ( + return withPwaBanner(
{t('login.title')}
@@ -410,13 +428,17 @@ function AppInner() {
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
-
+
, ) } return ( + { + async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) @@ -634,6 +637,14 @@ export class ApiClient { ) } + /** Generic Pi session endpoint — replaces per-method wrappers. */ + async callPiEndpoint(sessionId: string, path: string, init?: RequestInit): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/pi-${path}`, + init + ) + } + async getMachineCursorModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/cursor-models` diff --git a/web/src/chat/inlineMediaSource.ts b/web/src/chat/inlineMediaSource.ts new file mode 100644 index 0000000000..7b0191f8f8 --- /dev/null +++ b/web/src/chat/inlineMediaSource.ts @@ -0,0 +1,28 @@ +/** v1 inline media provenance (wire + chat blocks). See cli/src/modules/common/inlineMediaSource.ts */ +export type InlineMediaIngress = 'mcp' | 'acp' | 'tool_result' + +export type InlineMediaSource = { + ingress: InlineMediaIngress + flavor?: string + toolCallId?: string + toolName?: string +} + +export function inlineMediaSourceFromWire(value: unknown): InlineMediaSource | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const record = value as Record + const ingress = record.ingress ?? record.path + if (ingress !== 'mcp' && ingress !== 'acp' && ingress !== 'tool_result') return undefined + const flavor = typeof record.flavor === 'string' ? record.flavor : undefined + const toolCallId = typeof record.toolCallId === 'string' + ? record.toolCallId + : typeof record.tool_call_id === 'string' + ? record.tool_call_id + : undefined + const toolName = typeof record.toolName === 'string' + ? record.toolName + : typeof record.tool_name === 'string' + ? record.tool_name + : undefined + return { ingress, flavor, toolCallId, toolName } +} diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index a079e030fd..65466a54fe 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -16,6 +16,11 @@ const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 // Fallback for Codex sessions when the server has not reported an explicit modelContextWindow. // The value matches the context window currently reported by Codex App Server token-count events. const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 258_400 +// Pi supports multiple providers with varying context windows. 200K is a +// conservative default (most Claude/GPT-4 class models). When the server +// reports an explicit modelContextWindow via usage events, that takes +// precedence over this fallback. +const DEFAULT_PI_CONTEXT_WINDOW_TOKENS = 200_000 function parseCursorWireContextWindow(model: string): number | null { const match = model.match(/\[([^\]]+)\]/) @@ -47,6 +52,10 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? return Math.max(1, DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) } + if (flavor === 'pi') { + return Math.max(1, DEFAULT_PI_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) + } + if (flavor === 'cursor') { const trimmedModel = model?.trim() const windowTokens = trimmedModel ? parseCursorWireContextWindow(trimmedModel) : null diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3a3db02e6f..6b9b0a4e5a 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -178,6 +178,27 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes agent error payloads as error events', () => { + const normalized = normalizeDecryptedMessage(makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + } + })) + + expect(normalized).toMatchObject({ + role: 'event', + content: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + }) + }) + it('treats non-sidechain string user output as sidechain', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 39de43c655..b5b90d5b6f 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,4 +1,5 @@ import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' +import { inlineMediaSourceFromWire } from '@/chat/inlineMediaSource' import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol' import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' @@ -559,6 +560,7 @@ export function normalizeAgentRecord( const imageId = asString(data.imageId ?? data.image_id) if (!imageId) return null const uuid = asString(data.id) ?? messageId + const source = inlineMediaSourceFromWire(data.source) return { id: messageId, localId, @@ -571,12 +573,28 @@ export function normalizeAgentRecord( fileName: asString(data.fileName ?? data.file_name) ?? 'generated-image', mimeType: asString(data.mimeType ?? data.mime_type), uuid, - parentUUID: null + parentUUID: null, + source, }], meta } } + if (data.type === 'error' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'error', + message: data.message + }, + isSidechain: false, + meta + } + } + if (data.type === 'message' && typeof data.message === 'string') { const review = parseCodexReviewMessage(data.message) if (review) { diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index 6918fde4d0..af4497fa62 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from 'vitest' import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation' +describe('getEventPresentation — agent errors', () => { + it('formats error events with warning icon and message text', () => { + const result = getEventPresentation({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }) + + expect(result.icon).toBe('⚠️') + expect(result.text).toBe('Cursor Agent failed: authentication required') + }) +}) + describe('getEventPresentation — limit-warning', () => { it('formats five_hour warning', () => { const result = getEventPresentation({ diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 676c6bcc22..168693dc5a 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -182,6 +182,9 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { const suffix = typeLabel ? ` (${typeLabel})` : '' return { icon: '⏳', text: endsAt ? `Usage limit reached${suffix} until ${formatUnixTimestamp(endsAt)}` : `Usage limit reached${suffix}` } } + if (event.type === 'error') { + return { icon: '⚠️', text: typeof event.message === 'string' ? event.message : 'Error' } + } if (event.type === 'message') { return { icon: null, text: typeof event.message === 'string' ? event.message : 'Message' } } diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index e4d9a480a2..eeb5338a6e 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -143,6 +143,7 @@ function areGeneratedImageBlocksEqual(left: GeneratedImageBlock, right: Generate && left.imageId === right.imageId && left.fileName === right.fileName && left.mimeType === right.mimeType + && left.source === right.source && left.meta === right.meta } diff --git a/web/src/chat/reducerEvents.ts b/web/src/chat/reducerEvents.ts index f2302caba9..23ac761b7f 100644 --- a/web/src/chat/reducerEvents.ts +++ b/web/src/chat/reducerEvents.ts @@ -79,6 +79,18 @@ export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { continue } + if (event.type === 'error' && typeof event.message === 'string') { + const message = event.message.trim() + const key = `error:${message}` + if (key === prevEventKey) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + continue + } + let key: string try { key = `event:${JSON.stringify(event)}` diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index d13cd34a9a..d7437f780c 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -180,6 +180,15 @@ function normalizeTraceMessage( meta: source.meta } + if (data.type === 'error' && typeof data.message === 'string') { + return [{ + ...base, + id: traceId, + role: 'event', + content: { type: 'error', message: data.message } + } as TracedMessage] + } + if (data.type === 'message' && typeof data.message === 'string') { return [{ ...base, @@ -748,6 +757,7 @@ export function reduceTimeline( imageId: c.imageId, fileName: c.fileName, mimeType: c.mimeType, + source: c.source, meta: msg.meta }) continue diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 4641e91387..13f2df6836 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -1,5 +1,6 @@ import type { AttachmentMetadata, MessageStatus } from '@/types/api' import type { ThreadGoal } from '@/types/api' +import type { InlineMediaSource } from '@/chat/inlineMediaSource' export type UsageData = { input_tokens: number @@ -16,6 +17,7 @@ export type UsageData = { export type AgentEvent = | { type: 'switch'; mode: 'local' | 'remote' } | { type: 'message'; message: string } + | { type: 'error'; message: string } | { type: 'title-changed'; title: string } | { type: 'limit-reached'; endsAt: number; limitType: string } | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string } @@ -63,6 +65,7 @@ export type GeneratedImageContent = { mimeType: string | null uuid: string parentUUID: string | null + source?: InlineMediaSource } export type CodexReviewFinding = { @@ -231,6 +234,7 @@ export type GeneratedImageBlock = { imageId: string fileName: string mimeType: string | null + source?: InlineMediaSource meta?: unknown } diff --git a/web/src/components/AgentFlavorIcon.test.tsx b/web/src/components/AgentFlavorIcon.test.tsx new file mode 100644 index 0000000000..5dc776cbdf --- /dev/null +++ b/web/src/components/AgentFlavorIcon.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/react' +import { AgentFlavorIcon } from './AgentFlavorIcon' + +function getBadge(container: HTMLElement): HTMLElement { + const badge = container.querySelector('span') + if (!badge) throw new Error('AgentFlavorIcon did not render a ') + return badge +} + +describe('AgentFlavorIcon', () => { + it('renders the "Pi" label and purple background for the pi flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Pi') + // The Pi badge uses a specific purple; if the literal ever drifts, + // the test should fail and force an intentional design update. + expect(badge.className).toContain('bg-[#5b21b6]') + expect(badge.className).toContain('text-white') + }) + + it('matches the exact class contract for all known flavors (regression)', () => { + const cases: Array<{ flavor: string; label: string; bg: string }> = [ + { flavor: 'claude', label: 'Cl', bg: 'bg-[#d97706]' }, + { flavor: 'codex', label: 'Cx', bg: 'bg-[#111827]' }, + { flavor: 'cursor', label: 'Cu', bg: 'bg-[#0f766e]' }, + { flavor: 'gemini', label: 'Gm', bg: 'bg-[#2563eb]' }, + { flavor: 'kimi', label: 'Km', bg: 'bg-[#7c3aed]' }, + { flavor: 'pi', label: 'Pi', bg: 'bg-[#5b21b6]' }, + { flavor: 'opencode', label: 'Op', bg: 'bg-[#15803d]' }, + ] + for (const { flavor, label, bg } of cases) { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe(label) + expect(badge.className).toContain(bg) + } + }) + + it('renders the "Un" badge with secondary-bg colors for null flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('renders the "Un" badge for undefined flavor', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for empty string', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for unknown flavor strings', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('normalizes flavor case and whitespace', () => { + // The component lowercases + trims internally so 'PI ', 'Pi', ' pi' + // all resolve to the Pi badge. + for (const flavor of ['PI', 'Pi', ' pi ', 'PI ']) { + const { container } = render() + expect(getBadge(container).textContent).toBe('Pi') + } + }) + + it('does NOT match a flavor when only whitespace is present', () => { + // ' '.trim() === '' so the unknown branch is the only valid one. + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('applies the default size classes when no className is provided', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-4') + expect(badge.className).toContain('w-4') + }) + + it('appends the provided className alongside the badge classes', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-6') + expect(badge.className).toContain('w-6') + // The default size classes must be replaced by the custom className + // (the implementation uses `${className ?? 'h-4 w-4'}`). + expect(badge.className).not.toContain('h-4 w-4') + }) + + it('marks the badge aria-hidden for screen readers (decorative only)', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.getAttribute('aria-hidden')).toBe('true') + }) +}) diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index b88f25181a..796bea956f 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -19,6 +19,10 @@ const FLAVOR_BADGES: Record = { label: 'Km', colors: 'bg-[#7c3aed] text-white', }, + pi: { + label: 'Pi', + colors: 'bg-[#5b21b6] text-white', + }, opencode: { label: 'Op', colors: 'bg-[#15803d] text-white', diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 5b0325a6f6..0bb8484b11 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,6 +8,10 @@ import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' import { useRef, useState } from 'react' +function ChevronIcon() { + return +} + function VoiceAssistantIcon() { return ( void + piThinkingLabel?: string + piThinkingDisabled?: boolean + piThinkingOpen?: boolean + onPiThinkingToggle?: () => void // Scratchlist drawer toggle. When `onScratchlistToggle` is provided, a // notepad icon appears next to the schedule-send icon. Click toggles // composer-send-routing between chat and scratchlist; SessionChat owns @@ -498,6 +511,42 @@ export function ComposerButtons(props: { ) : null} + {props.piModelLabel ? ( + + ) : null} + + {props.piThinkingLabel ? ( + + ) : null} + {props.showTerminalButton ? (
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).toHaveBeenCalledTimes(1) + expect(addAttachment).toHaveBeenCalledWith(file) + }) + + it('ignores non-file drops so the browser keeps its default (e.g. text into composer)', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const event = createDropEvent(['text/plain'], []) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(false) + expect(addAttachment).not.toHaveBeenCalled() + }) + + it('does not attach when disabled but still cancels the file default', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/AssistantChat/DragDropZone.tsx b/web/src/components/AssistantChat/DragDropZone.tsx new file mode 100644 index 0000000000..102ac5d19f --- /dev/null +++ b/web/src/components/AssistantChat/DragDropZone.tsx @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { useAssistantApi } from '@assistant-ui/react' +import { useDragOver } from '@/hooks/useDragOver' +import { useTranslation } from '@/lib/use-translation' + +export function DragDropZone({ + children, + disabled, +}: { + children: React.ReactNode + disabled?: boolean +}) { + const api = useAssistantApi() + const isDragging = useDragOver() + const { t } = useTranslation() + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault() + e.dataTransfer.dropEffect = disabled ? 'none' : 'copy' + } + }, [disabled]) + + const onDrop = useCallback( + async (e: React.DragEvent) => { + // Let non-file drops (e.g. selected text into the composer) keep + // their default browser behaviour instead of being cancelled. + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + if (disabled) return + const files = Array.from(e.dataTransfer.files) + if (files.length === 0) return + try { + for (const file of files) { + await api.composer().addAttachment(file) + } + } catch (error) { + console.error('Error adding dragged file:', error) + } + }, + [api, disabled] + ) + + return ( +
+ {children} + {isDragging && !disabled && ( +
+
+ {t('composer.dropToAttach')} +
+
+ )} +
+ ) +} diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index f5da947360..10287ae6e7 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -12,7 +12,7 @@ import { useRef, useState } from 'react' -import type { AgentState, CodexCollaborationMode, PermissionMode, ThreadGoal } from '@/types/api' +import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary, ThreadGoal } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -20,7 +20,8 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { usePlatform } from '@/hooks/usePlatform' import { usePWAInstall } from '@/hooks/usePWAInstall' -import { supportsEffort, supportsModelChange } from '@hapi/protocol' +import { supportsEffort, supportsModelChange, PI_THINKING_LEVEL_LABELS } from '@hapi/protocol' +import type { PiThinkingLevel } from '@hapi/protocol' import { markSkillUsed } from '@/lib/recent-skills' import { useComposerDraft } from '@/hooks/useComposerDraft' import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' @@ -34,6 +35,10 @@ import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' +import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupported } from './piThinkingLevelOptions' +import { groupModelsByProvider } from './piModelGroups' +import { PiModelPanel } from './PiModelPanel' +import { PiThinkingLevelPanel } from './PiThinkingLevelPanel' export interface TextInputState { text: string @@ -51,6 +56,10 @@ export interface TextInputState { * or null for an immediate send. When non-null, the composer also * restores the schedule via `onSchedule` so the operator can edit and * retry without silently downgrading a scheduled send to immediate. + * - `action` is an optional recovery affordance rendered as a button next + * to the message. Used by the inactive-session branch (#918) to expose + * a one-click Reopen. Other failure modes (5xx, network, generic 4xx) + * leave this null and only render the message. * * Owned by the route component (`router.tsx`); the composer is a pure * consumer that: @@ -63,6 +72,11 @@ export type ComposerSendError = { text: string message: string scheduledAt: number | null + action?: { + label: string + onClick: () => void + pending?: boolean + } | null } const defaultSuggestionHandler = async (): Promise => [] @@ -87,6 +101,11 @@ export function HappyComposer(props: { controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> + /** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */ + piModels?: PiModelSummary[] + /** Pi: provider-qualified selected model from metadata (survives reload; + * disambiguates when two providers share a modelId). */ + piSelectedModel?: { provider: string; modelId: string } | null availableModelReasoningEffortOptions?: Array<{ value: string; name?: string }> /** Cursor: selected base model key (not wire id). */ selectedModelBase?: string | null @@ -96,7 +115,7 @@ export function HappyComposer(props: { modelEffortOptions?: Array<{ value: string; label: string }> onCollaborationModeChange?: (mode: CodexCollaborationMode) => void onPermissionModeChange?: (mode: PermissionMode) => void - onModelChange?: (model: string | null) => void + onModelChange?: (model: { provider: string; modelId: string } | string | null) => void /** Cursor: effort/variant wire id (separate from base model change). */ onModelEffortChange?: (wireId: string | null) => void onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void @@ -152,6 +171,8 @@ export function HappyComposer(props: { controlledByUser = false, agentFlavor, availableModelOptions, + piModels, + piSelectedModel, availableModelReasoningEffortOptions, selectedModelBase, selectedModelVariant, @@ -216,6 +237,8 @@ export function HappyComposer(props: { selection: { start: 0, end: 0 } }) const [showSettings, setShowSettings] = useState(false) + const [showPiModelPanel, setShowPiModelPanel] = useState(false) + const [showPiThinkingPanel, setShowPiThinkingPanel] = useState(false) const [isAborting, setIsAborting] = useState(false) const [isSwitching, setIsSwitching] = useState(false) const [showContinueHint, setShowContinueHint] = useState(false) @@ -400,9 +423,40 @@ export function HappyComposer(props: { : [], [agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions] ) + // Pi: group models by provider for hierarchical display + const piModelGroups = useMemo( + () => piModels && piModels.length > 0 ? groupModelsByProvider(piModels) : null, + [piModels] + ) + // Pi: find the currently selected model's thinkingLevelMap for effort filtering. + // Prefer provider-qualified match (metadata.piSelectedModel) when available — + // two providers may share a modelId, and a modelId-only match would pick the + // wrong one, sending the wrong provider on the next model/effort change. + const selectedPiModel = useMemo( + () => piSelectedModel + ? piModels?.find((m) => m.provider === piSelectedModel.provider && m.modelId === piSelectedModel.modelId) + : piModels?.find((m) => m.modelId === model), + [piModels, piSelectedModel, model] + ) + + // Pi: reset effort to highest supported level when model changes and current level is unsupported + useEffect(() => { + if (!effort || !selectedPiModel || !onEffortChange) return + // Non-reasoning model: clear stale effort so the hub does not forward + // a set_thinking_level the user can no longer see or change. + if (selectedPiModel.reasoning === false) { + onEffortChange(null) + return + } + if (!isThinkingLevelSupported(effort, selectedPiModel.thinkingLevelMap)) { + onEffortChange(getHighestThinkingLevel(selectedPiModel.thinkingLevelMap)) + } + }, [selectedPiModel, effort, onEffortChange]) const claudeEffortOptions = useMemo( - () => getClaudeComposerEffortOptions(effort), - [effort] + () => agentFlavor === 'pi' + ? getPiThinkingLevelOptions(effort, selectedPiModel?.thinkingLevelMap) + : getClaudeComposerEffortOptions(effort), + [agentFlavor, effort, selectedPiModel] ) const permissionModes = useMemo( () => permissionModeOptions.map((option) => option.mode), @@ -506,6 +560,11 @@ export function HappyComposer(props: { useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { + // Pi needs { provider, modelId } to disambiguate duplicate model IDs, + // but this generic cycler only emits a bare modelId (or null), which + // would lose the provider and can pick the wrong cached match or clear + // the model. Pi model changes go only through the dedicated PiModelPanel. + if (agentFlavor === 'pi') return if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && supportsModelChange(agentFlavor)) { e.preventDefault() onModelChange(getNextModelForFlavor(agentFlavor, model, availableModelOptions)) @@ -593,7 +652,7 @@ export function HappyComposer(props: { haptic('light') }, [onCollaborationModeChange, controlsDisabled, haptic]) - const handleModelChange = useCallback((nextModel: string | null) => { + const handleModelChange = useCallback((nextModel: { provider: string; modelId: string } | string | null) => { if (!onModelChange || controlsDisabled) return onModelChange(nextModel) setShowSettings(false) @@ -638,14 +697,16 @@ export function HappyComposer(props: { const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && modelOptions.length > 0) + const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && (piModels && piModels.length > 0 || modelOptions.length > 0)) const showModelEffortSettings = Boolean( (onModelEffortChange ?? onModelChange) && modelEffortOptions && modelEffortOptions.length > 0 ) const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) - const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) + // For Pi: hide effort when selected model explicitly has reasoning: false + const piEffortHidden = piModels && selectedPiModel && selectedPiModel.reasoning === false + const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor) && !piEffortHidden) const showFastModeSettings = Boolean(onServiceTierChange) const showSettingsButton = Boolean( showCollaborationSettings @@ -673,7 +734,89 @@ export function HappyComposer(props: { // the error context while the new attempt is in flight. }, [api]) + // Pi: selected model info for UI labels and thinking level filtering + const piModelLabel = agentFlavor === 'pi' + ? (selectedPiModel?.name ?? selectedPiModel?.modelId ?? 'Model') + : undefined + const piThinkingLabel = agentFlavor === 'pi' + ? (() => { + if (!selectedPiModel) return 'Thinking' + const effectiveLevel = effort && isThinkingLevelSupported(effort, selectedPiModel.thinkingLevelMap) + ? effort + : getHighestThinkingLevel(selectedPiModel.thinkingLevelMap) + return effectiveLevel + ? (PI_THINKING_LEVEL_LABELS[effectiveLevel as PiThinkingLevel] ?? effectiveLevel) + : 'Thinking' + })() + : undefined + const piHasModels = piModels && piModels.length > 0 + + const closeAllPanels = useCallback(() => { + setShowSettings(false) + setShowPiModelPanel(false) + setShowPiThinkingPanel(false) + }, []) + + const handlePiModelToggle = useCallback(() => { + if (controlsDisabled) return + setShowPiModelPanel((v) => !v) + setShowSettings(false) + setShowPiThinkingPanel(false) + haptic('light') + }, [controlsDisabled, haptic]) + + const handlePiThinkingToggle = useCallback(() => { + if (controlsDisabled) return + setShowPiThinkingPanel((v) => !v) + setShowSettings(false) + setShowPiModelPanel(false) + haptic('light') + }, [controlsDisabled, haptic]) + const overlays = useMemo(() => { + // Pi flavor: separate floating panels for model and thinking level. + // (Pi RPC mode has no runtime permission switching → no permission panel.) + if (agentFlavor === 'pi') { + const panels: React.ReactNode[] = [] + + // Model selection panel + if (showPiModelPanel && piModels && piModels.length > 0) { + const currentPiModel = selectedPiModel ?? null + panels.push( +
+ { + handleModelChange({ provider: piModel.provider, modelId: piModel.modelId }) + }} + onClose={closeAllPanels} + /> +
+ ) + } + + // Thinking level panel + if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) { + panels.push( +
+ handleEffortChange(level)} + onClose={closeAllPanels} + /> +
+ ) + } + + if (panels.length > 0) return <>{panels} + } + + // Non-Pi flavors: original unified gear menu if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { return (
@@ -765,81 +908,79 @@ export function HappyComposer(props: {
{t('misc.model')}
- {modelOptions.map((option) => { - const isSelected = selectedModelBase !== undefined - ? selectedModelBase === option.value - : model === option.value - return ( - + ))}
- - {option.label} - - - ) - })} -
- ) : null} - - {showModelSettings && showModelEffortSettings ? ( -
- ) : null} - - {showModelEffortSettings ? ( -
-
- {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} -
- {modelEffortOptions!.map((option) => ( - - ))} +
+ {isSelected && ( +
+ )} +
+ + {option.label} + + + ) + }) + )}
) : null} @@ -987,6 +1128,12 @@ export function HappyComposer(props: { return null }, [ showSettings, + showPiModelPanel, + showPiThinkingPanel, + agentFlavor, + piModels, + selectedPiModel, + closeAllPanels, showCollaborationSettings, showPermissionSettings, showModelSettings, @@ -1052,9 +1199,20 @@ export function HappyComposer(props: {
- {sendError.message} + {sendError.message} + {sendError.action ? ( + + ) : null}
) : null} @@ -1113,6 +1271,14 @@ export function HappyComposer(props: { onSchedule={setPendingSchedule} onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)} hasAttachments={hasAttachments} + piModelLabel={piModelLabel} + piModelDisabled={controlsDisabled || !piHasModels} + piModelOpen={showPiModelPanel} + onPiModelToggle={handlePiModelToggle} + piThinkingLabel={piThinkingLabel} + piThinkingDisabled={controlsDisabled || !piHasModels || !selectedPiModel || selectedPiModel.reasoning === false} + piThinkingOpen={showPiThinkingPanel} + onPiThinkingToggle={handlePiThinkingToggle} scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} diff --git a/web/src/components/AssistantChat/PiModelPanel.tsx b/web/src/components/AssistantChat/PiModelPanel.tsx new file mode 100644 index 0000000000..899b2086c5 --- /dev/null +++ b/web/src/components/AssistantChat/PiModelPanel.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from '@/lib/use-translation' +import type { PiModelSummary } from '@/types/api' +import { groupModelsByProvider } from './piModelGroups' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' + +export function PiModelPanel(props: { + models: PiModelSummary[] + currentModel: { provider: string; modelId: string } | null + controlsDisabled?: boolean + onSelect: (model: PiModelSummary) => void + onClose: () => void +}) { + const { t } = useTranslation() + const groups = groupModelsByProvider(props.models) + const disabled = props.controlsDisabled ?? false + + const isSelected = (piModel: PiModelSummary) => + props.currentModel?.provider === piModel.provider && + props.currentModel?.modelId === piModel.modelId + + return ( + +
+
+ {t('misc.model')} +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.models.map((piModel) => { + const selected = isSelected(piModel) + return ( + + ) + })} +
+ ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx new file mode 100644 index 0000000000..9402900180 --- /dev/null +++ b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx @@ -0,0 +1,76 @@ +import { PI_THINKING_LEVEL_LABELS } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' +import { isThinkingLevelSupported } from './piThinkingLevelOptions' + +const ALL_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const + +/** + * Determine which thinking levels a model supports. + * - reasoning=false → no levels + * - reasoning=true (or unknown) + thinkingLevelMap → filter by map via isThinkingLevelSupported + * - reasoning=true (or unknown) + no map → all levels except xhigh + */ +function getSupportedLevels( + reasoning?: boolean, + thinkingLevelMap?: PiThinkingLevelMap, +): string[] { + if (reasoning === false) return [] + return ALL_LEVELS.filter((level) => isThinkingLevelSupported(level, thinkingLevelMap)) +} + +export function PiThinkingLevelPanel(props: { + currentLevel: string | null + reasoning?: boolean + thinkingLevelMap?: PiThinkingLevelMap + controlsDisabled?: boolean + onSelect: (level: string | null) => void + onClose: () => void +}) { + const supportedLevels = getSupportedLevels(props.reasoning, props.thinkingLevelMap) + const disabled = props.controlsDisabled ?? false + + if (supportedLevels.length === 0) return null + + return ( + +
+
+ Thinking Level +
+ {supportedLevels.map((level) => ( + + ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index d8df623050..be98157241 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { DecryptedMessage } from '@/types/api' -import { computeCanCancel, computeEditPendingSchedule, formatScheduledTime, getQueuedMessageEditText, getQueuedMessagePreview, sortQueuedMessages } from './QueuedMessagesBar' +import { computeCanCancel, computeEditPendingSchedule, getQueuedMessageEditText, getQueuedMessagePreview, sortQueuedMessages } from './QueuedMessagesBar' +import { formatScheduledTime } from '@/lib/scheduledTime' /** * Unit tests for computeCanCancel — the race guard that prevents sending diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index dbb6c8e1ca..371e70ce8b 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -10,6 +10,7 @@ import { useCancelQueuedMessage } from '@/hooks/mutations/useCancelQueuedMessage import { useTranslation } from '@/lib/use-translation' import { useToast } from '@/lib/toast-context' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' +import { formatScheduledTime } from '@/lib/scheduledTime' function ClockIcon() { return ( @@ -147,22 +148,6 @@ export function computeCanCancel({ * Edit = client-side cancel + prefill composer with message text (Codex dialect). * Cancel = DELETE /sessions/:id/messages/:messageId with optimistic removal. */ -/** @internal Exported for unit testing. */ -export function formatScheduledTime(scheduledAt: number): string { - const date = new Date(scheduledAt) - const now = new Date() - const opts: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - } - if (date.getFullYear() !== now.getFullYear()) { - opts.year = 'numeric' - } - return date.toLocaleString(undefined, opts) -} - export function QueuedMessagesBar({ sessionId, api, diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 871b463bcb..26b62b2d33 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types' @@ -15,6 +15,7 @@ import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble' import { ImagePreview } from '@/components/ImagePreview' +import { generatedInlineMediaLabel, isInlineVideoMimeType } from '@/lib/generatedInlineMedia' function isToolCallBlock(value: unknown): value is ToolCallBlock { if (!isObject(value)) return false @@ -49,52 +50,105 @@ function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock { return true } +const MIN_INLINE_IMAGE_DIMENSION = 64 + +function computeTinyImageScale(width: number, height: number): number { + const minDim = Math.min(width, height) + if (minDim <= 0 || minDim >= MIN_INLINE_IMAGE_DIMENSION) { + return 1 + } + return Math.min(MIN_INLINE_IMAGE_DIMENSION / minDim, 16) +} + function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const ctx = useHappyChatContext() const [objectUrl, setObjectUrl] = useState(null) const [error, setError] = useState(null) + const [imageStyle, setImageStyle] = useState(undefined) + const objectUrlRef = useRef(null) + const isVideo = isInlineVideoMimeType(props.block.mimeType) + const mediaLabel = generatedInlineMediaLabel(props.block.mimeType) + + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } + } + }, []) useEffect(() => { let disposed = false - let nextObjectUrl: string | null = null + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } setObjectUrl(null) + setImageStyle(undefined) setError(null) + void ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId) .then((blob) => { if (disposed) return - nextObjectUrl = URL.createObjectURL(blob) + const nextObjectUrl = URL.createObjectURL(blob) + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + } + objectUrlRef.current = nextObjectUrl setObjectUrl(nextObjectUrl) + if (!isVideo) { + setImageStyle(undefined) + const probe = new Image() + probe.onload = () => { + if (disposed) return + const scale = computeTinyImageScale(probe.naturalWidth, probe.naturalHeight) + setImageStyle(scale === 1 ? undefined : { transform: `scale(${scale})` }) + } + probe.src = nextObjectUrl + } }) .catch((err: unknown) => { if (disposed) return - setError(err instanceof Error ? err.message : 'Failed to load generated image') + setError(err instanceof Error ? err.message : 'Failed to load inline media') }) return () => { disposed = true - if (nextObjectUrl) { - URL.revokeObjectURL(nextObjectUrl) - } } - }, [ctx.api, ctx.sessionId, props.block.imageId]) + }, [ctx.api, ctx.sessionId, props.block.imageId, isVideo]) return (
- Generated image · {props.block.fileName} + {mediaLabel} · {props.block.fileName}
{objectUrl ? ( - + isVideo ? ( +
+
+ ) : ( +
+ +
+ ) ) : error ? (
- Generated image is unavailable. {error} + {mediaLabel} is unavailable. {error}
) : (
diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index ef18983017..a7900cec3f 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -141,6 +141,19 @@ describe('getModelOptionsForFlavor', () => { { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } ]) }) + + it('returns just the auto/default option for pi flavor (no Claude fallback)', () => { + const options = getModelOptionsForFlavor('pi') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('keeps the current pi model in the options list when it is not auto', () => { + const options = getModelOptionsForFlavor('pi', 'claude-sonnet-4-5') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -197,4 +210,82 @@ describe('getNextModelForFlavor', () => { const next = getNextModelForFlavor('cursor', 'composer-2.5') expect(next).toBe('composer-2.5') }) + + it('keeps the current pi model on cycle (no Claude fallback)', () => { + // Pi has no predefined model list — Ctrl/Cmd+M must not cycle + // through Claude presets, which would push sonnet/opus ids into + // a Pi session via set-session-config. + const next = getNextModelForFlavor('pi', 'claude-sonnet-4-5') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('returns null for pi without a current model (no Claude fallback)', () => { + const next = getNextModelForFlavor('pi', null) + expect(next).toBeNull() + }) + + it('treats "auto" as null and returns null for pi (no Claude preset injection)', () => { + // normalizeCurrentModel maps 'auto' to null; a Pi session whose UI + // displays 'Auto' must not be switched to sonnet/opus by the + // cycler shortcut. + const next = getNextModelForFlavor('pi', 'auto') + expect(next).toBeNull() + }) + + it('treats "default" as null and returns null for pi', () => { + const next = getNextModelForFlavor('pi', 'default') + expect(next).toBeNull() + }) + + it('treats empty/whitespace strings as null for pi (no Claude preset injection)', () => { + expect(getNextModelForFlavor('pi', '')).toBeNull() + expect(getNextModelForFlavor('pi', ' ')).toBeNull() + }) + + it('trims surrounding whitespace from the current pi model', () => { + const next = getNextModelForFlavor('pi', ' claude-sonnet-4-5 ') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('keeps a kimi current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('kimi', 'kimi-k2-0711')).toBe('kimi-k2-0711') + expect(getNextModelForFlavor('kimi', null)).toBeNull() + }) + + it('keeps a cursor current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('cursor', 'composer-2.5')).toBe('composer-2.5') + expect(getNextModelForFlavor('cursor', null)).toBeNull() + }) + + it('keeps an opencode current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('opencode', 'ollama/legacy')).toBe('ollama/legacy') + expect(getNextModelForFlavor('opencode', null)).toBeNull() + }) +}) + +describe('getModelOptionsForFlavor — pi normalize filter', () => { + it('drops "auto" and renders just the default option for pi', () => { + // 'auto' should be normalized to null, which equals the auto entry; + // we must not produce a duplicate { value: null, label: 'auto' } row. + const options = getModelOptionsForFlavor('pi', 'auto') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops "default" and renders just the default option for pi', () => { + const options = getModelOptionsForFlavor('pi', 'default') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops empty/whitespace currentModel for pi', () => { + expect(getModelOptionsForFlavor('pi', '')).toEqual([{ value: null, label: 'Default' }]) + expect(getModelOptionsForFlavor('pi', ' ')).toEqual([{ value: null, label: 'Default' }]) + }) + + it('trims whitespace from a real current pi model', () => { + const options = getModelOptionsForFlavor('pi', ' custom-model ') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'custom-model', label: 'custom-model' } + ]) + }) }) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 11a5fea41e..9f350ecd16 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -126,6 +126,14 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + // Pi model list is provided dynamically via piModels prop in SessionChat, + // not through this function. Show just the auto/default option here to + // prevent falling through to the Claude preset cycler (which would + // surface unrelated Claude models and let set-session-config push + // `sonnet`/`opus` ids into a Pi session). + if (flavor === 'pi') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } return getClaudeModelOptions(currentModel) } @@ -167,5 +175,10 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + // Pi model list is provided dynamically via piModels prop — pressing + // Ctrl/Cmd+M must not fall through to the Claude preset cycler. + if (flavor === 'pi') { + return normalizeCurrentModel(currentModel) + } return getNextClaudeComposerModel(currentModel) } diff --git a/web/src/components/AssistantChat/piModelGroups.ts b/web/src/components/AssistantChat/piModelGroups.ts new file mode 100644 index 0000000000..df0d33c090 --- /dev/null +++ b/web/src/components/AssistantChat/piModelGroups.ts @@ -0,0 +1,35 @@ +import type { PiModelSummary } from '@/types/api' + +type ProviderGroup = { + provider: string + label: string + models: PiModelSummary[] +} + +/** Format provider name for display */ +function formatProviderLabel(provider: string): string { + if (provider === 'unknown') return 'Other' + // Capitalize first letter, keep rest as-is + return provider.charAt(0).toUpperCase() + provider.slice(1) +} + +/** Group Pi models by provider, preserving original order within each group */ +export function groupModelsByProvider(models: PiModelSummary[]): ProviderGroup[] { + const groupOrder: string[] = [] + const groups = new Map() + + for (const model of models) { + const provider = model.provider || 'unknown' + if (!groups.has(provider)) { + groupOrder.push(provider) + groups.set(provider, []) + } + groups.get(provider)!.push(model) + } + + return groupOrder.map((provider) => ({ + provider, + label: formatProviderLabel(provider), + models: groups.get(provider)!, + })) +} diff --git a/web/src/components/AssistantChat/piThinkingLevelOptions.ts b/web/src/components/AssistantChat/piThinkingLevelOptions.ts new file mode 100644 index 0000000000..8a9ad35806 --- /dev/null +++ b/web/src/components/AssistantChat/piThinkingLevelOptions.ts @@ -0,0 +1,82 @@ +import { PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS, type PiThinkingLevel } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' + +type PiThinkingLevelOption = { + value: string + label: string +} + +function normalizePiThinkingLevel(level?: string | null): string | null { + const trimmedLevel = level?.trim().toLowerCase() + if (!trimmedLevel || trimmedLevel === 'default' || trimmedLevel === 'auto') { + return null + } + + return trimmedLevel +} + +function formatPiThinkingLevelLabel(level: string): string { + return PI_THINKING_LEVEL_LABELS[level as PiThinkingLevel] + ?? `${level.charAt(0).toUpperCase()}${level.slice(1)}` +} + +/** + * Get thinking level options filtered by the model's thinkingLevelMap. + * Levels mapped to `null` in the map are unsupported and excluded. + * Levels not present in the map are included (treated as supported with default mapping). + */ +export function getPiThinkingLevelOptions( + currentLevel?: string | null, + thinkingLevelMap?: PiThinkingLevelMap +): PiThinkingLevelOption[] { + const normalizedCurrentLevel = normalizePiThinkingLevel(currentLevel) + const options: PiThinkingLevelOption[] = [] + + // Include current level if it's non-standard (custom) + if ( + normalizedCurrentLevel + && !(PI_THINKING_LEVELS as readonly string[]).includes(normalizedCurrentLevel) + && !isLevelExcluded(normalizedCurrentLevel, thinkingLevelMap) + ) { + options.push({ + value: normalizedCurrentLevel, + label: formatPiThinkingLevelLabel(normalizedCurrentLevel) + }) + } + + options.push(...PI_THINKING_LEVELS + .filter((level) => !isLevelExcluded(level, thinkingLevelMap)) + .map((level) => ({ + value: level, + label: PI_THINKING_LEVEL_LABELS[level] + })) + ) + + return options +} + +/** Check whether a thinking level is supported by the model's thinkingLevelMap */ +export function isThinkingLevelSupported(level: string, map?: PiThinkingLevelMap): boolean { + // xhigh requires explicit opt-in via the map + if (level === 'xhigh') { + if (!map || !(level in map)) return false + return map[level] !== null + } + if (!map || !(level in map)) return true + return map[level] !== null +} + +/** A level is excluded if it maps to `null` in the thinkingLevelMap, or xhigh without explicit opt-in */ +function isLevelExcluded(level: string, map?: PiThinkingLevelMap): boolean { + return !isThinkingLevelSupported(level, map) +} + +/** Return the highest supported thinking level, or null if none */ +export function getHighestThinkingLevel(map?: PiThinkingLevelMap): string | null { + for (let i = PI_THINKING_LEVELS.length - 1; i >= 0; i--) { + if (isThinkingLevelSupported(PI_THINKING_LEVELS[i]!, map)) { + return PI_THINKING_LEVELS[i]! + } + } + return null +} diff --git a/web/src/components/CodexSessionSyncDialog.test.tsx b/web/src/components/CodexSessionSyncDialog.test.tsx index a90e167be9..81f524f26a 100644 --- a/web/src/components/CodexSessionSyncDialog.test.tsx +++ b/web/src/components/CodexSessionSyncDialog.test.tsx @@ -5,9 +5,10 @@ import { CodexSessionSyncDialog } from './CodexSessionSyncDialog' import type { CodexLocalSessionSummary } from '@/types/api' function renderDialog( - sessions: CodexLocalSessionSummary[], + sessions: CodexLocalSessionSummary[] = [], onConfirm = vi.fn(async () => {}), - currentCodexSessionId: string | null = null + currentCodexSessionId: string | null = null, + onRestartCodexDesktop = vi.fn(async () => {}) ) { const view = render( @@ -17,14 +18,14 @@ function renderDialog( sessions={sessions} currentCodexSessionId={currentCodexSessionId} onConfirm={onConfirm} - onRestartCodexDesktop={vi.fn()} + onRestartCodexDesktop={onRestartCodexDesktop} isPending={false} isRestartingCodexDesktop={false} isLoading={false} /> ) - return { ...view, onConfirm } + return { ...view, onConfirm, onRestartCodexDesktop } } describe('CodexSessionSyncDialog', () => { @@ -147,4 +148,22 @@ describe('CodexSessionSyncDialog', () => { expect(onConfirm).toHaveBeenCalledWith(['codex-session-2']) }) + + it('keeps the restart control clear of the close button area', () => { + renderDialog() + + const header = screen.getByTestId('codex-import-dialog-header') + expect(header).toHaveClass('flex') + expect(header).toHaveClass('pr-10') + expect(screen.getByRole('button', { name: 'Restart Codex client' })).toHaveClass('shrink-0') + }) + + it('restarts Codex Desktop from the header control', () => { + const onRestartCodexDesktop = vi.fn(async () => {}) + renderDialog([], undefined, null, onRestartCodexDesktop) + + fireEvent.click(screen.getByRole('button', { name: 'Restart Codex client' })) + + expect(onRestartCodexDesktop).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index c7eefe6062..3e7b2cd3fa 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -142,8 +142,8 @@ export function CodexSessionSyncDialog(props: { return ( !open && onClose()}> -
- +
+ {t('codexSync.confirm.title')} {t('codexSync.confirm.description')} @@ -153,12 +153,13 @@ export function CodexSessionSyncDialog(props: { type="button" variant="secondary" size="sm" + className="shrink-0" onClick={() => void onRestartCodexDesktop()} disabled={isRestartingCodexDesktop} aria-label={t('codexSync.restart.tooltip')} title={t('codexSync.restart.tooltip')} > - {/* 中文注释:把容易被误解为“刷新页面”的 icon 改成明确文字按钮,直接说明这是重启 Codex 客户端。 */} + {/* 中文注释:右侧预留关闭按钮区域,重启按钮保持在标题行右侧但不压到关闭按钮。 */} {isRestartingCodexDesktop ? t('codexSync.restart.confirming') : t('codexSync.restart.tooltip')}
diff --git a/web/src/components/HoverTooltip.test.tsx b/web/src/components/HoverTooltip.test.tsx new file mode 100644 index 0000000000..3cebecf77f --- /dev/null +++ b/web/src/components/HoverTooltip.test.tsx @@ -0,0 +1,60 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + HoverTooltip, + SESSION_ROW_TOOLTIP_FOCUS_CLASS, + useSessionRowTooltipIds +} from './HoverTooltip' + +afterEach(() => cleanup()) + +describe('HoverTooltip keyboard wiring', () => { + it('applies parent row focus-visible reveal classes', () => { + render( + icon} + revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS} + > + Scheduled copy + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.id).toBe('sched-tooltip') + expect(tooltip.className).toContain('group-focus-visible/session-row:visible') + expect(tooltip.className).not.toContain('group-focus-within') + }) +}) + +describe('useSessionRowTooltipIds', () => { + function Probe(props: { hasAttention: boolean; hasSchedule: boolean }) { + const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds( + props.hasAttention, + props.hasSchedule + ) + return ( +
+ ) + } + + it('returns both ids and a combined describedBy when both indicators are present', () => { + render() + const probe = screen.getByTestId('probe') + const attention = probe.getAttribute('data-attention') + const schedule = probe.getAttribute('data-schedule') + expect(attention).toBeTruthy() + expect(schedule).toBeTruthy() + expect(probe.getAttribute('data-describedby')).toBe(`${attention} ${schedule}`) + }) + + it('returns undefined describedBy when neither indicator is present', () => { + render() + expect(screen.getByTestId('probe').getAttribute('data-describedby')).toBe('') + }) +}) diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx new file mode 100644 index 0000000000..cc523f0620 --- /dev/null +++ b/web/src/components/HoverTooltip.tsx @@ -0,0 +1,79 @@ +import { useId, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +/** Tailwind classes that reveal the bubble when a named parent row has :focus-visible. */ +export const SESSION_ROW_TOOLTIP_FOCUS_CLASS = + 'group-focus-visible/session-row:opacity-100 group-focus-visible/session-row:visible' + +/** + * Lightweight CSS-driven tooltip used by the session list to surface "why is + * this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal, + * no positioning JS) keeps the component cheap and avoids z-index surprises + * inside the session-row ` +
+ +
+ + {t('pwa.update.whyToggle')} + +

+ {t('pwa.update.whyBody')} +

+
+
+ ) +} + +export function PwaUpdateBannerWithStatusOffset({ + isSyncing, + isReconnecting, +}: { + isSyncing: boolean + isReconnecting: boolean +}) { + const voice = useVoiceOptional() + const hasTopStatusBanner = + isSyncing || + isReconnecting || + Boolean(voice && voice.status === 'error' && voice.errorMessage) + + return ( + + ) +} diff --git a/web/src/components/SessionAttentionIndicator.test.tsx b/web/src/components/SessionAttentionIndicator.test.tsx new file mode 100644 index 0000000000..20002208f5 --- /dev/null +++ b/web/src/components/SessionAttentionIndicator.test.tsx @@ -0,0 +1,211 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { ReactNode } from 'react' +import type { PendingRequest, SessionSummary } from '@/types/api' +import type { SessionAttention } from '@/lib/sessionAttention' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionAttentionIndicator } from './SessionAttentionIndicator' + +afterEach(() => cleanup()) + +function renderWithI18n(children: ReactNode) { + return render({children}) +} + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + active: true, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + model: null, + effort: null, + ...overrides + } +} + +function makeRequest(overrides: Partial & { id: string; kind: PendingRequest['kind']; tool: string }): PendingRequest { + return { since: 0, ...overrides } +} + +describe('SessionAttentionIndicator tooltip', () => { + it('renders permission tooltip body listing each pending tool', () => { + const summary = makeSummary({ + id: 's1', + pendingRequestsCount: 2, + pendingRequestKinds: ['permission'], + pendingRequests: [ + makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }), + makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' }) + ] + }) + const attention: SessionAttention = { kind: 'permission' } + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('Permission required') + expect(tooltip.textContent).toContain('Approve:') + expect(tooltip.textContent).toContain('Bash') + expect(tooltip.textContent).toContain('Edit') + expect(tooltip.textContent).not.toContain('+1 more') + }) + + it('shows "+N more" when pendingRequestsCount exceeds the rendered slice', () => { + const summary = makeSummary({ + id: 's1', + pendingRequestsCount: 7, + pendingRequestKinds: ['permission'], + pendingRequests: [ + makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }), + makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' }), + makeRequest({ id: 'r3', kind: 'permission', tool: 'Read' }), + makeRequest({ id: 'r4', kind: 'permission', tool: 'Write' }), + makeRequest({ id: 'r5', kind: 'permission', tool: 'Glob' }) + ] + }) + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('+2 more') + }) + + it('renders only the requested kind even when both kinds are pending', () => { + const summary = makeSummary({ + id: 's1', + pendingRequestsCount: 2, + pendingRequestKinds: ['permission', 'input'], + pendingRequests: [ + makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }), + makeRequest({ id: 'r2', kind: 'input', tool: 'AskUserQuestion' }) + ] + }) + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('Needs input') + expect(tooltip.textContent).toContain('Reply to:') + expect(tooltip.textContent).toContain('AskUserQuestion') + expect(tooltip.textContent).not.toContain('Bash') + }) + + it('suppresses the "+N more" hint when both kinds are pending and the slice is capped', () => { + // 5 mixed requests in the slice + 2 more we don't see. The total count + // mixes kinds so we cannot honestly report a per-kind overflow. + const summary = makeSummary({ + id: 's1', + pendingRequestsCount: 7, + pendingRequestKinds: ['permission', 'input'], + pendingRequests: [ + makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }), + makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' }), + makeRequest({ id: 'r3', kind: 'permission', tool: 'Read' }), + makeRequest({ id: 'r4', kind: 'input', tool: 'AskUserQuestion' }), + makeRequest({ id: 'r5', kind: 'input', tool: 'request_user_input' }) + ] + }) + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('Bash') + expect(tooltip.textContent).toContain('Edit') + expect(tooltip.textContent).toContain('Read') + expect(tooltip.textContent).not.toMatch(/\+\d+ more/) + }) + + it('renders background task count', () => { + const summary = makeSummary({ + id: 's1', + backgroundTaskCount: 3 + }) + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('Background tasks running') + expect(tooltip.textContent).toContain('3 tasks running') + }) + + it('renders only the title for unread attention (relative time is already on the row)', () => { + const updatedAt = Date.now() - 5 * 60_000 + const summary = makeSummary({ + id: 's1', + updatedAt + }) + + renderWithI18n( + + ) + + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toContain('New activity') + // The "Nm ago" pill in the session row already shows this; do not duplicate. + expect(tooltip.textContent).not.toMatch(/Updated /) + }) + + it('exposes a stable tooltip id for row aria-describedby wiring', () => { + const summary = makeSummary({ id: 's1' }) + + renderWithI18n( + + ) + + expect(document.getElementById('row-tooltip-unread')).toBeTruthy() + }) +}) diff --git a/web/src/components/SessionAttentionIndicator.tsx b/web/src/components/SessionAttentionIndicator.tsx index e5bb8a8a12..0beb66dedd 100644 --- a/web/src/components/SessionAttentionIndicator.tsx +++ b/web/src/components/SessionAttentionIndicator.tsx @@ -1,5 +1,8 @@ +import type { PendingRequest, SessionSummary } from '@/types/api' import type { SessionAttention } from '@/lib/sessionAttention' import { getSessionAttentionLabelKey } from '@/lib/sessionAttention' +import { useTranslation } from '@/lib/use-translation' +import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip' const ATTENTION_DOT_CLASS: Record = { permission: 'bg-amber-500 animate-pulse', @@ -8,17 +11,121 @@ const ATTENTION_DOT_CLASS: Record = { unread: 'bg-[var(--app-link)]' } +/** + * Visible attention dot + hover tooltip explaining the indicator. + * + * The tooltip body composes from `summary.pendingRequests` (capped oldest-first; + * see `PENDING_REQUEST_SUMMARY_CAP` in `@hapi/protocol`) for permission / input + * attention; from counts and timestamps for background / unread. + */ export function SessionAttentionIndicator(props: { attention: SessionAttention + summary: SessionSummary label: string + tooltipId: string }) { - return ( + const { t } = useTranslation() + const dot = ( ) + + return ( + + + + ) +} + +function AttentionTooltipBody(props: { + attention: SessionAttention + summary: SessionSummary + label: string + t: (key: string, params?: Record) => string +}) { + const { attention, summary, label, t } = props + return ( + + {label} + + + ) +} + +function AttentionTooltipDetail(props: { + attention: SessionAttention + summary: SessionSummary + t: (key: string, params?: Record) => string +}) { + const { attention, summary, t } = props + + if (attention.kind === 'permission' || attention.kind === 'input') { + const wantedKind = attention.kind + const items = (summary.pendingRequests ?? []) + .filter((req): req is PendingRequest => req.kind === wantedKind) + if (items.length === 0) { + return null + } + // Overflow is only knowable per-kind when all pending requests in the + // session share that kind — otherwise `pendingRequestsCount` mixes the + // counts of both kinds. Suppress the "+N more" hint in the mixed case + // rather than report a wrong number. + const kinds = summary.pendingRequestKinds ?? [] + const onlyThisKind = kinds.length === 1 && kinds[0] === wantedKind + const overflow = onlyThisKind + ? Math.max(0, (summary.pendingRequestsCount ?? items.length) - items.length) + : 0 + const bodyKey = wantedKind === 'permission' + ? 'session.tooltip.permission.body' + : 'session.tooltip.input.body' + return ( + + {t(bodyKey)} +
    + {items.map(req => ( +
  • + {req.tool} +
  • + ))} +
+ {overflow > 0 ? ( + + {t('session.tooltip.moreCount', { count: overflow })} + + ) : null} +
+ ) + } + + if (attention.kind === 'background') { + const count = summary.backgroundTaskCount ?? 0 + if (count <= 0) return null + const key = count === 1 + ? 'session.tooltip.background.count.one' + : 'session.tooltip.background.count.other' + return ( + + {t(key, { count })} + + ) + } + + // 'unread' deliberately has no body: the relative-time pill is already + // rendered in the session row, so a tooltip body would just duplicate it. + return null } export function getAttentionLabel( diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 351a017f0c..57eedc3b0e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { AssistantRuntimeProvider, useAssistantApi } from '@assistant-ui/react' +import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react' +import { DragDropZone } from '@/components/AssistantChat/DragDropZone' import type { ApiClient } from '@/api/client' import type { AttachmentMetadata, @@ -8,6 +9,7 @@ import type { DecryptedMessage, PermissionMode, Session, + PiModelSummary, SlashCommand } from '@/types/api' import type { ChatBlock, NormalizedMessage } from '@/chat/types' @@ -29,6 +31,9 @@ import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel' import { useScratchlist } from '@/lib/use-scratchlist' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' +import { consumeSharePendingTransfer } from '@/lib/sharePendingState' +import { deleteShareTransfer, getShareTransfer } from '@/lib/shareTransfer' +import { getDraft } from '@/lib/composer-drafts' import { useTranslation } from '@/lib/use-translation' import { SessionHeader } from '@/components/SessionHeader' import { CursorMigrationBanner } from '@/components/CursorMigrationBanner' @@ -53,6 +58,7 @@ import { } from '@/lib/sessionChatCursorModel' import { buildCursorEffortPickerOptions, resolveCursorVariantOptions } from '@/lib/cursorModelOptions' import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' +import { usePiModels } from '@/hooks/queries/usePiModels' import { useOpencodeReasoningEffortOptions } from '@/hooks/queries/useOpencodeReasoningEffortOptions' import { useVoiceOptional } from '@/lib/voice-context' import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' @@ -154,6 +160,97 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { return message.invokedAt == null && message.scheduledAt != null } +/** + * Consumes a pending Web Share Target transfer once the assistant runtime + * is mounted and the session is active enough to accept attachments. + * + * Lifecycle: + * - A mount effect reads the transfer id out of sessionStorage *once* + * via consumeSharePendingTransfer() (not during render — StrictMode + * would consume on the discarded pass). The id is stashed in a ref. + * - The actual seed (composer.setText + composer.addAttachment per file) + * runs once `props.sessionActive` is true. Inactive sessions disable + * the attachmentAdapter, so writing attachments while inactive would + * no-op and leak Blobs in IDB. The seed waits in a re-renderable + * effect for the active flip. + * - `consumedRef` gates the effect to a single seed per component + * instance — refs survive a StrictMode mount/cleanup/remount pair, so + * the second invoke early-returns and the first invoke's async chain + * completes naturally (we deliberately don't cancel on cleanup; the + * upload is idempotent and the only side effects on the composer are + * no-ops once the runtime is unmounted). + * - The IDB row is deleted after the seed completes so a back-button + * refresh of /sessions/:id doesn't re-attach the same payload. + */ +function ShareSeedConsumer(props: { sessionId: string; sessionActive: boolean }) { + const assistantApi = useAssistantApi() + const composerText = useAssistantState(({ composer }) => composer.text) + const composerTextRef = useRef(composerText) + const initRef = useRef(false) + const transferIdRef = useRef(null) + const consumedRef = useRef(false) + const [transferReady, setTransferReady] = useState(false) + + useEffect(() => { + composerTextRef.current = composerText + }, [composerText]) + + // Consume in an effect, not during render — React.StrictMode double- + // invokes render functions in dev; a render-time consume deletes the + // sessionStorage key on the discarded pass and the committed render + // then sees no transfer. + useEffect(() => { + if (initRef.current) return + initRef.current = true + transferIdRef.current = consumeSharePendingTransfer() + setTransferReady(true) + }, []) + + useEffect(() => { + if (!transferReady) return + if (consumedRef.current) return + const transferId = transferIdRef.current + if (!transferId) return + if (!props.sessionActive) return + consumedRef.current = true + + void (async () => { + try { + const payload = await getShareTransfer(transferId) + if (!payload) return + const seedText = [payload.title, payload.text, payload.url] + .filter((part) => typeof part === 'string' && part.length > 0) + .join('\n') + .trim() + if (seedText.length > 0) { + const existingText = composerTextRef.current.trim().length > 0 + ? composerTextRef.current + : getDraft(props.sessionId) + const nextText = [existingText.trim(), seedText] + .filter((part) => part.length > 0) + .join('\n\n') + if (nextText.length > 0) { + assistantApi.composer().setText(nextText) + } + } + for (const file of payload.files) { + const reconstructed = new File([file.blob], file.name, { type: file.type }) + try { + await assistantApi.composer().addAttachment(reconstructed) + } catch (err) { + console.error('share-seed addAttachment failed', err) + } + } + await deleteShareTransfer(transferId).catch(() => {}) + } catch (err) { + console.error('share-seed pull failed', err) + } + })() + }, [transferReady, props.sessionActive, props.sessionId, assistantApi]) + + return null +} + /** * Mounts the per-session scratchlist DRAWER (composer-controlled). * @@ -278,6 +375,8 @@ type SessionChatProps = { // user dismisses or starts editing. sendError?: ComposerSendError | null onClearSendError?: () => void + initialOutlineOpen?: boolean + onInitialOutlineConsumed?: () => void } /** @@ -311,7 +410,15 @@ function SessionChatInner(props: SessionChatProps) { const blocksByIdRef = useRef>(new Map()) const visibleGroupsRef = useRef([]) const [forceScrollToken, setForceScrollToken] = useState(0) - const [outlineOpen, setOutlineOpen] = useState(false) + const [outlineOpen, setOutlineOpen] = useState(props.initialOutlineOpen ?? false) + useEffect(() => { + if (!props.initialOutlineOpen) { + return + } + setOutlineOpen(true) + props.onInitialOutlineConsumed?.() + }, [props.initialOutlineOpen, props.onInitialOutlineConsumed]) + const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) const scratchlist = useScratchlist(props.session.id) @@ -466,6 +573,17 @@ function SessionChatInner(props: SessionChatProps) { sessionCliModelSkus, props.session.model ]) + const piModelsState = usePiModels({ + api: props.api, + sessionId: props.session.id, + enabled: agentFlavor === 'pi' && props.session.active + }) + // Fallback to cached models from metadata when session is inactive + const piMetadata = props.session.metadata as Record | null + const piCachedModels = piMetadata?.piAvailableModels as PiModelSummary[] | undefined ?? [] + // Provider-qualified selected model — disambiguates when two providers + // share a modelId (hub persists this alongside the legacy modelId string). + const piSelectedModel = piMetadata?.piSelectedModel as { provider: string; modelId: string } | null | undefined const cursorCatalogReadinessArgs = useMemo(() => ({ sessionLoading: cursorModelsState.isLoading, machineLoading: machineCursorModelsState.isLoading, @@ -552,7 +670,6 @@ function SessionChatInner(props: SessionChatProps) { ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) : null ), [agentFlavor, cursorModelEffortOptions, props.session.model]) - const { abortSession, switchSession, @@ -792,7 +909,7 @@ function SessionChatInner(props: SessionChatProps) { }, [setCollaborationMode, props.onRefresh, haptic]) // Model mode change handler - const handleModelChange = useCallback(async (model: string | null) => { + const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => { try { await setModel(model) haptic.notification('success') @@ -889,13 +1006,18 @@ function SessionChatInner(props: SessionChatProps) { props.onRefresh() }, [switchSession, props.onRefresh]) - const handleViewFiles = useCallback(() => { + const handleToggleFiles = useCallback(() => { + setOutlineOpen(false) navigate({ to: '/sessions/$sessionId/files', params: { sessionId: props.session.id } }) }, [navigate, props.session.id]) + const handleToggleOutline = useCallback(() => { + setOutlineOpen((open) => !open) + }, []) + const handleViewTerminal = useCallback(() => { navigate({ to: '/sessions/$sessionId/terminal', @@ -983,8 +1105,10 @@ function SessionChatInner(props: SessionChatProps) { setOutlineOpen(true)} + onToggleFiles={props.session.metadata?.path ? handleToggleFiles : undefined} + filesActive={false} + onToggleOutline={handleToggleOutline} + outlineActive={outlineOpen} api={props.api} onSessionDeleted={props.onBack} onSessionReopened={(newSessionId) => { @@ -1013,9 +1137,15 @@ function SessionChatInner(props: SessionChatProps) { ) : null} -
+ + + 0 ? piModelsState.availableModels : piCachedModels) : undefined} + piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} availableModelReasoningEffortOptions={ agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0 ? opencodeReasoningEffortState.options @@ -1153,9 +1291,11 @@ function SessionChatInner(props: SessionChatProps) { && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 - ? handleCursorBaseModelChange + ? ((model) => handleCursorBaseModelChange(typeof model === 'string' ? model : model?.modelId ?? null)) : undefined) - : handleModelChange + : agentFlavor === 'pi' + ? (props.session.active && !piModelsState.error ? handleModelChange : undefined) + : handleModelChange } onModelEffortChange={ agentFlavor === 'cursor' @@ -1199,7 +1339,7 @@ function SessionChatInner(props: SessionChatProps) { sendError={props.sendError ?? null} onClearSendError={props.onClearSendError} /> -
+
{/* Voice session component - renders nothing but initializes voice backend */} diff --git a/web/src/components/SessionFiles/DirectoryTree.tsx b/web/src/components/SessionFiles/DirectoryTree.tsx index 80b1e532a2..4f282cb33d 100644 --- a/web/src/components/SessionFiles/DirectoryTree.tsx +++ b/web/src/components/SessionFiles/DirectoryTree.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { ApiClient } from '@/api/client' import { FileIcon } from '@/components/FileIcon' import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory' @@ -171,13 +171,40 @@ function DirectoryNode(props: { ) } +const STORAGE_KEY_PREFIX = 'hapi-dir-expanded-' + +function readExpanded(sessionId: string): Set { + try { + const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + sessionId) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Set(parsed as string[]) + } + } catch { + // ignore + } + return new Set(['']) +} + +function writeExpanded(sessionId: string, expanded: Set) { + try { + sessionStorage.setItem(STORAGE_KEY_PREFIX + sessionId, JSON.stringify([...expanded])) + } catch { + // ignore + } +} + export function DirectoryTree(props: { api: ApiClient | null sessionId: string rootLabel: string onOpenFile: (path: string) => void }) { - const [expanded, setExpanded] = useState>(() => new Set([''])) + const [expanded, setExpanded] = useState>(() => readExpanded(props.sessionId)) + + useEffect(() => { + writeExpanded(props.sessionId, expanded) + }, [props.sessionId, expanded]) const handleToggle = useCallback((path: string) => { setExpanded((prev) => { diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index a65f84d0fe..c80ac68a4e 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -70,6 +70,14 @@ function OutlineIcon(props: { className?: string }) { ) } +function headerToggleClass(active: boolean): string { + return `flex h-8 w-8 items-center justify-center rounded-full transition-colors ${ + active + ? 'bg-[var(--app-button)] text-[var(--app-button-text)] hover:opacity-90' + : 'text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]' + }` +} + function MoreVerticalIcon(props: { className?: string }) { return ( void - onViewFiles?: () => void - onOpenOutline?: () => void + onToggleFiles?: () => void + filesActive?: boolean + onToggleOutline?: () => void + outlineActive?: boolean api: ApiClient | null onSessionDeleted?: () => void onSessionReopened?: (newSessionId: string) => void @@ -194,24 +204,27 @@ export function SessionHeader(props: {
- {props.onViewFiles ? ( + {props.onToggleFiles ? ( ) : null} - {props.onOpenOutline ? ( + {props.onToggleOutline ? ( diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 6708916f6a..4112fb5432 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -18,8 +18,10 @@ function makeSession(overrides: Partial & { id: string }): Sessi todoProgress: null, pendingRequestsCount: 0, pendingRequestKinds: [], + pendingRequests: [], backgroundTaskCount: 0, futureScheduledMessageCount: 0, + nextScheduledAt: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 7d2270d317..3cfe546065 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -3,6 +3,8 @@ import type { SessionSummary } from '@/types/api' import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, + filterActiveSessionsOnly, + getNextSessionVisibleCount, getSessionDedupKey, getVisibleSessionPreview, isSidebarEmptySessionStub, @@ -22,8 +24,10 @@ function makeSession(overrides: Partial & { id: string }): Sessi todoProgress: null, pendingRequestsCount: 0, pendingRequestKinds: [], + pendingRequests: [], backgroundTaskCount: 0, futureScheduledMessageCount: 0, + nextScheduledAt: null, model: null, effort: null, ...overrides @@ -296,6 +300,51 @@ describe('getVisibleSessionPreview', () => { }) +describe('filterActiveSessionsOnly', () => { + it('keeps only active sessions when no selection', () => { + const sessions = [ + makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'dead', metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['live']) + }) + + it('keeps the selected inactive session visible', () => { + const sessions = [ + makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'dead', metadata: { path: '/p' } }), + makeSession({ id: 'selected-dead', metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions, 'selected-dead').map(s => s.id).sort()) + .toEqual(['live', 'selected-dead']) + }) + + it('preserves input order', () => { + const sessions = [ + makeSession({ id: 'a', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'b', metadata: { path: '/p' } }), + makeSession({ id: 'c', active: true, metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['a', 'c']) + }) +}) + +describe('getNextSessionVisibleCount', () => { + it('reveals one batch of step size per call', () => { + expect(getNextSessionVisibleCount(8, 8, 20)).toBe(16) + expect(getNextSessionVisibleCount(16, 8, 20)).toBe(20) + }) + + it('never exceeds the total session count', () => { + expect(getNextSessionVisibleCount(18, 8, 20)).toBe(20) + expect(getNextSessionVisibleCount(20, 8, 20)).toBe(20) + }) + + it('always advances by at least one even with a zero step', () => { + expect(getNextSessionVisibleCount(5, 0, 20)).toBe(6) + }) +}) + describe('expandSelectedSessionCollapseOverrides', () => { it('expands collapsed project and machine, but preserves session preview folding', () => { const overrides = new Map([ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index a173e7aebc..8443b88279 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -13,9 +13,13 @@ import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' +import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip' +import { formatRelativeTime } from '@/lib/relativeTime' +import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' @@ -170,6 +174,20 @@ export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessi .filter(session => shouldShowSessionInSidebar(session, selectedSessionId)) } +// "Active sessions only" view: hide inactive sessions, but never hide the one the +// operator currently has open — otherwise toggling the filter would yank the +// selected session out from under them. +export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { + return sessions.filter(session => session.active || session.id === selectedSessionId) +} + +// Paginated "Show N more": reveal one batch (step) at a time instead of expanding +// every hidden session at once. Always advances by at least one and never exceeds +// the total so the button reliably reaches a fully-expanded state. +export function getNextSessionVisibleCount(current: number, step: number, total: number): number { + return Math.min(current + Math.max(1, step), total) +} + function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { const groups = new Map() @@ -545,19 +563,6 @@ function MachineIcon(props: { className?: string }) { ) } -function formatRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { - const ms = value < 1_000_000_000_000 ? value * 1000 : value - if (!Number.isFinite(ms)) return null - const delta = Date.now() - ms - if (delta < 60_000) return t('session.time.justNow') - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) return t('session.time.minutesAgo', { n: minutes }) - const hours = Math.floor(minutes / 60) - if (hours < 24) return t('session.time.hoursAgo', { n: hours }) - const days = Math.floor(hours / 24) - if (days < 7) return t('session.time.daysAgo', { n: days }) - return new Date(ms).toLocaleDateString() -} function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value @@ -654,14 +659,20 @@ function SessionItem(props: { const scheduledLabel = s.futureScheduledMessageCount > 1 ? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount }) : t('session.item.scheduledMessage') + const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0 + const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds( + Boolean(attention), + hasScheduleTooltip + ) return ( <> ) : null}
diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 4b7fe53d1d..b8cd402479 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -13,6 +13,7 @@ import remarkBreaks from 'remark-breaks' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code' +import remarkRepairTables from '@/lib/remark-repair-tables' import { useNavigate } from '@tanstack/react-router' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' @@ -28,7 +29,9 @@ import { UriConfirmDialog } from '@/components/UriConfirmDialog' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' // ── Plugin array ──────────────────────────────────────────────────────────── -// Order: remarkGfm → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks +// Order: remarkGfm → remarkRepairTables → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks +// remarkRepairTables must run immediately after remarkGfm — it reads file.value +// (raw source) to pad short separator rows before remark-gfm parses the table. // remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the // CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation // from them. Both must come before remarkMath (to avoid treating TeX as URI). @@ -51,6 +54,7 @@ const MARKDOWN_PLUGIN_TAIL = [ export const MARKDOWN_PLUGINS = [ remarkGfm, + remarkRepairTables, ...MARKDOWN_PLUGIN_TAIL, ] satisfies NonNullable @@ -58,6 +62,7 @@ export const MARKDOWN_PLUGINS = [ // changing assistant/tool markdown behavior globally. export const MARKDOWN_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkRepairTables, remarkBreaks, ...MARKDOWN_PLUGIN_TAIL, ] satisfies NonNullable diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index e2b47583cc..35a77e17b2 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -14,7 +14,8 @@ async function getMermaid() { function resolveTheme() { if (typeof document === 'undefined') return 'light' as const - return document.documentElement.dataset.theme === 'dark' ? 'dark' as const : 'light' as const + const theme = document.documentElement.dataset.theme + return theme === 'dark' || theme === 'oled' ? 'dark' as const : 'light' as const } async function ensureMermaid(theme: 'light' | 'dark') { diff --git a/web/src/hooks/mutations/useSendMessage.test.tsx b/web/src/hooks/mutations/useSendMessage.test.tsx index 9187bce44c..084f06a283 100644 --- a/web/src/hooks/mutations/useSendMessage.test.tsx +++ b/web/src/hooks/mutations/useSendMessage.test.tsx @@ -3,7 +3,7 @@ import { renderHook, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' import { useSendMessage } from './useSendMessage' -import type { ApiClient } from '@/api/client' +import { ApiError, type ApiClient } from '@/api/client' vi.mock('@/lib/message-window-store', () => ({ appendOptimisticMessage: vi.fn(), @@ -523,6 +523,114 @@ describe('useSendMessage', () => { await expect(acceptedPromise!).resolves.toBe(true) }) + // #918: the inactive-session 409 path + describe('inactive-session 409 (issue #918)', () => { + it('fires onError with the ApiError so the consumer can render a session_inactive affordance', async () => { + // Hub returns 409 with code: 'session_inactive' (guards.ts). + // The api client throws ApiError(status=409, code='session_inactive'). + const onError = vi.fn() + const api = createMockApi(async () => { + throw new ApiError( + 'HTTP 409 Conflict: {"error":"Session is inactive","code":"session_inactive"}', + 409, + 'session_inactive', + '{"error":"Session is inactive","code":"session_inactive"}' + ) + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello inactive') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string } + expect(info.text).toBe('hello inactive') + expect(info.sessionId).toBe('session-A') + expect(info.error).toBeInstanceOf(ApiError) + const apiErr = info.error as ApiError + expect(apiErr.status).toBe(409) + expect(apiErr.code).toBe('session_inactive') + }) + + it('fires onError when resolveSessionId rejects (pre-mutation inactive-session failure)', async () => { + // Pre-mutation: the route's resolveSessionId throws when + // inactiveSessionCanResume returns false OR api.resumeSession + // fails. Prior to #918 this dropped the typed text into the + // void with only a console.error; the operator saw nothing. + // The hook must surface this through onError too. + const onError = vi.fn() + const api = createMockApi() + const resumeError = new ApiError('Session is inactive', 409, 'session_inactive') + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { + onError, + resolveSessionId: async () => { throw resumeError }, + onSessionResolved: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello pre-mutation') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string } + expect(info.text).toBe('hello pre-mutation') + // Keyed by the ORIGINAL sessionId: pre-mutation never navigated. + expect(info.sessionId).toBe('session-A') + expect(info.error).toBe(resumeError) + }) + + it('5xx still uses the legacy text-restore path (#918 must not regress transient-failure UX)', async () => { + // Acceptance criterion: a real transient 500/network failure + // must keep the original behavior (remove optimistic row, + // onError fires with the plain message), not adopt the + // session_inactive affordance. + const onError = vi.fn() + const api = createMockApi(async () => { + throw new ApiError( + 'HTTP 500 Internal Server Error', + 500, + undefined, + undefined + ) + }) + + const { removeOptimisticMessage } = await import('@/lib/message-window-store') + const removeMock = vi.mocked(removeOptimisticMessage) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello transient') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + expect(removeMock).toHaveBeenCalledWith('session-A', 'local-id-1') + const info = onError.mock.calls[0][0] as { error: unknown } + // No session_inactive code -> consumer renders fallback + // message, no Reopen action attached. + expect((info.error as ApiError).code).toBeUndefined() + expect((info.error as ApiError).status).toBe(500) + }) + }) + it('preserves scheduledAt when retrying a failed scheduled message', async () => { const sendMock = vi.fn(async () => {}) const api = createMockApi(sendMock) diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index 2f6c77ff46..c37d38742b 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -235,6 +235,22 @@ export function useSendMessage( } catch (error) { haptic.notification('error') console.error('Failed to resolve session before send:', error) + // #918: surface the failure via onError so the route can render + // an inline affordance instead of silently swallowing the + // typed text. This covers the "no resume target" branch + // (inactiveSessionCanResume === false) and also any failure + // from api.resumeSession itself. The mutation never started + // (no optimistic row to clean up); onError is the only + // visibility hook the consumer has for this pre-mutation + // path. Key by the ORIGINAL sessionId because navigation + // hasn't happened yet -- the operator is still on the + // archived session's route. + options?.onError?.({ + sessionId, + text, + error, + scheduledAt: scheduledAt ?? null + }) return false } finally { resolveGuardRef.current = false diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index bdbb59f3da..d9e8b30022 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -19,7 +19,7 @@ export function useSessionActions( switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise - setModel: (model: string | null) => Promise + setModel: (model: { provider: string; modelId: string } | string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise setServiceTier: (serviceTier: string | null) => Promise @@ -111,7 +111,7 @@ export function useSessionActions( }) const modelMutation = useMutation({ - mutationFn: async (model: string | null) => { + mutationFn: async (model: { provider: string; modelId: string } | string | null) => { if (!api || !sessionId) { throw new Error('Session unavailable') } diff --git a/web/src/hooks/queries/usePiModels.ts b/web/src/hooks/queries/usePiModels.ts new file mode 100644 index 0000000000..61ffd96ddd --- /dev/null +++ b/web/src/hooks/queries/usePiModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { PiModelSummary, PiModelsResponse } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function usePiModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: PiModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionPiModels(sessionId) + : ['session-pi-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('Pi models target unavailable') + } + return await api.callPiEndpoint(sessionId, 'models') + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Pi models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Pi models' + : null, + } +} diff --git a/web/src/hooks/queries/useSession.test.ts b/web/src/hooks/queries/useSession.test.ts index b5adcfdc31..7d374cbc0b 100644 --- a/web/src/hooks/queries/useSession.test.ts +++ b/web/src/hooks/queries/useSession.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSessionNotFoundError } from './useSession' +import { isSessionNotFoundError, SESSION_DETAIL_STALE_TIME_MS } from './useSession' describe('isSessionNotFoundError', () => { it('matches hub 404 session responses', () => { @@ -11,3 +11,13 @@ describe('isSessionNotFoundError', () => { expect(isSessionNotFoundError(null)).toBe(false) }) }) + +describe('SESSION_DETAIL_STALE_TIME_MS', () => { + // SSE patches the cache directly on session-updated events, so the REST + // endpoint is just a cold-start / reconnect-recovery path. A long staleTime + // suppresses focus-refetch and remount-refetch storms — primary lever for + // the refetch-storm fix (tiann/hapi#884). + it('is set to a value that suppresses focus/mount refetches', () => { + expect(SESSION_DETAIL_STALE_TIME_MS).toBeGreaterThanOrEqual(10_000) + }) +}) diff --git a/web/src/hooks/queries/useSession.ts b/web/src/hooks/queries/useSession.ts index d9d6e5be07..adf086ea11 100644 --- a/web/src/hooks/queries/useSession.ts +++ b/web/src/hooks/queries/useSession.ts @@ -8,6 +8,17 @@ export function isSessionNotFoundError(error: unknown): boolean { && (error.message.includes('HTTP 404') || error.message.includes('Session not found')) } +// Session detail freshness is driven by SSE events (`useSSE` patches the cache +// directly on `session-updated`). The REST endpoint is only a cold-start / +// reconnect-recovery path, so a long per-query staleTime extends the global +// default (5s, see `web/src/lib/query-client.ts`) for `useSession` only — this +// suppresses remount-refetch when the user navigates back to a recently-viewed +// session within the window, without making the UI stale. Explicit +// `invalidateQueries` calls (SSE fallback path, reconnect-recovery in +// `App.tsx`) still refetch active observers regardless of staleTime, so live +// updates and recovery flows continue to work. See tiann/hapi#884. +export const SESSION_DETAIL_STALE_TIME_MS = 30_000 + export function useSession(api: ApiClient | null, sessionId: string | null): { session: Session | null isLoading: boolean @@ -25,6 +36,7 @@ export function useSession(api: ApiClient | null, sessionId: string | null): { return await api.getSession(sessionId) }, enabled: Boolean(api && sessionId), + staleTime: SESSION_DETAIL_STALE_TIME_MS, retry: (failureCount, error) => { if (isSessionNotFoundError(error)) { return false diff --git a/web/src/hooks/useAuth.test.tsx b/web/src/hooks/useAuth.test.tsx new file mode 100644 index 0000000000..a7d7dbc3e0 --- /dev/null +++ b/web/src/hooks/useAuth.test.tsx @@ -0,0 +1,79 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +// Mock the network layer so we can drive token refreshes deterministically. +// The real ApiClient reads the live token via `getToken`, so the mock records the +// constructor options (including onUnauthorized) and hands out incrementing tokens. +const h = vi.hoisted(() => { + let idSeq = 0 + let authCount = 0 + class MockApiClient { + token: string + options: { getToken?: () => string | null; onUnauthorized?: () => unknown; baseUrl?: string } | undefined + readonly id: number + constructor(token: string, options?: MockApiClient['options']) { + this.token = token + this.options = options + this.id = ++idSeq + } + async authenticate(): Promise<{ token: string; user: { id: string } }> { + authCount += 1 + return { token: `token-${authCount}`, user: { id: 'u1' } } + } + } + class MockApiError extends Error { + status: number + code?: string + constructor(message: string, status = 401, code?: string) { + super(message) + this.status = status + this.code = code + } + } + return { MockApiClient, MockApiError } +}) + +vi.mock('@/api/client', () => ({ ApiClient: h.MockApiClient, ApiError: h.MockApiError })) + +// Imported after the mock is registered (vi.mock is hoisted). +import { useAuth } from '@/hooks/useAuth' + +type ApiWithOptions = { + id: number + options?: { getToken?: () => string | null; onUnauthorized?: () => unknown } +} + +describe('useAuth — api identity stability across token refresh (issue #927)', () => { + it('keeps the same ApiClient instance when the token refreshes', async () => { + // Stable authSource reference, exactly like the real caller (useAuthSource holds it in + // useState). This isolates the bug under test: a *token* refresh, not a source change. + const authSource = { type: 'accessToken' as const, token: 'seed' } + const { result } = renderHook(() => useAuth(authSource, 'http://hub.test')) + + // Initial authenticate resolves and sets the first token. + await waitFor(() => expect(result.current.api).not.toBeNull()) + const api1 = result.current.api as unknown as ApiWithOptions + const token1 = result.current.token + expect(token1).toBe('token-1') + + // Drive the exact real-world trigger: a 401 invokes onUnauthorized, + // which force-refreshes the token (this is what the flaky remote network does). + await act(async () => { + await api1.options?.onUnauthorized?.() + }) + + // The token did advance... + expect(result.current.token).toBe('token-2') + expect(result.current.token).not.toBe(token1) + + // ...but recreating the client was unnecessary: the OLD instance already serves + // the fresh token via getToken, so nothing downstream needed a new `api` reference. + expect(api1.options?.getToken?.()).toBe(result.current.token) + + // DESIRED: `api` stays referentially stable across a refresh, so effects keyed on + // `api` (VoiceBackendSession `[props.api]`, GeneratedImageCard `[ctx.api, ...]`) do + // NOT re-run / remount. On current code `api` is rebuilt because `token` is a useMemo + // dep, which drives the Voice-remount spam + per-image refetch storm. This fails today. + expect(result.current.api).toBe(api1 as unknown as typeof result.current.api) + }) +}) diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 054ced5a08..8a6c38b2b1 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -157,15 +157,21 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { } }, [baseUrl]) + // Keep the ApiClient referentially stable across token *refreshes*: the client always reads + // the live token via getToken (tokenRef), so it never needs rebuilding when the token value + // changes — only when auth presence toggles (login/logout). Rebuilding on every refresh churns + // `api`'s identity, which remounts everything keyed on it (VoiceBackendSession `[props.api]`, + // GeneratedImageCard `[ctx.api, ...]`) and drives the remount/refetch storm. Issue #927. + const hasToken = token !== null const api = useMemo(() => ( - token - ? new ApiClient(token, { + hasToken + ? new ApiClient(tokenRef.current ?? '', { baseUrl, getToken: () => tokenRef.current, onUnauthorized: () => refreshAuth({ force: true }) }) : null - ), [baseUrl, refreshAuth, token]) + ), [baseUrl, refreshAuth, hasToken]) useEffect(() => { let isCancelled = false diff --git a/web/src/hooks/useChatSurfaceColors.ts b/web/src/hooks/useChatSurfaceColors.ts index f4402f16b4..466f5ef395 100644 --- a/web/src/hooks/useChatSurfaceColors.ts +++ b/web/src/hooks/useChatSurfaceColors.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' -type ThemeMode = 'light' | 'dark' +type ThemeMode = 'light' | 'dark' | 'oled' type SurfaceKey = 'tool-group' | 'user-message' export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow' @@ -29,6 +29,10 @@ const THEME_BASES: Record> = { 'tool-group': '#2b2f34', 'user-message': '#2b2f34', }, + oled: { + 'tool-group': '#0e0e10', + 'user-message': '#141414', + }, } let initialized = false @@ -90,7 +94,9 @@ function parseChatSurfaceColorPreference(raw: string | null): ChatSurfaceColorPr function getThemeMode(): ThemeMode { if (!isBrowser()) return 'light' - return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' + const theme = document.documentElement.getAttribute('data-theme') + if (theme === 'dark' || theme === 'oled') return theme + return 'light' } function hexToRgb(hex: string): [number, number, number] { @@ -135,7 +141,8 @@ function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: ThemeMode, if (!accent) return null const base = THEME_BASES[theme][surface] - const ratio = pref.startsWith('custom:') ? (theme === 'dark' ? 0.22 : 0.34) : (theme === 'dark' ? 0.2 : 0.3) + const isDarkBase = theme !== 'light' + const ratio = pref.startsWith('custom:') ? (isDarkBase ? 0.22 : 0.34) : (isDarkBase ? 0.2 : 0.3) return mixHex(base, accent, ratio) } diff --git a/web/src/hooks/useDragOver.test.ts b/web/src/hooks/useDragOver.test.ts new file mode 100644 index 0000000000..6a98a47b01 --- /dev/null +++ b/web/src/hooks/useDragOver.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDragOver } from './useDragOver' + +function makeDragEvent(type: string, types: string[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types }, + configurable: true, + }) + return event +} + +describe('useDragOver', () => { + it('prevents the browser default when a file is dropped outside a zone', () => { + // Regression: a file dropped on the document (e.g. the sidebar) must not + // trigger the browser's file-open/navigation behaviour. + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('does not prevent default for a non-file drop', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['text/plain']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(false) + unmount() + }) + + it('also prevents default on dragover for files so the drop can be cancelled', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('dragover', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('tracks file-drag state and clears it on drop', () => { + const { result, unmount } = renderHook(() => useDragOver()) + expect(result.current).toBe(false) + + act(() => { + document.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + }) + expect(result.current).toBe(true) + + act(() => { + document.dispatchEvent(makeDragEvent('drop', ['Files'])) + }) + expect(result.current).toBe(false) + unmount() + }) +}) diff --git a/web/src/hooks/useDragOver.ts b/web/src/hooks/useDragOver.ts new file mode 100644 index 0000000000..6a53e90eda --- /dev/null +++ b/web/src/hooks/useDragOver.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' + +/** + * Returns true while the user is dragging files over the browser window. + * Also suppresses the browser's default file-open behaviour for drags that + * land outside an explicit drop zone. + */ +export function useDragOver(): boolean { + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + setIsDraggingFiles(true) + } + } + + // Only clear when the drag leaves the browser window entirely + // (relatedTarget === null means the pointer moved outside the document) + const onDragLeave = (e: DragEvent) => { + if (e.relatedTarget === null) { + setIsDraggingFiles(false) + } + } + + const clearDrag = () => setIsDraggingFiles(false) + + // Prevent the browser from opening/navigating to a file dropped outside + // an explicit drop zone (e.g. the sidebar). This must run on BOTH + // `dragover` and `drop`: preventing only `dragover` still lets the + // browser perform its default file-open action on the `drop` event. + const preventFileDefault = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + e.preventDefault() + } + } + + const onDrop = (e: DragEvent) => { + preventFileDefault(e) + clearDrag() + } + + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('dragend', clearDrag) + document.addEventListener('drop', onDrop) + document.addEventListener('dragover', preventFileDefault) + + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('dragend', clearDrag) + document.removeEventListener('drop', onDrop) + document.removeEventListener('dragover', preventFileDefault) + } + }, []) + + return isDraggingFiles +} diff --git a/web/src/hooks/usePwaUpdate.test.ts b/web/src/hooks/usePwaUpdate.test.ts new file mode 100644 index 0000000000..d5733b693c --- /dev/null +++ b/web/src/hooks/usePwaUpdate.test.ts @@ -0,0 +1,230 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PWA_UPDATE_CHECK_INTERVAL_MS, + PWA_UPDATE_RELOAD_FALLBACK_MS, + requestPwaUpdateReload, + setupRegistrationUpdateChecks, + usePwaUpdate, +} from '@/hooks/usePwaUpdate' + +const registerSWMock = vi.fn() +const serviceWorkerListeners = new Map>() + +vi.mock('virtual:pwa-register', () => ({ + registerSW: (options: Parameters[0]) => registerSWMock(options), +})) + +beforeEach(() => { + serviceWorkerListeners.clear() + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + addEventListener: (type: string, listener: EventListener) => { + const bucket = serviceWorkerListeners.get(type) ?? new Set() + bucket.add(listener) + serviceWorkerListeners.set(type, bucket) + }, + removeEventListener: (type: string, listener: EventListener) => { + serviceWorkerListeners.get(type)?.delete(listener) + }, + }, + }) +}) + +describe('setupRegistrationUpdateChecks', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('checks for updates on an hourly interval', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + const cleanup = setupRegistrationUpdateChecks(registration) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(2) + + cleanup() + }) + + it('checks for updates when the tab becomes visible', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + const cleanup = setupRegistrationUpdateChecks(registration) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + document.dispatchEvent(new Event('visibilitychange')) + expect(registration.update).not.toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + document.dispatchEvent(new Event('visibilitychange')) + expect(registration.update).toHaveBeenCalledTimes(1) + + cleanup() + }) + + it('removes listeners and clears the interval on cleanup', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener') + const clearIntervalSpy = vi.spyOn(window, 'clearInterval') + + const cleanup = setupRegistrationUpdateChecks(registration) + cleanup() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(clearIntervalSpy).toHaveBeenCalled() + }) +}) + +describe('requestPwaUpdateReload', () => { + it('reloads immediately when updateSW is unavailable', async () => { + const reloadPage = vi.fn() + + await requestPwaUpdateReload(null, { reloadPage }) + + expect(reloadPage).toHaveBeenCalledTimes(1) + }) + + it('calls updateSW and reloads on controllerchange', async () => { + const updateSW = vi.fn().mockImplementation(async () => { + for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) { + listener(new Event('controllerchange')) + } + }) + const reloadPage = vi.fn() + + await requestPwaUpdateReload(updateSW, { reloadPage }) + + expect(updateSW).toHaveBeenCalledWith(true) + expect(reloadPage).toHaveBeenCalledTimes(1) + }) + + it('falls back to reload when controllerchange never fires', async () => { + vi.useFakeTimers() + + const updateSW = vi.fn().mockResolvedValue(undefined) + const reloadPage = vi.fn() + + const pending = requestPwaUpdateReload(updateSW, { + reloadPage, + setTimeoutFn: vi.fn((callback, delay) => { + expect(delay).toBe(PWA_UPDATE_RELOAD_FALLBACK_MS) + return setTimeout(callback, delay) + }) as typeof setTimeout, + }) + + await pending + vi.runAllTimers() + + expect(updateSW).toHaveBeenCalledWith(true) + expect(reloadPage).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) +}) + +describe('usePwaUpdate', () => { + let capturedOptions: { + onNeedRefresh?: () => void + onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void + } = {} + const updateSW = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + capturedOptions = {} + updateSW.mockClear() + registerSWMock.mockImplementation((options) => { + capturedOptions = options + return updateSW + }) + }) + + it('registers the service worker and exposes refresh state', () => { + const { result } = renderHook(() => usePwaUpdate()) + + expect(registerSWMock).toHaveBeenCalledTimes(1) + expect(result.current.needRefresh).toBe(false) + + act(() => { + capturedOptions.onNeedRefresh?.() + }) + + expect(result.current.needRefresh).toBe(true) + }) + + it('reloads through updateSW when reload is called', async () => { + const updateSW = vi.fn().mockImplementation(async () => { + for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) { + listener(new Event('controllerchange')) + } + }) + registerSWMock.mockImplementation((options) => { + capturedOptions = options + return updateSW + }) + + const { result } = renderHook(() => usePwaUpdate()) + + await act(async () => { + result.current.reload() + }) + + expect(updateSW).toHaveBeenCalledWith(true) + }) + + it('keeps needRefresh true until a successful reload clears the page', () => { + const { result } = renderHook(() => usePwaUpdate()) + + act(() => { + capturedOptions.onNeedRefresh?.() + }) + + expect(result.current.needRefresh).toBe(true) + + act(() => { + result.current.reload() + }) + + expect(updateSW).toHaveBeenCalledWith(true) + expect(result.current.needRefresh).toBe(true) + }) + + it('wires registration update checks from onRegistered', () => { + vi.useFakeTimers() + + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + renderHook(() => usePwaUpdate()) + + act(() => { + capturedOptions.onRegistered?.(registration) + }) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) +}) diff --git a/web/src/hooks/usePwaUpdate.ts b/web/src/hooks/usePwaUpdate.ts new file mode 100644 index 0000000000..5a4e18911b --- /dev/null +++ b/web/src/hooks/usePwaUpdate.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { registerSW } from 'virtual:pwa-register' + +export const PWA_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000 +export const PWA_UPDATE_RELOAD_FALLBACK_MS = 2000 + +export async function requestPwaUpdateReload( + updateSW: ((reloadPage?: boolean) => Promise) | null | undefined, + options: { + reloadPage?: () => void + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + } = {}, +): Promise { + const reloadPage = options.reloadPage ?? (() => window.location.reload()) + const setTimeoutFn = options.setTimeoutFn ?? setTimeout + const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout + + if (!updateSW) { + reloadPage() + return + } + + let reloaded = false + const doReload = () => { + if (reloaded) { + return + } + reloaded = true + reloadPage() + } + + const onControllerChange = () => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + doReload() + } + + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange) + + let fallbackTimer: ReturnType | undefined + + try { + await updateSW(true) + } catch (error) { + console.error('PWA update failed', error) + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + if (fallbackTimer !== undefined) { + clearTimeoutFn(fallbackTimer) + } + doReload() + return + } + + fallbackTimer = setTimeoutFn(() => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + doReload() + }, PWA_UPDATE_RELOAD_FALLBACK_MS) +} + +export function setupRegistrationUpdateChecks( + registration: ServiceWorkerRegistration, +): () => void { + const intervalId = window.setInterval(() => { + void registration.update() + }, PWA_UPDATE_CHECK_INTERVAL_MS) + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void registration.update() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } +} + +export function usePwaUpdate() { + const [needRefresh, setNeedRefresh] = useState(false) + const updateSWRef = useRef<((reloadPage?: boolean) => Promise) | null>(null) + const cleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + const updateSW = registerSW({ + onNeedRefresh() { + setNeedRefresh(true) + }, + onOfflineReady() { + console.log('App ready for offline use') + }, + onRegistered(registration) { + cleanupRef.current?.() + cleanupRef.current = null + + if (!registration) { + return + } + + cleanupRef.current = setupRegistrationUpdateChecks(registration) + }, + onRegisterError(error) { + console.error('SW registration error:', error) + }, + }) + + updateSWRef.current = updateSW + + return () => { + cleanupRef.current?.() + cleanupRef.current = null + updateSWRef.current = null + } + }, []) + + const reload = useCallback(() => { + void requestPwaUpdateReload(updateSWRef.current) + }, []) + + return { needRefresh, reload } +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 11569e43a3..fe462d82af 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -304,7 +304,8 @@ export function useSSE(options: { const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined const summary = { ...toSessionSummary(session), - futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0 + futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0, + nextScheduledAt: existing?.nextScheduledAt ?? null } const nextSessions = previous.sessions.slice() if (existingIndex >= 0) { diff --git a/web/src/hooks/useShowActiveSessionsOnly.ts b/web/src/hooks/useShowActiveSessionsOnly.ts new file mode 100644 index 0000000000..9e5be10bba --- /dev/null +++ b/web/src/hooks/useShowActiveSessionsOnly.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from 'react' + +export const DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY = false + +function getShowActiveSessionsOnlyStorageKey(): string { + return 'hapi-show-active-sessions-only' +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) { + return null + } + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseShowActiveSessionsOnly(raw: string | null): boolean { + if (raw === 'true') { + return true + } + return DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY +} + +export function getInitialShowActiveSessionsOnly(): boolean { + return parseShowActiveSessionsOnly(safeGetItem(getShowActiveSessionsOnlyStorageKey())) +} + +export function useShowActiveSessionsOnly(): { + showActiveSessionsOnly: boolean + setShowActiveSessionsOnly: (value: boolean) => void +} { + const [showActiveSessionsOnly, setShowActiveSessionsOnlyState] = useState(getInitialShowActiveSessionsOnly) + + useEffect(() => { + if (!isBrowser()) { + return + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== getShowActiveSessionsOnlyStorageKey()) { + return + } + setShowActiveSessionsOnlyState(parseShowActiveSessionsOnly(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setShowActiveSessionsOnly = useCallback((value: boolean) => { + setShowActiveSessionsOnlyState(value) + + if (value === DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY) { + safeRemoveItem(getShowActiveSessionsOnlyStorageKey()) + } else { + safeSetItem(getShowActiveSessionsOnlyStorageKey(), String(value)) + } + }, []) + + return { showActiveSessionsOnly, setShowActiveSessionsOnly } +} diff --git a/web/src/hooks/useTheme.test.ts b/web/src/hooks/useTheme.test.ts index 9468320ae1..150b4baa58 100644 --- a/web/src/hooks/useTheme.test.ts +++ b/web/src/hooks/useTheme.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' -import { getThemeColor, initializeTheme, useAppearance } from '@/hooks/useTheme' +import { getAppearanceOptions, getThemeColor, initializeTheme, useAppearance } from '@/hooks/useTheme' describe('useTheme', () => { beforeEach(() => { @@ -43,4 +43,27 @@ describe('useTheme', () => { expect(document.documentElement).toHaveAttribute('data-theme', 'light') expect(document.querySelector('meta[name="theme-color"]')?.content).toBe(getThemeColor('light')) }) + + it('exposes OLED Black as a selectable appearance option', () => { + expect(getAppearanceOptions().some((opt) => opt.value === 'oled')).toBe(true) + }) + + it('applies the OLED appearance with a pure-black browser theme color', () => { + localStorage.setItem('hapi-appearance', 'oled') + + initializeTheme() + + expect(document.documentElement).toHaveAttribute('data-theme', 'oled') + expect(getThemeColor('oled')).toBe('#000000') + expect(document.querySelector('meta[name="theme-color"]')?.content).toBe('#000000') + }) + + it('does not auto-select OLED for the system appearance', () => { + // No stored appearance => system; system must resolve to light/dark, never OLED. + initializeTheme() + + const theme = document.documentElement.getAttribute('data-theme') + expect(theme === 'light' || theme === 'dark').toBe(true) + expect(theme).not.toBe('oled') + }) }) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 7fda73ddfa..de61918990 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,14 +1,15 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { getTelegramWebApp } from './useTelegram' -type ColorScheme = 'light' | 'dark' +type ColorScheme = 'light' | 'dark' | 'oled' -export type AppearancePreference = 'system' | 'dark' | 'light' +export type AppearancePreference = 'system' | 'dark' | 'light' | 'oled' const APPEARANCE_KEY = 'hapi-appearance' const THEME_COLORS: Record = { light: '#ffffff', dark: '#1c1c1e', + oled: '#000000', } function isBrowser(): boolean { @@ -43,7 +44,7 @@ function safeRemoveItem(key: string): void { } function parseAppearance(raw: string | null): AppearancePreference { - if (raw === 'dark' || raw === 'light') return raw + if (raw === 'dark' || raw === 'light' || raw === 'oled') return raw return 'system' } @@ -55,15 +56,16 @@ export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePrefere return [ { value: 'system', labelKey: 'settings.display.appearance.system' }, { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'oled', labelKey: 'settings.display.appearance.oled' }, { value: 'light', labelKey: 'settings.display.appearance.light' }, ] } function getColorScheme(): ColorScheme { const pref = getStoredAppearance() - if (pref === 'dark' || pref === 'light') return pref + if (pref === 'dark' || pref === 'light' || pref === 'oled') return pref - // 'system': use Telegram → system preference → light + // 'system': use Telegram → system preference → light (never auto-selects OLED) const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' @@ -143,7 +145,7 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { return { colorScheme, - isDark: colorScheme === 'dark', + isDark: colorScheme === 'dark' || colorScheme === 'oled', } } diff --git a/web/src/hooks/useThemeColors.test.ts b/web/src/hooks/useThemeColors.test.ts new file mode 100644 index 0000000000..5493112788 --- /dev/null +++ b/web/src/hooks/useThemeColors.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyThemeColors, + initializeThemeColors, + THEME_COLOR_KEYS, + useThemeColors, +} from '@/hooks/useThemeColors' + +function setScheme(scheme: string): void { + document.documentElement.setAttribute('data-theme', scheme) +} + +describe('useThemeColors', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + document.documentElement.removeAttribute('style') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes a curated set of key colors including background and accent', () => { + const ids = THEME_COLOR_KEYS.map((key) => key.id) + expect(THEME_COLOR_KEYS.length).toBeGreaterThanOrEqual(6) + expect(ids).toContain('background') + expect(ids).toContain('accent') + }) + + it('writes the mapped CSS variables when a key color is customized', () => { + setScheme('oled') + const { result } = renderHook(() => useThemeColors()) + + act(() => result.current.setColor('background', '#123456')) + + expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#123456') + expect(localStorage.getItem('hapi-theme-colors')).toContain('123456') + }) + + it('ignores invalid hex values', () => { + setScheme('oled') + const { result } = renderHook(() => useThemeColors()) + + act(() => result.current.setColor('background', 'not-a-color')) + + expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('') + expect(localStorage.getItem('hapi-theme-colors')).toBeNull() + }) + + it('scopes custom colors per appearance', () => { + setScheme('dark') + const { result } = renderHook(() => useThemeColors()) + + act(() => result.current.setColor('background', '#111111')) + expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#111111') + + // A dark-only override must not leak into the light appearance. + act(() => setScheme('light')) + applyThemeColors() + expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('') + + // Switching back restores it. + act(() => setScheme('dark')) + applyThemeColors() + expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#111111') + }) + + it('resets a key color back to the theme default', () => { + setScheme('oled') + const { result } = renderHook(() => useThemeColors()) + + act(() => result.current.setColor('background', '#123456')) + act(() => result.current.resetColor('background')) + + expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('') + expect(localStorage.getItem('hapi-theme-colors')).toBeNull() + }) + + it('reapplies stored colors for the active appearance during initialization', () => { + localStorage.setItem('hapi-theme-colors', JSON.stringify({ oled: { background: '#0b0b0b' } })) + setScheme('oled') + + initializeThemeColors() + + expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#0b0b0b') + }) +}) diff --git a/web/src/hooks/useThemeColors.ts b/web/src/hooks/useThemeColors.ts new file mode 100644 index 0000000000..826bc856cf --- /dev/null +++ b/web/src/hooks/useThemeColors.ts @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * Per-appearance "key color" customization. + * + * Unlike {@link useChatSurfaceColors} (which tints two chat surfaces), this hook + * exposes a curated set of key colors. Each key cascades to a small group of + * `--app-*` tokens (and a couple of derived ones) so the whole palette stays + * coherent without a 60-token editor. Overrides are scoped per appearance + * (`light | dark | oled`) because a color that reads well on white will not on + * pure black. + */ + +export type ThemeScheme = 'light' | 'dark' | 'oled' + +export type ThemeColorKeyId = + | 'background' + | 'surface' + | 'text' + | 'hint' + | 'accent' + | 'border' + | 'userBubble' + +interface ThemeColorKey { + id: ThemeColorKeyId + labelKey: string + /** Tokens set directly to the chosen hex. */ + targets: readonly string[] + /** Tokens computed from the chosen hex (cleared together with the base). */ + derivedTargets?: readonly string[] + derive?: (hex: string, scheme: ThemeScheme) => Record +} + +const STORAGE_KEY = 'hapi-theme-colors' + +export const THEME_COLOR_KEYS: readonly ThemeColorKey[] = [ + { + id: 'background', + labelKey: 'settings.display.themeColors.key.background', + targets: ['--app-bg'], + }, + { + id: 'surface', + labelKey: 'settings.display.themeColors.key.surface', + targets: [ + '--app-secondary-bg', + '--app-dialog-bg', + '--app-tool-card-bg', + '--app-reasoning-bg', + '--app-md-table-bg', + '--app-code-bg', + '--app-inline-code-bg', + ], + derivedTargets: ['--app-tool-card-hover-bg'], + derive: (hex, scheme) => ({ + '--app-tool-card-hover-bg': mixHex(hex, contrastColor(scheme), 0.08), + }), + }, + { + id: 'text', + labelKey: 'settings.display.themeColors.key.text', + targets: ['--app-fg', '--app-chat-user-fg', '--app-inline-code-fg'], + }, + { + id: 'hint', + labelKey: 'settings.display.themeColors.key.hint', + targets: ['--app-hint', '--app-tool-card-subtitle'], + }, + { + id: 'accent', + labelKey: 'settings.display.themeColors.key.accent', + targets: ['--app-link', '--app-chat-user-chip-fg'], + }, + { + id: 'border', + labelKey: 'settings.display.themeColors.key.border', + targets: ['--app-border', '--app-divider'], + }, + { + id: 'userBubble', + labelKey: 'settings.display.themeColors.key.userBubble', + targets: ['--app-chat-user-bg'], + }, +] + +/** Fallback swatch values that mirror the CSS theme defaults (no override set). */ +const DEFAULT_HEX: Record> = { + light: { + background: '#ffffff', + surface: '#f2f4f6', + text: '#111827', + hint: '#6b7280', + accent: '#111827', + border: '#e2e8f0', + userBubble: '#f2f4f6', + }, + dark: { + background: '#1c1c1e', + surface: '#2b2f34', + text: '#ffffff', + hint: '#8e8e93', + accent: '#ffffff', + border: '#2a2a2c', + userBubble: '#2b2f34', + }, + oled: { + background: '#000000', + surface: '#0e0e10', + text: '#f5f5f7', + hint: '#8e8e93', + accent: '#4ea1ff', + border: '#1f1f22', + userBubble: '#141414', + }, +} + +type StoredThemeColors = Partial>>> + +let initialized = false + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) return + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) return + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function isHexColor(value: string): boolean { + return /^#[0-9a-f]{6}$/i.test(value) +} + +export function normalizeThemeColor(value: string): string | null { + const normalized = value.trim().toLowerCase() + return isHexColor(normalized) ? normalized : null +} + +function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace('#', '') + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ] +} + +function clampChannel(value: number): number { + return Math.max(0, Math.min(255, value)) +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${[r, g, b] + .map((channel) => clampChannel(channel).toString(16).padStart(2, '0')) + .join('')}` +} + +function mixHex(base: string, accent: string, ratio: number): string { + const [br, bg, bb] = hexToRgb(base) + const [ar, ag, ab] = hexToRgb(accent) + return rgbToHex( + Math.round(br + (ar - br) * ratio), + Math.round(bg + (ag - bg) * ratio), + Math.round(bb + (ab - bb) * ratio), + ) +} + +function contrastColor(scheme: ThemeScheme): string { + return scheme === 'light' ? '#000000' : '#ffffff' +} + +export function getThemeScheme(): ThemeScheme { + if (!isBrowser()) return 'light' + const theme = document.documentElement.getAttribute('data-theme') + if (theme === 'dark' || theme === 'oled') return theme + return 'light' +} + +function getStoredThemeColors(): StoredThemeColors { + const raw = safeGetItem(STORAGE_KEY) + if (!raw) return {} + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return {} + } + if (typeof parsed !== 'object' || parsed === null) return {} + + const result: StoredThemeColors = {} + for (const scheme of ['light', 'dark', 'oled'] as const) { + const group = (parsed as Record)[scheme] + if (typeof group !== 'object' || group === null) continue + + const cleaned: Partial> = {} + for (const key of THEME_COLOR_KEYS) { + const value = (group as Record)[key.id] + if (typeof value === 'string') { + const normalized = normalizeThemeColor(value) + if (normalized) cleaned[key.id] = normalized + } + } + if (Object.keys(cleaned).length > 0) result[scheme] = cleaned + } + return result +} + +function writeStoredThemeColors(value: StoredThemeColors): void { + const hasAny = Object.values(value).some((group) => group && Object.keys(group).length > 0) + if (!hasAny) { + safeRemoveItem(STORAGE_KEY) + } else { + safeSetItem(STORAGE_KEY, JSON.stringify(value)) + } +} + +/** Re-apply the stored overrides for the currently-active appearance. */ +export function applyThemeColors(): void { + if (!isBrowser()) return + + const scheme = getThemeScheme() + const overrides = getStoredThemeColors()[scheme] ?? {} + const rootStyle = document.documentElement.style + + for (const key of THEME_COLOR_KEYS) { + const override = overrides[key.id] + const hex = override && isHexColor(override) ? override : null + + for (const cssVar of key.targets) { + if (hex) rootStyle.setProperty(cssVar, hex) + else rootStyle.removeProperty(cssVar) + } + + if (key.derivedTargets) { + const derived = hex && key.derive ? key.derive(hex, scheme) : {} + for (const cssVar of key.derivedTargets) { + const value = derived[cssVar] + if (value) rootStyle.setProperty(cssVar, value) + else rootStyle.removeProperty(cssVar) + } + } + } +} + +export function getThemeColorPickerValue(scheme: ThemeScheme, id: ThemeColorKeyId): string { + const override = getStoredThemeColors()[scheme]?.[id] + return override ?? DEFAULT_HEX[scheme][id] +} + +export function initializeThemeColors(): void { + if (!isBrowser()) return + + applyThemeColors() + + if (initialized) return + initialized = true + + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key === STORAGE_KEY) applyThemeColors() + }) + + const themeObserver = new MutationObserver(() => { + applyThemeColors() + }) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) +} + +export function useThemeColors(): { + scheme: ThemeScheme + keys: readonly ThemeColorKey[] + getPickerValue: (id: ThemeColorKeyId) => string + isCustomized: (id: ThemeColorKeyId) => boolean + hasAnyCustom: boolean + setColor: (id: ThemeColorKeyId, value: string) => void + resetColor: (id: ThemeColorKeyId) => void + resetAll: () => void +} { + const [scheme, setScheme] = useState(getThemeScheme) + const [overrides, setOverrides] = useState>>( + () => getStoredThemeColors()[getThemeScheme()] ?? {}, + ) + + useEffect(() => { + if (!isBrowser()) return + + const refresh = () => { + const next = getThemeScheme() + setScheme(next) + setOverrides(getStoredThemeColors()[next] ?? {}) + } + + const themeObserver = new MutationObserver(refresh) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) refresh() + } + window.addEventListener('storage', onStorage) + + return () => { + themeObserver.disconnect() + window.removeEventListener('storage', onStorage) + } + }, []) + + const setColor = useCallback((id: ThemeColorKeyId, value: string) => { + const normalized = normalizeThemeColor(value) + if (!normalized) return + + const activeScheme = getThemeScheme() + const all = getStoredThemeColors() + all[activeScheme] = { ...(all[activeScheme] ?? {}), [id]: normalized } + writeStoredThemeColors(all) + applyThemeColors() + + setScheme(activeScheme) + setOverrides(all[activeScheme] ?? {}) + }, []) + + const resetColor = useCallback((id: ThemeColorKeyId) => { + const activeScheme = getThemeScheme() + const all = getStoredThemeColors() + const group = all[activeScheme] + if (group) { + delete group[id] + if (Object.keys(group).length === 0) delete all[activeScheme] + } + writeStoredThemeColors(all) + applyThemeColors() + + setScheme(activeScheme) + setOverrides(all[activeScheme] ?? {}) + }, []) + + const resetAll = useCallback(() => { + const activeScheme = getThemeScheme() + const all = getStoredThemeColors() + delete all[activeScheme] + writeStoredThemeColors(all) + applyThemeColors() + + setScheme(activeScheme) + setOverrides({}) + }, []) + + const getPickerValue = useCallback( + (id: ThemeColorKeyId) => overrides[id] ?? DEFAULT_HEX[scheme][id], + [overrides, scheme], + ) + + const isCustomized = useCallback((id: ThemeColorKeyId) => Boolean(overrides[id]), [overrides]) + + return { + scheme, + keys: THEME_COLOR_KEYS, + getPickerValue, + isCustomized, + hasAnyCustom: Object.keys(overrides).length > 0, + setColor, + resetColor, + resetAll, + } +} diff --git a/web/src/index.css b/web/src/index.css index d874750a55..5833f9d74a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -155,6 +155,84 @@ --app-badge-error-border: rgba(248, 113, 113, 0.35); } +[data-theme="oled"] { + /* + * Pure-black canvas for OLED panels. Unlike Dark mode we never defer to + * --tg-theme-* here, otherwise Telegram's gray backgrounds would defeat the + * point of OLED black. Elevation comes from borders, not gray fills. + */ + --app-bg: #000000; + --app-fg: #f5f5f7; + --app-hint: #8e8e93; + --app-link: #4ea1ff; + --app-button: #4ea1ff; + --app-button-text: #000000; + --app-banner-bg: #1a1a1c; + --app-banner-text: #f5f5f7; + --app-secondary-bg: #0a0a0b; + + --app-dialog-bg: #0c0c0e; + --app-chat-user-bg: #141414; + --app-chat-user-surface-bg: var(--app-chat-user-bg); + --app-chat-user-fg: #f5f7fa; + --app-chat-user-chip-bg: rgba(78, 161, 255, 0.18); + --app-chat-user-chip-fg: #8fc4ff; + --app-tool-card-bg: #0e0e10; + --app-tool-group-bg: var(--app-tool-card-bg); + --app-tool-card-hover-bg: #161618; + --app-tool-card-accent: #b8c0cb; + --app-tool-card-muted-action-fg: #6b6f78; + --app-tool-card-subtitle: #9aa0aa; + --app-code-header-bg: #161618; + --app-code-header-fg: #c4cbd6; + --app-code-copy-hover-bg: rgba(255, 255, 255, 0.08); + --app-inline-code-border: transparent; + --app-inline-code-fg: #f5f7fa; + --app-md-quote-bg: #131316; + --app-md-quote-border: #3a3a40; + --app-md-quote-fg: #d7dde6; + --app-md-table-bg: #0e0e10; + --app-md-table-head-bg: #161618; + --app-reasoning-bg: #0e0e10; + --app-mermaid-node-fill: #16181d; + --app-mermaid-node-border: #4ea1ff; + --app-mermaid-cluster-fill: #101216; + --app-mermaid-text: #edf1f5; + --app-link-muted: rgba(255, 255, 255, 0.22); + --app-scrollbar-thumb: rgba(196, 203, 214, 0.24); + --app-scrollbar-thumb-hover: rgba(196, 203, 214, 0.4); + + --app-border: rgba(255, 255, 255, 0.12); + --app-divider: rgba(255, 255, 255, 0.1); + --app-subtle-bg: rgba(255, 255, 255, 0.06); + --app-code-bg: #0e0e10; + --app-inline-code-bg: #1a1a1d; + + /* Diff colors (oled) */ + --app-diff-added-bg: #07251a; + --app-diff-added-text: #c9d1d9; + --app-diff-removed-bg: #2c1217; + --app-diff-removed-text: #c9d1d9; + + /* Git status colors (reuse dark hues) */ + --app-git-staged-color: #4ade80; + --app-git-unstaged-color: #f59e0b; + --app-git-deleted-color: #f87171; + --app-git-renamed-color: #60a5fa; + --app-git-untracked-color: #9ca3af; + + /* Badge colors (reuse dark hues, lower bg opacity) */ + --app-badge-warning-bg: rgba(251, 191, 36, 0.16); + --app-badge-warning-text: #fbbf24; + --app-badge-warning-border: rgba(251, 191, 36, 0.28); + --app-badge-success-bg: rgba(74, 222, 128, 0.16); + --app-badge-success-text: #4ade80; + --app-badge-success-border: rgba(74, 222, 128, 0.28); + --app-badge-error-bg: rgba(248, 113, 113, 0.16); + --app-badge-error-text: #fca5a5; + --app-badge-error-border: rgba(248, 113, 113, 0.3); +} + html { font-size: calc(100% * var(--app-font-scale, 1)); color-scheme: light; @@ -162,7 +240,8 @@ html { scrollbar-width: thin; } -[data-theme="dark"] { +[data-theme="dark"], +[data-theme="oled"] { color-scheme: dark; } @@ -436,7 +515,9 @@ body { } html[data-theme="dark"] .shiki, -html[data-theme="dark"] .shiki span { +html[data-theme="dark"] .shiki span, +html[data-theme="oled"] .shiki, +html[data-theme="oled"] .shiki span { color: var(--shiki-dark) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; diff --git a/web/src/lib/generatedInlineMedia.test.ts b/web/src/lib/generatedInlineMedia.test.ts new file mode 100644 index 0000000000..e7155f0adb --- /dev/null +++ b/web/src/lib/generatedInlineMedia.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { generatedInlineMediaLabel, isInlineVideoMimeType } from './generatedInlineMedia' + +describe('generatedInlineMedia', () => { + it('detects inline video MIME types', () => { + expect(isInlineVideoMimeType('video/mp4')).toBe(true) + expect(isInlineVideoMimeType('video/webm')).toBe(true) + expect(isInlineVideoMimeType('image/png')).toBe(false) + expect(isInlineVideoMimeType(null)).toBe(false) + }) + + it('labels generated inline media by MIME type', () => { + expect(generatedInlineMediaLabel('video/mp4')).toBe('Generated video') + expect(generatedInlineMediaLabel('image/png')).toBe('Generated image') + }) +}) diff --git a/web/src/lib/generatedInlineMedia.ts b/web/src/lib/generatedInlineMedia.ts new file mode 100644 index 0000000000..6d8e02875d --- /dev/null +++ b/web/src/lib/generatedInlineMedia.ts @@ -0,0 +1,7 @@ +export function isInlineVideoMimeType(mimeType: string | null | undefined): boolean { + return typeof mimeType === 'string' && mimeType.startsWith('video/') +} + +export function generatedInlineMediaLabel(mimeType: string | null | undefined): 'Generated video' | 'Generated image' { + return isInlineVideoMimeType(mimeType) ? 'Generated video' : 'Generated image' +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d5f059c80c..dcaeeb20d3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -110,10 +110,23 @@ export default { 'session.item.newActivity': 'New activity', 'session.item.scheduledMessage': 'Scheduled message pending', 'session.item.scheduledMessages': '{count} scheduled messages pending', + 'session.tooltip.permission.body': 'Approve:', + 'session.tooltip.input.body': 'Reply to:', + 'session.tooltip.background.count.one': '1 task running', + 'session.tooltip.background.count.other': '{count} tasks running', + 'session.tooltip.scheduled.body': 'Will fire when due.', + 'session.tooltip.scheduled.fires': 'Fires {when}', + 'session.tooltip.scheduled.next': 'Next {when} · +{more} more', + 'session.tooltip.moreCount': '+{count} more', 'session.time.justNow': 'just now', 'session.time.minutesAgo': '{n}m ago', 'session.time.hoursAgo': '{n}h ago', 'session.time.daysAgo': '{n}d ago', + 'session.time.inLessThanMinute': 'in <1m', + 'session.time.inMinutes': 'in {n}m', + 'session.time.inHours': 'in {n}h', + 'session.time.inDays': 'in {n}d', + 'session.time.soon': 'soon', 'session.time.importedFromCodex.justNow': 'just imported from Codex', 'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago', 'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago', @@ -129,6 +142,7 @@ export default { // Session header 'session.title': 'Files', + 'session.view.returnToChat': 'Return to conversation', 'session.more': 'More actions', 'session.outline.open': 'Conversation outline', 'session.outline.close': 'Close outline', @@ -240,6 +254,8 @@ export default { 'chat.terminal': 'Terminal', 'chat.switchRemote': 'Switch to remote mode', 'chat.sendError.fallback': "Couldn't send your message. Edit and try again.", + 'chat.sendError.sessionInactive': 'This session is archived. Reopen it to send your message.', + 'chat.sendError.sessionInactive.action': 'Reopen', // Codex review 'codexReview.title': 'Codex review', @@ -269,6 +285,7 @@ export default { // Files page 'files.page.title': 'Files', 'files.page.refresh': 'Refresh', + 'files.page.refreshFilesystem': 'Refresh filesystem view', 'files.page.searchPlaceholder': 'Search files', 'files.projectRoot': 'project root', 'files.branch.detached': 'detached HEAD', @@ -295,6 +312,7 @@ export default { 'file.page.unknownPath': 'Unknown path', 'file.page.copyPath': 'Copy path', 'file.page.copyContent': 'Copy file content', + 'file.page.download': 'Download file', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': 'File', 'file.page.missingPath': 'No file path provided.', @@ -394,6 +412,7 @@ export default { 'composer.abort': 'Abort', 'composer.switchRemote': 'Switch to remote mode', 'composer.attach': 'Attach file', + 'composer.dropToAttach': 'Drop to attach', 'composer.send': 'Send', 'composer.stop': 'Stop', 'composer.voice': 'Voice assistant', @@ -464,6 +483,11 @@ export default { 'reconnecting.reason.closed': 'stream closed', 'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout', 'reconnecting.reason.visibilityRecovery': 'resuming after background', + 'pwa.update.title': 'New version available', + 'pwa.update.body': 'Reload to get the latest HAPI', + 'pwa.update.reload': 'Reload', + 'pwa.update.whyToggle': "Why can't I dismiss this?", + 'pwa.update.whyBody': 'HAPI will not reload your tab automatically while you may have an agent running, a permission waiting, or a message in progress. Running an old web build against the current server can cause sync bugs and failed actions. This banner stays visible until you reload so you are not stuck on a stale version by accident — but you choose when to tap Reload and finish what you are doing first.', // Send blocked 'send.blocked.title': 'Cannot send message', @@ -493,12 +517,26 @@ export default { 'settings.display.appearance': 'Appearance', 'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.dark': 'Dark', + 'settings.display.appearance.oled': 'OLED Black', 'settings.display.appearance.light': 'Light', + 'settings.display.themeColors.title': 'Custom colors', + 'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.', + 'settings.display.themeColors.reset': 'Reset', + 'settings.display.themeColors.resetAll': 'Reset all', + 'settings.display.themeColors.key.background': 'Background', + 'settings.display.themeColors.key.surface': 'Cards & surfaces', + 'settings.display.themeColors.key.text': 'Text', + 'settings.display.themeColors.key.hint': 'Muted text', + 'settings.display.themeColors.key.accent': 'Accent & links', + 'settings.display.themeColors.key.border': 'Borders', + 'settings.display.themeColors.key.userBubble': 'Your message bubble', 'settings.display.fontSize': 'Font Size', 'settings.display.terminalFontSize': 'Terminal Font Size', 'settings.display.sessionPreviewLimit': 'Sessions Before Folding', 'settings.display.sessionPreviewLimit.decrease': 'Show fewer sessions before folding', 'settings.display.sessionPreviewLimit.increase': 'Show more sessions before folding', + 'settings.display.activeSessionsOnly': 'Active sessions only', + 'settings.display.activeSessionsOnly.desc': 'Hide inactive sessions in the sidebar. The session you have open stays visible.', 'settings.display.sessionListStatus': 'Session list status', 'settings.display.sessionListStatus.standard': 'Standard', 'settings.display.sessionListStatus.detailed': 'Detailed', @@ -641,4 +679,21 @@ export default { 'misc.permissionRequired': 'permission required', 'misc.percentLeft': '{percent}% left', 'misc.online': 'online', + + // Web Share Target picker + 'share.title': 'Share to HAPI', + 'share.subtitle': 'Pick a session to attach this to.', + 'share.recentSessions': 'Recent active sessions', + 'share.newSession': 'New session', + 'share.discard': 'Discard', + 'share.loading': 'Loading shared content…', + 'share.notFound.title': 'Shared content not found', + 'share.notFound.body': 'This share link expired or was opened directly without a transfer.', + 'share.error.ingest': "We couldn't read the shared content. Try again from the source app.", + 'share.error.noId': 'No share id was provided. Open this page from the system share sheet.', + 'share.backToSessions': 'Back to sessions', + 'share.preview.text': 'Shared text', + 'share.preview.empty': '(empty share)', + 'share.preview.files': '{n} files', + 'share.noActiveSessions': 'No active sessions. Pick "New session" below.', } as const diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 5e63692e94..7508af1039 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -110,10 +110,23 @@ export default { 'session.item.newActivity': '有新活动', 'session.item.scheduledMessage': '有待发送的定时消息', 'session.item.scheduledMessages': '{count} 条定时消息待发送', + 'session.tooltip.permission.body': '批准:', + 'session.tooltip.input.body': '回复:', + 'session.tooltip.background.count.one': '1 个任务运行中', + 'session.tooltip.background.count.other': '{count} 个任务运行中', + 'session.tooltip.scheduled.body': '到时会自动发送。', + 'session.tooltip.scheduled.fires': '{when} 发送', + 'session.tooltip.scheduled.next': '下次 {when} · 另有 {more} 条', + 'session.tooltip.moreCount': '另有 {count} 条', 'session.time.justNow': '刚刚', 'session.time.minutesAgo': '{n} 分钟前', 'session.time.hoursAgo': '{n} 小时前', 'session.time.daysAgo': '{n} 天前', + 'session.time.inLessThanMinute': '不到 1 分钟', + 'session.time.inMinutes': '{n} 分钟后', + 'session.time.inHours': '{n} 小时后', + 'session.time.inDays': '{n} 天后', + 'session.time.soon': '即将', 'session.time.importedFromCodex.justNow': '刚刚从codex客户端导入', 'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入', 'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入', @@ -129,6 +142,7 @@ export default { // Session header 'session.title': '文件', + 'session.view.returnToChat': '返回会话', 'session.more': '更多操作', 'session.outline.open': '会话大纲', 'session.outline.close': '关闭大纲', @@ -244,6 +258,8 @@ export default { 'chat.terminal': '终端', 'chat.switchRemote': '切换到远程模式', 'chat.sendError.fallback': '消息未能发送。请修改后重试。', + 'chat.sendError.sessionInactive': '此会话已归档。请先重新打开再发送消息。', + 'chat.sendError.sessionInactive.action': '重新打开', // Codex review 'codexReview.title': 'Codex review', @@ -273,6 +289,7 @@ export default { // Files page 'files.page.title': '文件', 'files.page.refresh': '刷新', + 'files.page.refreshFilesystem': '刷新文件系统视图', 'files.page.searchPlaceholder': '搜索文件', 'files.projectRoot': '项目根目录', 'files.branch.detached': '游离 HEAD', @@ -299,6 +316,7 @@ export default { 'file.page.unknownPath': '未知路径', 'file.page.copyPath': '复制路径', 'file.page.copyContent': '复制文件内容', + 'file.page.download': '下载文件', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': '文件', 'file.page.missingPath': '未提供文件路径。', @@ -398,6 +416,7 @@ export default { 'composer.abort': '中止', 'composer.switchRemote': '切换到远程模式', 'composer.attach': '添加文件', + 'composer.dropToAttach': '松开以添加文件', 'composer.send': '发送', 'composer.stop': '停止', 'composer.voice': '语音助手', @@ -468,6 +487,11 @@ export default { 'reconnecting.reason.closed': '流连接已关闭', 'reconnecting.reason.heartbeatTimeout': '心跳超时', 'reconnecting.reason.visibilityRecovery': '后台恢复中', + 'pwa.update.title': '新版本可用', + 'pwa.update.body': '重新加载以获取最新版 HAPI', + 'pwa.update.reload': '重新加载', + 'pwa.update.whyToggle': '为什么不能关闭此提示?', + 'pwa.update.whyBody': '当你可能有正在运行的智能体、待处理的权限请求或未发送的消息时,HAPI 不会自动重新加载标签页。旧版网页与当前服务器一起运行可能导致同步错误和操作失败。此横幅会一直保持显示,直到你重新加载,以免你在不知情的情况下停留在旧版本 — 但何时点击「重新加载」由你决定,可以先完成手头的工作。', // Send blocked 'send.blocked.title': '无法发送消息', @@ -497,12 +521,26 @@ export default { 'settings.display.appearance': '外观', 'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.dark': '深色', + 'settings.display.appearance.oled': 'OLED 纯黑', 'settings.display.appearance.light': '浅色', + 'settings.display.themeColors.title': '自定义配色', + 'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。', + 'settings.display.themeColors.reset': '重置', + 'settings.display.themeColors.resetAll': '全部重置', + 'settings.display.themeColors.key.background': '背景', + 'settings.display.themeColors.key.surface': '卡片与表面', + 'settings.display.themeColors.key.text': '文字', + 'settings.display.themeColors.key.hint': '次要文字', + 'settings.display.themeColors.key.accent': '强调与链接', + 'settings.display.themeColors.key.border': '边框', + 'settings.display.themeColors.key.userBubble': '你的消息气泡', 'settings.display.fontSize': '字体大小', 'settings.display.terminalFontSize': '终端字体大小', 'settings.display.sessionPreviewLimit': '会话折叠阈值', 'settings.display.sessionPreviewLimit.decrease': '减少折叠前显示的会话数', 'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数', + 'settings.display.activeSessionsOnly': '仅显示活跃会话', + 'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。', 'settings.display.sessionListStatus': '会话列表状态', 'settings.display.sessionListStatus.standard': '标准', 'settings.display.sessionListStatus.detailed': '详细', @@ -645,4 +683,21 @@ export default { 'misc.permissionRequired': '需要权限', 'misc.percentLeft': '剩余 {percent}%', 'misc.online': '在线', + + // Web Share Target 分享面板 + 'share.title': '分享到 HAPI', + 'share.subtitle': '选择要附加到的会话。', + 'share.recentSessions': '最近的活跃会话', + 'share.newSession': '新建会话', + 'share.discard': '放弃', + 'share.loading': '正在加载分享内容…', + 'share.notFound.title': '未找到分享内容', + 'share.notFound.body': '此分享链接已过期或被直接打开而没有传输。', + 'share.error.ingest': '无法读取分享内容,请从源应用重试。', + 'share.error.noId': '未提供分享 ID,请从系统分享面板打开此页面。', + 'share.backToSessions': '返回会话列表', + 'share.preview.text': '分享文本', + 'share.preview.empty': '(空分享)', + 'share.preview.files': '{n} 个文件', + 'share.noActiveSessions': '没有活跃会话。请在下方选择"新建会话"。', } as const diff --git a/web/src/lib/pwa-update-context.tsx b/web/src/lib/pwa-update-context.tsx new file mode 100644 index 0000000000..725cfdb9d3 --- /dev/null +++ b/web/src/lib/pwa-update-context.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { usePwaUpdate } from '@/hooks/usePwaUpdate' + +type PwaUpdateContextValue = ReturnType + +const PwaUpdateContext = createContext(null) + +export function PwaUpdateProvider({ children }: { children: ReactNode }) { + const value = usePwaUpdate() + + return ( + + {children} + + ) +} + +export function usePwaUpdateContext() { + const value = useContext(PwaUpdateContext) + if (!value) { + throw new Error('usePwaUpdateContext must be used within PwaUpdateProvider') + } + return value +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a0664af7e2..e7adcfb309 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -17,6 +17,7 @@ export const queryKeys = { slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const, sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const, + sessionPiModels: (sessionId: string) => ['session-pi-models', sessionId] as const, machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const, sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, diff --git a/web/src/lib/relativeTime.ts b/web/src/lib/relativeTime.ts new file mode 100644 index 0000000000..31abbb13c8 --- /dev/null +++ b/web/src/lib/relativeTime.ts @@ -0,0 +1,22 @@ +/** + * Formats an epoch ms / s value as a localised "Nm ago" / "Nh ago" / date label. + * Accepts both ms and seconds; values smaller than 1e12 are treated as seconds. + * + * Returns `null` when the input is not finite. + */ +export function formatRelativeTime( + value: number, + t: (key: string, params?: Record) => string +): string | null { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + if (!Number.isFinite(ms)) return null + const delta = Date.now() - ms + if (delta < 60_000) return t('session.time.justNow') + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) return t('session.time.minutesAgo', { n: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) return t('session.time.hoursAgo', { n: hours }) + const days = Math.floor(hours / 24) + if (days < 7) return t('session.time.daysAgo', { n: days }) + return new Date(ms).toLocaleDateString() +} diff --git a/web/src/lib/remark-repair-tables.test.ts b/web/src/lib/remark-repair-tables.test.ts new file mode 100644 index 0000000000..abe95c00f2 --- /dev/null +++ b/web/src/lib/remark-repair-tables.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkStringify from 'remark-stringify' +import { unified } from 'unified' +import remarkRepairTables, { repairMarkdownTables } from './remark-repair-tables' + +function process(md: string): string { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRepairTables) + .use(remarkStringify) + .processSync(md) + .toString() +} + +/** + * Returns table rows from the stringified output. + * A proper table row starts with | (not \| which is escaped paragraph content). + */ +function tableRows(md: string): string[] { + return md.split('\n').filter(l => { + const t = l.trim() + return t.startsWith('|') && !t.startsWith('\\|') + }) +} + +// ── String-level function ──────────────────────────────────────────────────── + +describe('repairMarkdownTables (string)', () => { + it('pads a 2-cell separator for a 3-column header', () => { + const input = '| A | B | C |\n|---|---|\n| x | y | z |\n' + const out = repairMarkdownTables(input) + expect(out).not.toBe(input) + // Separator line should now have 3 cells + const sepLine = out.split('\n')[1] + expect(sepLine.split('|').filter(c => c.trim()).length).toBe(3) + }) + + it('pads a 1-cell separator for a 4-column header', () => { + const input = '| W | X | Y | Z |\n|---|\n| a | b | c | d |\n' + const out = repairMarkdownTables(input) + const sepLine = out.split('\n')[1] + expect(sepLine.split('|').filter(c => c.trim()).length).toBe(4) + }) + + it('returns the source unchanged when separator already matches', () => { + const input = '| A | B | C |\n|---|---|---|\n| x | y | z |\n' + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not modify separator lines not following a |-starting row', () => { + const input = 'Some prose\n|---|---|\n| x | y | z |\n' + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not modify table-like lines inside a fenced code block', () => { + const input = [ + 'Here is an example:', + '```', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '```', + '', + ].join('\n') + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not modify table-like lines inside a ~~~ fenced code block', () => { + const input = '~~~\n| A | B | C |\n|---|---|\n| x | y | z |\n~~~\n' + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not pad a valid table whose header contains a code span with a pipe', () => { + // | `a | b` | c | is a 2-column header; separator has 2 cells — valid + const input = '| `a | b` | c |\n|---|---|\n| x | y |\n' + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not close a ```` fence on a ``` line (closer must be >= opener length)', () => { + const input = [ + '````', + '```', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '```', + '````', + '', + ].join('\n') + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not close a ~~~~ fence on a ~~~ line (closer must be >= opener length)', () => { + const input = [ + '~~~~', + '~~~', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '~~~', + '~~~~', + '', + ].join('\n') + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not flip fence state when ``` appears inside a ~~~ block', () => { + const input = [ + '~~~', + '```', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '```', + '~~~', + '', + ].join('\n') + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('does not close a fence on a same-marker line that has info text', () => { + // ```ts inside a ``` fence is not a valid closing marker — only whitespace may follow + const input = [ + '```', + '```ts', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '```ts', + '```', + '', + ].join('\n') + expect(repairMarkdownTables(input)).toBe(input) + }) + + it('repairs a broken table after a fenced code block closes', () => { + const input = [ + '```', + '| A | B | C |', + '|---|---|', + '```', + '| A | B | C |', + '|---|---|', + '| x | y | z |', + ].join('\n') + const out = repairMarkdownTables(input) + // Lines inside the fence should be unchanged + expect(out.split('\n')[2]).toBe('|---|---|') + // The real table after the fence should be repaired + const sepLine = out.split('\n')[5] + expect(sepLine.split('|').filter(c => c.trim()).length).toBe(3) + }) +}) + +// ── Plugin (parse + transform + stringify) ──────────────────────────────────── + +describe('remarkRepairTables (plugin)', () => { + it('leaves a valid 3-column table unchanged', () => { + const md = '| A | B | C |\n|---|---|---|\n| x | y | z |\n' + const out = process(md) + // Must render as table rows (no escaped \|) + const rows = tableRows(out) + expect(rows.length).toBeGreaterThanOrEqual(3) + expect(out).toContain('| A |') + expect(out).toContain('| C |') + }) + + it('repairs separator with 2 cells for a 3-column header', () => { + const md = '| A | B | C |\n|---|---|\n| x | y | z |\n' + const out = process(md) + // Must render as a table — no escaped \| prefix + const rows = tableRows(out) + expect(rows.length).toBeGreaterThanOrEqual(2) + // All 3 header columns survive as proper table cells + expect(out).toContain('| A |') + expect(out).toContain('| B |') + expect(out).toContain('| C |') + }) + + it('repairs separator with 1 cell for a 4-column header', () => { + const md = '| W | X | Y | Z |\n|---|\n| a | b | c | d |\n' + const out = process(md) + expect(out).toContain('| W |') + expect(out).toContain('| Z |') + expect(tableRows(out).length).toBeGreaterThanOrEqual(2) + }) + + it('preserves alignment hints in existing separator cells', () => { + const md = '| A | B | C |\n|:---|---:|\n| x | y | z |\n' + const out = process(md) + expect(out).toContain('| C |') + const rows = tableRows(out) + expect(rows.length).toBeGreaterThanOrEqual(2) + }) + + it('does not corrupt a valid table with an escaped pipe in the header', () => { + // | A \| B | C | is a 2-column header (the \| is a literal pipe, not a delimiter) + // separator has 2 cells — valid, must not be padded to 3 + const md = '| A \\| B | C |\n|---|---|\n| x | y |\n' + const out = process(md) + const sepRow = out.split('\n').find(l => /^\|[\s|:|-]+\|$/.test(l.trim())) + expect(sepRow).toBeDefined() + expect(sepRow!.split('|').filter(c => c.trim()).length).toBe(2) + }) + + it('does not modify a table where separator already matches', () => { + const md = '| A | B |\n|---|---|\n| x | y |\n' + const out = process(md) + expect(out).toContain('| A |') + expect(out).toContain('| B |') + }) + + it('handles multiple tables — repairs broken, leaves valid untouched', () => { + const md = [ + '| A | B | C |', + '|---|---|', + '| x | y | z |', + '', + '| P | Q |', + '|---|---|', + '| 1 | 2 |', + ].join('\n') + '\n' + const out = process(md) + // First table repaired — C must be in a proper table row + expect(out).toContain('| C |') + // Second table unchanged and intact + expect(out).toContain('| P |') + expect(out).toContain('| Q |') + }) + + it('does not touch pipe characters in code spans or prose', () => { + const md = 'Use `foo | bar` for piping.\n' + const out = process(md) + expect(out).toContain('foo | bar') + }) + + it('ignores a paragraph that merely contains pipe characters', () => { + const md = 'Run: `jq \'.[] | select(.active)\'`\n' + const out = process(md) + expect(out).toContain('jq') + }) +}) diff --git a/web/src/lib/remark-repair-tables.ts b/web/src/lib/remark-repair-tables.ts new file mode 100644 index 0000000000..2d47e645ea --- /dev/null +++ b/web/src/lib/remark-repair-tables.ts @@ -0,0 +1,158 @@ +/** + * Remark plugin that repairs GFM tables where the separator row has fewer + * columns than the header row. + * + * Background: remark-gfm 4.x follows the GFM spec strictly — if the delimiter + * row has fewer cells than the header row, the entire block is degraded to a + * paragraph (no table node is produced at all). The previous approach of + * visiting `table` AST nodes could never trigger because remark-gfm never + * produced one. This version operates at the source level: it scans file.value + * for broken separator rows and pads them BEFORE remark-gfm parses, so the + * table is preserved with all columns intact. + */ + +import type { Processor } from 'unified' +import type { Root } from 'mdast' +import type { VFile } from 'vfile' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Count pipe-delimited cells in one table row line of raw source. + * Strips backtick code spans first so pipes inside them are not counted. + * Skips escaped pipes (\|) which are literal characters, not cell boundaries. */ +function countSourceCells(line: string): number { + // Replace code spans with a placeholder so any | inside them is invisible + const trimmed = line.trim().replace(/`+[^`]*?`+/g, '\x00') + const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed + const stripped = inner.endsWith('|') ? inner.slice(0, -1) : inner + let cells = 1 + let escaped = false + for (const ch of stripped) { + if (escaped) { escaped = false; continue } + if (ch === '\\') { escaped = true; continue } + if (ch === '|') cells++ + } + return cells +} + +/** Returns true if every pipe-delimited cell in the line matches the GFM separator pattern. */ +function isSeparatorLine(line: string): boolean { + const trimmed = line.trim() + if (!trimmed.includes('-')) return false + const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed + const stripped = inner.endsWith('|') ? inner.slice(0, -1) : inner + const cells = stripped.split('|') + return cells.length > 0 && cells.every(c => /^\s*:?-+:?\s*$/.test(c)) +} + +/** Count cells in a separator line (returns null if line is not a separator). */ +function countSeparatorCells(line: string): number | null { + if (!isSeparatorLine(line)) return null + const trimmed = line.trim() + const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed + const stripped = inner.endsWith('|') ? inner.slice(0, -1) : inner + return stripped.split('|').length +} + +/** + * Pad `sepLine` to have `targetCols` cells, preserving any existing alignment + * hints in the cells that are already there. Returns the repaired line, or + * null if the line already has enough cells or is not a valid separator. + */ +function padSeparatorLine(sepLine: string, targetCols: number): string | null { + const trimmed = sepLine.trim() + if (!trimmed) return null + + const hasLeading = trimmed.startsWith('|') + const hasTrailing = trimmed.endsWith('|') + + const inner = hasLeading ? trimmed.slice(1) : trimmed + const stripped = inner.endsWith('|') ? inner.slice(0, -1) : inner + const cells = stripped.split('|') + + if (cells.length >= targetCols) return null + if (!cells.every(c => /^\s*:?-+:?\s*$/.test(c))) return null + + const extra = Array(targetCols - cells.length).fill(' --- ') + const paddedInner = [...cells, ...extra].join('|') + return (hasLeading ? '|' : '') + paddedInner + (hasTrailing ? '|' : '') +} + +// ── String-level preprocessor ───────────────────────────────────────────────── + +/** + * Scan raw markdown for broken table separators and pad them in-place. + * This must run before any markdown parser sees the source, because + * remark-gfm 4.x degrades a mismatched-separator table block to a paragraph. + * + * Tracks fenced code blocks so table-like lines inside ``` or ~~~ fences are + * never modified. Also preserves leading whitespace when replacing the + * separator line so indented tables are not affected. + */ +export function repairMarkdownTables(source: string): string { + const lines = source.split('\n') + let changed = false + // Track fence character AND opening length: a ```` fence must not be closed + // by ``` (GFM §4.5: closer must match the opening marker family AND be at + // least as long). Also ignore the opposite marker family (backtick vs tilde). + let fenceChar: '`' | '~' | null = null + let fenceLength = 0 + + for (let i = 0; i < lines.length; i++) { + // Capture marker + everything after so we can check the closing-fence rule: + // openers may have an info string (```ts), but closers must be whitespace-only. + const fenceMatch = lines[i].match(/^ {0,3}(`{3,}|~{3,})(.*)$/) + if (fenceMatch) { + const ch = fenceMatch[1][0] as '`' | '~' + const len = fenceMatch[1].length + const rest = fenceMatch[2] + if (fenceChar === null) { + fenceChar = ch + fenceLength = len + } else if (ch === fenceChar && len >= fenceLength && /^\s*$/.test(rest)) { + fenceChar = null + fenceLength = 0 + } + continue + } + if (fenceChar !== null) continue + if (i === 0) continue + + const sep = lines[i] + if (!isSeparatorLine(sep)) continue + + const hdr = lines[i - 1] + // Only repair when the header row starts with | (the common LLM output form) + if (!hdr.trim().startsWith('|')) continue + + const headerCols = countSourceCells(hdr) + const sepCols = countSeparatorCells(sep) + if (sepCols === null || sepCols >= headerCols) continue + + const repaired = padSeparatorLine(sep, headerCols) + if (repaired !== null) { + // Preserve original leading whitespace so indented tables are unchanged + const prefix = sep.match(/^\s*/)?.[0] ?? '' + lines[i] = prefix + repaired + changed = true + } + } + + return changed ? lines.join('\n') : source +} + +// ── Plugin ─────────────────────────────────────────────────────────────────── + +export default function remarkRepairTables(this: Processor) { + const processor = this + return (tree: Root, file: VFile) => { + const original = String(file.value) + const repaired = repairMarkdownTables(original) + if (repaired === original) return + + // Re-parse with the repaired source so remark-gfm produces table nodes + // processor.parse() runs only the parse phase, not transformers + const newTree = processor.parse(repaired) as Root + Object.assign(tree, newTree) + } +} diff --git a/web/src/lib/scheduledTime.test.ts b/web/src/lib/scheduledTime.test.ts new file mode 100644 index 0000000000..83cd12e437 --- /dev/null +++ b/web/src/lib/scheduledTime.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + formatFutureRelativeTime, + formatScheduledFireLabel, + formatScheduledTime, + formatScheduledTooltipDetail +} from './scheduledTime' + +const t = (key: string, params?: Record) => { + const table: Record = { + 'session.time.soon': 'soon', + 'session.time.inLessThanMinute': 'in <1m', + 'session.time.inMinutes': 'in {n}m', + 'session.time.inHours': 'in {n}h', + 'session.time.inDays': 'in {n}d', + 'session.tooltip.scheduled.body': 'Will fire when due.', + 'session.tooltip.scheduled.fires': 'Fires {when}', + 'session.tooltip.scheduled.next': 'Next {when} · +{more} more', + } + let out = table[key] ?? key + if (params) { + for (const [k, v] of Object.entries(params)) { + out = out.replace(`{${k}}`, String(v)) + } + } + return out +} + +describe('formatFutureRelativeTime', () => { + it('returns in-minutes countdown for near-future timestamps', () => { + const inFive = Date.now() + 5 * 60_000 + expect(formatFutureRelativeTime(inFive, t)).toBe('in 5m') + }) + + it('returns soon for past-due timestamps', () => { + expect(formatFutureRelativeTime(Date.now() - 1_000, t)).toBe('soon') + }) +}) + +describe('formatScheduledFireLabel', () => { + it('combines relative and absolute labels', () => { + const at = Date.now() + 5 * 60_000 + const label = formatScheduledFireLabel(at, t) + expect(label).toContain('in 5m') + expect(label).toContain('·') + expect(label).toContain(formatScheduledTime(at)) + }) +}) + +describe('formatScheduledTooltipDetail', () => { + it('shows single scheduled fire time', () => { + const at = Date.now() + 5 * 60_000 + const body = formatScheduledTooltipDetail({ + futureScheduledMessageCount: 1, + nextScheduledAt: at + }, t) + expect(body).toMatch(/^Fires /) + expect(body).toContain('in 5m') + }) + + it('shows next + overflow for multiple scheduled messages', () => { + const at = Date.now() + 5 * 60_000 + const body = formatScheduledTooltipDetail({ + futureScheduledMessageCount: 3, + nextScheduledAt: at + }, t) + expect(body).toContain('Next ') + expect(body).toContain('+2 more') + }) + + it('falls back when nextScheduledAt is missing', () => { + expect(formatScheduledTooltipDetail({ + futureScheduledMessageCount: 1, + nextScheduledAt: null + }, t)).toBe('Will fire when due.') + }) +}) diff --git a/web/src/lib/scheduledTime.ts b/web/src/lib/scheduledTime.ts new file mode 100644 index 0000000000..23d6cfee2a --- /dev/null +++ b/web/src/lib/scheduledTime.ts @@ -0,0 +1,71 @@ +/** + * Formatting helpers for scheduled-send UX (queued bar + session-list clock tooltip). + */ + +/** Locale-aware absolute fire time, e.g. "Jun 16, 1:45 PM". */ +export function formatScheduledTime(scheduledAt: number): string { + const date = new Date(scheduledAt) + const now = new Date() + const opts: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + } + if (date.getFullYear() !== now.getFullYear()) { + opts.year = 'numeric' + } + return date.toLocaleString(undefined, opts) +} + +/** Relative countdown until a future epoch-ms, e.g. "in 5m". Returns null when invalid. */ +export function formatFutureRelativeTime( + value: number, + t: (key: string, params?: Record) => string +): string | null { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + if (!Number.isFinite(ms)) return null + const delta = ms - Date.now() + if (delta <= 0) return t('session.time.soon') + if (delta < 60_000) return t('session.time.inLessThanMinute') + const minutes = Math.ceil(delta / 60_000) + if (minutes < 60) return t('session.time.inMinutes', { n: minutes }) + const hours = Math.ceil(minutes / 60) + if (hours < 24) return t('session.time.inHours', { n: hours }) + const days = Math.ceil(hours / 24) + if (days < 7) return t('session.time.inDays', { n: days }) + return formatScheduledTime(ms) +} + +/** "in 5m · Jun 16, 1:45 PM" for tooltip / queued copy. */ +export function formatScheduledFireLabel( + scheduledAt: number, + t: (key: string, params?: Record) => string +): string | null { + const relative = formatFutureRelativeTime(scheduledAt, t) + if (!relative) return null + const absolute = formatScheduledTime(scheduledAt) + // When the countdown is already an absolute date (>7d), don't duplicate. + if (relative === absolute) return relative + return `${relative} · ${absolute}` +} + +/** Session-list clock tooltip body from summary fields. */ +export function formatScheduledTooltipDetail( + summary: { futureScheduledMessageCount: number; nextScheduledAt: number | null }, + t: (key: string, params?: Record) => string +): string { + if (summary.nextScheduledAt != null) { + const when = formatScheduledFireLabel(summary.nextScheduledAt, t) + if (when) { + if (summary.futureScheduledMessageCount > 1) { + return t('session.tooltip.scheduled.next', { + when, + more: summary.futureScheduledMessageCount - 1 + }) + } + return t('session.tooltip.scheduled.fires', { when }) + } + } + return t('session.tooltip.scheduled.body') +} diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index bc6c7d6e64..1db6a91bc1 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -12,8 +12,10 @@ function makeSummary(overrides: Partial & { id: string }): Sessi todoProgress: null, pendingRequestsCount: 0, pendingRequestKinds: [], + pendingRequests: [], backgroundTaskCount: 0, futureScheduledMessageCount: 0, + nextScheduledAt: null, model: null, effort: null, ...overrides diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index 637ae965db..31c5209b7a 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -129,3 +129,132 @@ describe('sessionResume', () => { }), 3)).toBe(false) }) }) + +describe('sessionResume — pi flavor', () => { + it('resolveAgentSessionIdFromMetadata returns piSessionId when flavor is pi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-123', + })).toBe('pi-sess-123') + }) + + it('resolveAgentSessionIdFromMetadata returns undefined when flavor is pi but no piSessionId', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids when flavor is pi', () => { + // Stale ids from other flavors must not satisfy a Pi resume — hub + // will reject them and the web layer would otherwise claim the + // session is resumable. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + claudeSessionId: 'claude-stale', + codexSessionId: 'codex-stale', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata prefers piSessionId over other ids when flavor is pi', () => { + // Defensive: even if a stale id slipped in, the pi id should win. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-real', + claudeSessionId: 'claude-stale', + })).toBe('pi-sess-real') + }) + + it('inactiveSessionCanResume allows resume of pi session when piSessionId is present', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + piSessionId: 'pi-sess-abc', + }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume allows fresh pi spawn when path is set and there are no messages', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume rejects inactive pi session with messages but no piSessionId (no Pi recovery fallback)', () => { + // Pi does not have a recover-from-messages path the way Claude does. + // If the cli lost the session id, the user must start a new session + // (or click resume in the cli to re-establish the id). + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume rejects pi session whose only id is a stale cross-flavor id', () => { + // Stale codexSessionId alone does NOT satisfy Pi resume. + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + codexSessionId: 'stale-codex', + }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume allows active pi session unconditionally', () => { + expect(inactiveSessionCanResume(makeSession({ + active: true, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(true) + }) +}) + +describe('sessionResume — regression for all other flavor ids', () => { + // Every flavor-specific id resolver must still work; the switch in + // sessionResume.ts grew a new 'pi' branch and the existing branches + // must not be regressed. + it('codex', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'codex', codexSessionId: 'cx-1', + })).toBe('cx-1') + }) + it('gemini', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'gemini', geminiSessionId: 'gm-1', + })).toBe('gm-1') + }) + it('opencode', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'opencode', opencodeSessionId: 'oc-1', + })).toBe('oc-1') + }) + it('cursor', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'cursor', cursorSessionId: 'cu-1', + })).toBe('cu-1') + }) + it('kimi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'kimi', kimiSessionId: 'ki-1', + })).toBe('ki-1') + }) + it('claude (default branch)', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'claude', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) + it('unknown flavor falls back to claude branch', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'mystery', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) +}) diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts index 061e08e575..5cb9fd587c 100644 --- a/web/src/lib/sessionResume.ts +++ b/web/src/lib/sessionResume.ts @@ -3,7 +3,8 @@ import type { Session } from '@/types/api' /** Agent thread id used by hub `resolveAgentResumeId`, flavor-specific. * Mirrors hub: cross-flavor ids are ignored to avoid the web layer claiming a - * session is resumable when the hub will only honor the current flavor's id. */ + * session is resumable when the hub will only honor the current flavor's id. + */ export function resolveAgentSessionIdFromMetadata( metadata: Session['metadata'] | null | undefined, ): string | undefined { @@ -17,6 +18,7 @@ export function resolveAgentSessionIdFromMetadata( case 'opencode': return metadata.opencodeSessionId ?? undefined case 'cursor': return metadata.cursorSessionId ?? undefined case 'kimi': return metadata.kimiSessionId ?? undefined + case 'pi': return metadata.piSessionId ?? undefined default: return metadata.claudeSessionId ?? undefined } } diff --git a/web/src/lib/sharePath.test.ts b/web/src/lib/sharePath.test.ts new file mode 100644 index 0000000000..03212ed84b --- /dev/null +++ b/web/src/lib/sharePath.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { shareTargetPathnameFromBase } from './sharePath' + +describe('shareTargetPathnameFromBase', () => { + it('returns /share for root base', () => { + expect(shareTargetPathnameFromBase('/')).toBe('/share') + }) + + it('returns /repo/share for subpath base', () => { + expect(shareTargetPathnameFromBase('/repo/')).toBe('/repo/share') + }) + + it('handles base without trailing slash', () => { + expect(shareTargetPathnameFromBase('/repo')).toBe('/repo/share') + }) +}) diff --git a/web/src/lib/sharePath.ts b/web/src/lib/sharePath.ts new file mode 100644 index 0000000000..8e8f62a050 --- /dev/null +++ b/web/src/lib/sharePath.ts @@ -0,0 +1,18 @@ +/** + * Web Share Target paths must respect Vite `base` so subpath deployments + * (e.g. GitHub Pages at `//`) keep the POST action inside the PWA + * scope and under the service worker's control. + */ + +const RESOLVE_ORIGIN = 'https://hapi.local/' + +/** Build the share-target pathname from an explicit Vite base (build-time). */ +export function shareTargetPathnameFromBase(baseUrl: string): string { + const normalized = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` + return new URL('share', new URL(normalized, RESOLVE_ORIGIN)).pathname +} + +/** Share-target pathname for the current bundle (`import.meta.env.BASE_URL`). */ +export function shareTargetPathname(): string { + return shareTargetPathnameFromBase(import.meta.env.BASE_URL) +} diff --git a/web/src/lib/sharePendingState.test.ts b/web/src/lib/sharePendingState.test.ts new file mode 100644 index 0000000000..0dd4420d9d --- /dev/null +++ b/web/src/lib/sharePendingState.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + SHARE_PENDING_TRANSFER_KEY, + consumeSharePendingTransfer, + setSharePendingTransfer, +} from './sharePendingState' + +afterEach(() => { + try { window.sessionStorage.clear() } catch { /* noop */ } +}) + +describe('sharePendingState', () => { + it('round-trips a transfer id and clears the slot on consume', () => { + setSharePendingTransfer('xfer-1') + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBe('xfer-1') + + const first = consumeSharePendingTransfer() + expect(first).toBe('xfer-1') + + const second = consumeSharePendingTransfer() + expect(second).toBeNull() + }) + + it('returns null when no transfer is pending', () => { + expect(consumeSharePendingTransfer()).toBeNull() + }) + + it('overwrites a stale id rather than appending', () => { + setSharePendingTransfer('a') + setSharePendingTransfer('b') + expect(consumeSharePendingTransfer()).toBe('b') + expect(consumeSharePendingTransfer()).toBeNull() + }) +}) diff --git a/web/src/lib/sharePendingState.ts b/web/src/lib/sharePendingState.ts new file mode 100644 index 0000000000..3673b54d6c --- /dev/null +++ b/web/src/lib/sharePendingState.ts @@ -0,0 +1,41 @@ +/** + * sessionStorage hand-off between the share picker (`/share`) and the + * session mount (`SessionChat`). + * + * The picker stores the IDB transfer id under this key, navigates to + * `/sessions/:id` (or `/sessions/new`), and the session mounter reads + clears + * the key on first render. sessionStorage rather than router state because: + * + * - it survives the `/sessions/new` -> `/sessions/:id` navigation that + * `NewSessionPage` performs internally with `replace: true`, which + * would drop router history state. + * - it scopes to the PWA window/tab — Android Chrome opens the share + * target in the installed PWA's own window, so collisions with other + * tabs are not a concern. + * + * The key is read **once** per mount; consume() returns the id and clears + * the slot atomically so a refresh of /sessions/:id doesn't replay the + * upload. + */ + +export const SHARE_PENDING_TRANSFER_KEY = 'hapi.share.pendingTransferId' + +export function setSharePendingTransfer(transferId: string): void { + try { + window.sessionStorage.setItem(SHARE_PENDING_TRANSFER_KEY, transferId) + } catch { + // Quota errors / disabled storage — caller proceeds without seed. + } +} + +export function consumeSharePendingTransfer(): string | null { + try { + const value = window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY) + if (value) { + window.sessionStorage.removeItem(SHARE_PENDING_TRANSFER_KEY) + } + return value + } catch { + return null + } +} diff --git a/web/src/lib/shareTransfer.test.ts b/web/src/lib/shareTransfer.test.ts new file mode 100644 index 0000000000..f9244a349f --- /dev/null +++ b/web/src/lib/shareTransfer.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildSharePayloadFromFormData, + ingestShareRequest, + type ShareTransferPayload, +} from './shareTransfer' + +describe('buildSharePayloadFromFormData', () => { + it('extracts text-only share with empty file list', async () => { + const fd = new FormData() + fd.set('title', 'My note') + fd.set('text', 'Hello world') + fd.set('url', 'https://example.com/page') + + const payload = await buildSharePayloadFromFormData(fd, 1700000000000) + + expect(payload).toEqual({ + title: 'My note', + text: 'Hello world', + url: 'https://example.com/page', + files: [], + createdAt: 1700000000000, + }) + }) + + it('falls back to empty strings when fields are missing', async () => { + const fd = new FormData() + const payload = await buildSharePayloadFromFormData(fd, 42) + + expect(payload.title).toBe('') + expect(payload.text).toBe('') + expect(payload.url).toBe('') + expect(payload.files).toEqual([]) + expect(payload.createdAt).toBe(42) + }) + + it('extracts a single image file with type', async () => { + const fd = new FormData() + const file = new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }) + fd.append('files', file) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files).toHaveLength(1) + expect(payload.files[0]).toMatchObject({ + name: 'photo.png', + type: 'image/png', + }) + expect(payload.files[0].blob).toBeInstanceOf(Blob) + }) + + it('handles multi-file shares preserving order', async () => { + const fd = new FormData() + const a = new File([new Uint8Array([1])], 'a.txt', { type: 'text/plain' }) + const b = new File([new Uint8Array([2])], 'b.pdf', { type: 'application/pdf' }) + const c = new File([new Uint8Array([3])], 'c.bin', { type: '' }) + fd.append('files', a) + fd.append('files', b) + fd.append('files', c) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files.map((f) => f.name)).toEqual(['a.txt', 'b.pdf', 'c.bin']) + // Empty mime should fall back to application/octet-stream so the + // downstream uploader doesn't choke on Content-Type: ''. + expect(payload.files[2].type).toBe('application/octet-stream') + }) + + it('ignores non-File entries under the "files" key', async () => { + const fd = new FormData() + fd.append('files', 'stringy not a file') + const file = new File([new Uint8Array([0])], 'real.txt', { type: 'text/plain' }) + fd.append('files', file) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files).toHaveLength(1) + expect(payload.files[0].name).toBe('real.txt') + }) +}) + +describe('ingestShareRequest', () => { + // jsdom/undici loses File objects when serializing FormData through + // `new Request({ body })` and re-parsing via `request.formData()`. The + // production SW only invokes Request#formData() once on the inbound + // multipart frame; tests substitute a stub that returns the FormData + // directly so the path under test (form -> payload -> put -> redirect) + // is exercised without depending on multipart roundtrip fidelity. + function makeRequest(formData: FormData): Request { + return { + formData: () => Promise.resolve(formData), + } as unknown as Request + } + + it('persists payload via the put dep and returns a /share?id=… redirect', async () => { + const fd = new FormData() + fd.set('title', 'shared') + fd.append('files', new File([new Uint8Array([7])], 'a.bin', { type: '' })) + + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockResolvedValue('xfer-abc') + + const result = await ingestShareRequest(makeRequest(fd), { + put, + now: () => 9999, + }) + + expect(put).toHaveBeenCalledTimes(1) + const arg = put.mock.calls[0][0] + expect(arg.title).toBe('shared') + expect(arg.files).toHaveLength(1) + expect(arg.createdAt).toBe(9999) + expect(result.redirectTo).toBe('/share?id=xfer-abc') + }) + + it('encodes the transfer id so it survives querystring placement', async () => { + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockResolvedValue('contains spaces & ampersands') + + const result = await ingestShareRequest(makeRequest(new FormData()), { put }) + + expect(result.redirectTo).toBe('/share?id=contains%20spaces%20%26%20ampersands') + }) + + it('propagates put rejections so the SW can fall back to error redirect', async () => { + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockRejectedValue(new Error('quota exceeded')) + + await expect( + ingestShareRequest(makeRequest(new FormData()), { put }) + ).rejects.toThrow('quota exceeded') + }) +}) diff --git a/web/src/lib/shareTransfer.ts b/web/src/lib/shareTransfer.ts new file mode 100644 index 0000000000..427b95aef6 --- /dev/null +++ b/web/src/lib/shareTransfer.ts @@ -0,0 +1,185 @@ +import { shareTargetPathname } from './sharePath' + +/** + * Share-target transfer storage. + * + * Android Chrome's Web Share Target API delivers a multipart POST to + * /share. The service worker can't hand the resulting Blob objects to the + * SPA via window state (the form POST is processed before any window + * exists), so we stash the payload in IndexedDB under a transfer id and + * 303-redirect to /share?id=. The SPA route then pulls the + * payload out. + * + * Two concerns live in this module: + * + * 1. Persistence — wraps an IDB object store (`transfers`) with a typed + * put/get/delete and an opportunistic TTL sweep. IDB is used because it + * survives the SW->document hop and accepts Blobs directly; localStorage + * is string-only and would force an expensive base64 round-trip. + * + * 2. Form parsing — `buildSharePayloadFromFormData` and `ingestShareRequest` + * are pure functions that the service worker calls. Keeping them out of + * the SW lifecycle code lets unit tests cover the multipart shape + * without spinning up a real ServiceWorkerGlobalScope. + */ + +const DB_NAME = 'hapi-share-transfers' +const DB_VERSION = 1 +const STORE = 'transfers' +export const SHARE_TRANSFER_TTL_MS = 60 * 60 * 1000 + +export type ShareTransferFile = { + name: string + type: string + blob: Blob +} + +export type ShareTransferPayload = { + title: string + text: string + url: string + files: ShareTransferFile[] + createdAt: number +} + +type StoredRecord = ShareTransferPayload & { id: string } + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id' }) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('Failed to open share-transfer DB')) + }) +} + +function tx(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest | null): Promise { + return new Promise((resolve, reject) => { + openDb().then((db) => { + const transaction = db.transaction(STORE, mode) + const store = transaction.objectStore(STORE) + const request = run(store) + transaction.oncomplete = () => { + db.close() + resolve(request ? request.result : null) + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer tx failed')) + } + transaction.onabort = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer tx aborted')) + } + }, reject) + }) +} + +export async function putShareTransfer(payload: ShareTransferPayload): Promise { + const id = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + const record: StoredRecord = { id, ...payload } + await tx('readwrite', (store) => store.put(record)) + return id +} + +export async function getShareTransfer(id: string): Promise { + const record = await tx('readonly', (store) => store.get(id)) + if (!record) return null + const { id: _id, ...payload } = record + return payload +} + +export async function deleteShareTransfer(id: string): Promise { + await tx('readwrite', (store) => store.delete(id)) +} + +export async function cleanupExpiredShareTransfers(now: number = Date.now()): Promise { + return new Promise((resolve, reject) => { + openDb().then((db) => { + const transaction = db.transaction(STORE, 'readwrite') + const store = transaction.objectStore(STORE) + const cursorReq = store.openCursor() + let removed = 0 + cursorReq.onsuccess = () => { + const cursor = cursorReq.result + if (!cursor) return + const value = cursor.value as StoredRecord + if (now - value.createdAt > SHARE_TRANSFER_TTL_MS) { + cursor.delete() + removed++ + } + cursor.continue() + } + transaction.oncomplete = () => { + db.close() + resolve(removed) + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer cleanup failed')) + } + }, reject) + }) +} + +/** + * Pure form-data -> payload conversion. Exposed for unit tests. + * + * The Web Share Target manifest declares `title`, `text`, `url`, and a + * `files` part. Android Chrome sometimes omits parts the source app didn't + * supply, so each text field falls back to '' and `files` filters out + * non-File entries (string parts named 'files' have been observed when an + * app shares text-only). + */ +export async function buildSharePayloadFromFormData( + formData: FormData, + now: number = Date.now() +): Promise { + const title = stringField(formData, 'title') + const text = stringField(formData, 'text') + const url = stringField(formData, 'url') + const fileEntries = formData.getAll('files').filter((entry): entry is File => entry instanceof File) + const files: ShareTransferFile[] = fileEntries.map((file) => ({ + name: file.name, + type: file.type || 'application/octet-stream', + blob: file + })) + return { title, text, url, files, createdAt: now } +} + +function stringField(formData: FormData, name: string): string { + const value = formData.get(name) + return typeof value === 'string' ? value : '' +} + +export type ShareIngestDeps = { + put: (payload: ShareTransferPayload) => Promise + now?: () => number +} + +export type ShareIngestResult = { redirectTo: string } + +/** + * Service-worker entry point. Reads the multipart form, persists it via the + * injected `put` (defaulting to IndexedDB in production), and returns the + * relative URL to redirect to. The 303 status that converts the POST into + * a GET is set by the SW caller. + */ +export async function ingestShareRequest( + request: Request, + deps: ShareIngestDeps +): Promise { + const now = deps.now ? deps.now() : Date.now() + const formData = await request.formData() + const payload = await buildSharePayloadFromFormData(formData, now) + const id = await deps.put(payload) + const sharePath = shareTargetPathname() + return { redirectTo: `${sharePath}?id=${encodeURIComponent(id)}` } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 569e77efd0..6c5aee2e02 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,7 +4,6 @@ import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import './index.css' -import { registerSW } from 'virtual:pwa-register' import { initializeFontScale } from '@/hooks/useFontScale' import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' import { queryClient } from './lib/query-client' @@ -52,27 +51,6 @@ async function bootstrap() { restoreSpaRedirect() } - const updateSW = registerSW({ - onNeedRefresh() { - if (confirm('New version available! Reload to update?')) { - updateSW(true) - } - }, - onOfflineReady() { - console.log('App ready for offline use') - }, - onRegistered(registration) { - if (registration) { - setInterval(() => { - registration.update() - }, 60 * 60 * 1000) - } - }, - onRegisterError(error) { - console.error('SW registration error:', error) - } - }) - const history = isTelegram ? createMemoryHistory({ initialEntries: [getInitialPath()] }) : undefined diff --git a/web/src/router.tsx b/web/src/router.tsx index 84e030a531..84222fef70 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -10,6 +10,7 @@ import { useMatchRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router' import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' @@ -32,6 +33,7 @@ import { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' import { useSendMessage, type SendErrorInfo } from '@/hooks/mutations/useSendMessage' import type { ComposerSendError } from '@/components/AssistantChat/HappyComposer' +import { ApiError } from '@/api/client' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' @@ -45,6 +47,9 @@ import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' import SettingsPage from '@/routes/settings' +import SharePage from '@/routes/share' +import { setSharePendingTransfer } from '@/lib/sharePendingState' +import { deleteShareTransfer } from '@/lib/shareTransfer' function BackIcon(props: { className?: string }) { return ( @@ -574,20 +579,27 @@ function SessionsIndexPage() { } /** - * Extract a user-facing message from a thrown send error. - * `request` in the api client throws plain `Error` for !res.ok, with the - * format `"HTTP : "` -- we surface the message as - * a single line and fall back to a localized default when nothing usable is - * present (e.g. an aborted fetch that resolved with no message). + * Classify a thrown send error into a {message, code} pair the composer can + * render. `code` lets the consumer attach a recovery affordance (Reopen on + * `session_inactive`) without re-inspecting the raw error. + * + * `request` in the api client throws `ApiError` for !res.ok with `status` + * and `code` parsed from the JSON body. Older / non-JSON failures arrive as + * plain `Error`; we surface those by their message verbatim, falling back to + * a localized default when nothing usable is present (e.g. an aborted fetch + * that resolved with no message). */ -function deriveSendErrorMessage( +function classifySendError( error: unknown, t: (key: string) => string, -): string { +): { message: string; code: string | null } { + if (error instanceof ApiError && error.status === 409 && error.code === 'session_inactive') { + return { message: t('chat.sendError.sessionInactive'), code: 'session_inactive' } + } if (error instanceof Error && error.message) { - return error.message + return { message: error.message, code: null } } - return t('chat.sendError.fallback') + return { message: t('chat.sendError.fallback'), code: null } } function SessionPage() { @@ -598,6 +610,7 @@ function SessionPage() { const queryClient = useQueryClient() const { addToast } = useToast() const { sessionId } = useParams({ from: '/sessions/$sessionId' }) + const { outline } = useSearch({ from: '/sessions/$sessionId' }) const { session, error: sessionError, @@ -629,9 +642,22 @@ function SessionPage() { // text into the OLD session's composer and the next render would clear // it. The bumped `id` still lets the composer dedupe restorations of // identical text. - const [sendErrors, setSendErrors] = useState>({}) + // + // We persist the classifier `code` (not the bound action) so the + // composer-visible action stays reactive to `reopeningSessionId` state + // changes -- the action is built fresh on each render from {raw error + // record} x {current reopen state}. See classifySendError + the + // Reopen affordance below. + type RawSendError = { + id: number + text: string + message: string + code: string | null + scheduledAt: number | null + } + const [sendErrors, setSendErrors] = useState>({}) + const [reopeningSessionId, setReopeningSessionId] = useState(null) const sendErrorIdRef = useRef(0) - const sendError = sendErrors[sessionId] ?? null const clearSendError = useCallback(() => { setSendErrors((prev) => { if (!(sessionId in prev)) return prev @@ -641,6 +667,67 @@ function SessionPage() { }) }, [sessionId]) + // Reopen recovery (#918): one-click affordance attached to the inline + // composer error when the rejected send was inactive-session. Mirrors + // SessionList's Reopen UX -- POST /sessions/:id/reopen via + // api.reopenSession -- so the operator's mental model is consistent + // across surfaces. We do NOT auto-replay the send: per #917 the reopen + // path has known fragility, so the operator re-clicks Send on the + // restored composer text once Reopen lands. + const reopenFromErrorAffordance = useCallback((errorSessionId: string) => { + if (!api) return + setReopeningSessionId((prev) => prev ?? errorSessionId) + void (async () => { + try { + const result = await api.reopenSession(errorSessionId) + // Clear the inline error -- the operator now has a live + // session to retry against. + setSendErrors((prev) => { + if (!(errorSessionId in prev)) return prev + const next = { ...prev } + delete next[errorSessionId] + return next + }) + await queryClient.invalidateQueries({ queryKey: queryKeys.session(result.sessionId) }) + await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + if (result.sessionId && result.sessionId !== errorSessionId) { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: result.sessionId }, + replace: true + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : t('dialog.error.default') + addToast({ + title: t('resume.failed.title'), + body: message, + sessionId: errorSessionId, + url: '' + }) + } finally { + setReopeningSessionId(null) + } + })() + }, [api, queryClient, navigate, addToast, t]) + + const rawSendError = sendErrors[sessionId] ?? null + const sendError: ComposerSendError | null = rawSendError + ? { + id: rawSendError.id, + text: rawSendError.text, + message: rawSendError.message, + scheduledAt: rawSendError.scheduledAt, + action: rawSendError.code === 'session_inactive' + ? { + label: t('chat.sendError.sessionInactive.action'), + onClick: () => reopenFromErrorAffordance(sessionId), + pending: reopeningSessionId === sessionId + } + : null + } + : null + const { sendMessage, retryMessage, @@ -662,12 +749,14 @@ function SessionPage() { }, onError: (info: SendErrorInfo) => { sendErrorIdRef.current += 1 + const { message, code } = classifySendError(info.error, t) setSendErrors((prev) => ({ ...prev, [info.sessionId]: { id: sendErrorIdRef.current, text: info.text, - message: deriveSendErrorMessage(info.error, t), + message, + code, scheduledAt: info.scheduledAt } })) @@ -677,7 +766,15 @@ function SessionPage() { return currentSessionId } if (!inactiveSessionCanResume(session, messages.length)) { - throw new Error(t('resume.unavailable.noTarget')) + // #918: surface as a session_inactive ApiError so the + // onError consumer's classifier renders the Reopen + // affordance. `status: 409` mirrors the hub guard for + // structural parity; no HTTP call was made. + throw new ApiError( + t('chat.sendError.sessionInactive'), + 409, + 'session_inactive', + ) } try { return await api.resumeSession(currentSessionId, { permissionMode: session.permissionMode ?? undefined }) @@ -689,7 +786,14 @@ function SessionPage() { sessionId: currentSessionId, url: '' }) - throw error + // Rebrand as a session_inactive ApiError so the inline + // affordance offers Reopen (a separate code path from the + // failed Resume) and the operator has a recovery click. + throw new ApiError( + t('chat.sendError.sessionInactive'), + 409, + 'session_inactive', + ) } }, onSessionResolved: (resolvedSessionId) => { @@ -754,6 +858,14 @@ function SessionPage() { void refetchMessages() }, [refetchMessages, refetchSession]) + const handleInitialOutlineConsumed = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + replace: true, + }) + }, [navigate, sessionId]) + if (!session) { if (sessionError) { return ( @@ -771,7 +883,7 @@ function SessionPage() { @@ -810,6 +922,8 @@ function SessionPage() { availableSlashCommands={slashCommands} sendError={sendError} onClearSendError={clearSendError} + initialOutlineOpen={outline} + onInitialOutlineConsumed={handleInitialOutlineConsumed} /> ) } @@ -848,13 +962,19 @@ function NewSessionPage() { const queryClient = useQueryClient() const { machines, isLoading: machinesLoading, error: machinesError } = useMachines(api, true) const { t } = useTranslation() - const { directory: initialDirectory, machineId: initialMachineId } = newSessionRoute.useSearch() + const { directory: initialDirectory, machineId: initialMachineId, shareTransferId } = newSessionRoute.useSearch() const handleCancel = useCallback(() => { + if (shareTransferId) { + void deleteShareTransfer(shareTransferId) + } navigate({ to: '/sessions' }) - }, [navigate]) + }, [navigate, shareTransferId]) const handleSuccess = useCallback((sessionId: string) => { + if (shareTransferId) { + setSharePendingTransfer(shareTransferId) + } void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) // Replace current page with /sessions to clear spawn flow from history navigate({ to: '/sessions', replace: true }) @@ -865,18 +985,19 @@ function NewSessionPage() { params: { sessionId }, }) }) - }, [navigate, queryClient]) + }, [navigate, queryClient, shareTransferId]) const handleChooseFolder = useCallback((args: { machineId: string | null; directory: string }) => { // Forward the currently-selected machine so /browse opens scoped to // it rather than falling back to `hapi:lastMachineId`, which can // disagree if the user changed machines without yet creating a - // session. - navigate({ - to: '/browse', - search: args.machineId ? { machineId: args.machineId } : {} - }) - }, [navigate]) + // session. Preserve shareTransferId so a share-target spawn that + // detours through /browse still seeds the composer after success. + const search: { machineId?: string; shareTransferId?: string } = {} + if (args.machineId) search.machineId = args.machineId + if (shareTransferId) search.shareTransferId = shareTransferId + navigate({ to: '/browse', search }) + }, [navigate, shareTransferId]) return (
@@ -924,14 +1045,16 @@ function BrowsePage() { const goBack = useAppGoBack() const { machines, isLoading: machinesLoading } = useMachines(api, true) const { t } = useTranslation() - const { machineId: initialMachineId } = browseRoute.useSearch() + const { machineId: initialMachineId, shareTransferId } = browseRoute.useSearch() const handleStartSession = useCallback((machineId: string, directory: string) => { navigate({ to: '/sessions/new', - search: { directory, machineId } + search: shareTransferId + ? { directory, machineId, shareTransferId } + : { directory, machineId } }) - }, [navigate]) + }, [navigate, shareTransferId]) return (
@@ -986,6 +1109,10 @@ const sessionsIndexRoute = createRoute({ const sessionDetailRoute = createRoute({ getParentRoute: () => sessionsRoute, path: '$sessionId', + validateSearch: (search: Record): { outline?: boolean } => { + const outline = search.outline === true || search.outline === 'true' + return outline ? { outline: true } : {} + }, component: SessionDetailRoute, }) @@ -1050,6 +1177,7 @@ const sessionFileRoute = createRoute({ type NewSessionSearch = { directory?: string machineId?: string + shareTransferId?: string } const newSessionRoute = createRoute({ @@ -1063,6 +1191,9 @@ const newSessionRoute = createRoute({ if (typeof search.machineId === 'string' && search.machineId) { result.machineId = search.machineId } + if (typeof search.shareTransferId === 'string' && search.shareTransferId) { + result.shareTransferId = search.shareTransferId + } return result }, component: NewSessionPage, @@ -1071,11 +1202,15 @@ const newSessionRoute = createRoute({ const browseRoute = createRoute({ getParentRoute: () => rootRoute, path: '/browse', - validateSearch: (search: Record): { machineId?: string } => { + validateSearch: (search: Record): { machineId?: string; shareTransferId?: string } => { + const result: { machineId?: string; shareTransferId?: string } = {} if (typeof search.machineId === 'string' && search.machineId) { - return { machineId: search.machineId } + result.machineId = search.machineId } - return {} + if (typeof search.shareTransferId === 'string' && search.shareTransferId) { + result.shareTransferId = search.shareTransferId + } + return result }, component: BrowsePage, }) @@ -1086,6 +1221,25 @@ const settingsRoute = createRoute({ component: SettingsPage, }) +// Web Share Target landing route. Service worker (`web/src/sw.ts`) +// intercepts the manifest's `POST /share` and 303-redirects here with an +// IDB transfer id. `error=ingest` is set when the SW failed to write IDB. +const shareRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/share', + validateSearch: (search: Record): { id?: string; error?: string } => { + const result: { id?: string; error?: string } = {} + if (typeof search.id === 'string' && search.id) { + result.id = search.id + } + if (typeof search.error === 'string' && search.error) { + result.error = search.error + } + return result + }, + component: SharePage, +}) + export const routeTree = rootRoute.addChildren([ indexRoute, sessionsRoute.addChildren([ @@ -1099,6 +1253,7 @@ export const routeTree = rootRoute.addChildren([ ]), browseRoute, settingsRoute, + shareRoute, ]) type RouterHistory = Parameters[0]['history'] diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index a232740491..ab2b89d961 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -36,6 +36,44 @@ function decodePath(value: string): string { return decoded.ok ? decoded.text : value } +function DownloadIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function triggerDownload(fileName: string, base64Content: string, mimeType: string | null) { + const byteChars = atob(base64Content) + const byteArray = new Uint8Array(byteChars.length) + for (let i = 0; i < byteChars.length; i++) { + byteArray[i] = byteChars.charCodeAt(i) + } + const blob = new Blob([byteArray], { type: mimeType ?? 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + function BackIcon(props: { className?: string }) { return ( 0 && contentSizeBytes <= MAX_COPYABLE_FILE_BYTES + const canDownload = fileContentResult?.success === true && Boolean(fileContentResult.content) + const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') useEffect(() => { @@ -260,6 +300,16 @@ export default function FilePage() { > {pathCopied ? : } + {canDownload ? ( + + ) : null}
diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index f8c698d76d..0d253239d7 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -1,8 +1,10 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import type { FileSearchItem, GitFileStatus } from '@/types/api' import { FileIcon } from '@/components/FileIcon' import { DirectoryTree } from '@/components/SessionFiles/DirectoryTree' +import { SessionHeader } from '@/components/SessionHeader' +import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles' @@ -19,25 +21,6 @@ import { queryKeys } from '@/lib/query-keys' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from '@/lib/use-translation' -function BackIcon(props: { className?: string }) { - return ( - - - - ) -} - function RefreshIcon(props: { className?: string }) { return ( (null) const initialTab = search.tab === 'directories' ? 'directories' : 'changes' const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab) + useEffect(() => { + const el = scrollRef.current + if (!el) return + const key = SCROLL_KEY_PREFIX + sessionId + try { + const saved = sessionStorage.getItem(key) + if (saved !== null) el.scrollTop = Number(saved) + } catch { + // ignore + } + return () => { + try { + sessionStorage.setItem(key, String(el.scrollTop)) + } catch { + // ignore + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + const { status: gitStatus, error: gitError, @@ -279,7 +285,6 @@ export default function FilesPage() { }, [activeTab, navigate, sessionId]) const branchLabel = getDetachedBranchLabel(gitStatus?.branch, t) - const subtitle = session?.metadata?.path ?? sessionId const showGitErrorBanner = Boolean(gitError) const gitErrorMessage = useMemo( () => (gitError ? formatGitStatusError(gitError, t) : null), @@ -323,45 +328,71 @@ export default function FilesPage() { }) }, [navigate, sessionId]) + const handleToggleFiles = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + }) + }, [navigate, sessionId]) + + const handleToggleOutline = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + search: { outline: true }, + }) + }, [navigate, sessionId]) + + if (!session) { + return ( +
+ +
+ ) + } + return (
-
-
- -
-
{t('files.page.title')}
-
{subtitle}
-
- -
-
+ { + navigate({ + to: '/sessions/$sessionId/files', + params: { sessionId: newSessionId }, + replace: true, + }) + }} + />
-
-
- +
+
+ setSearchQuery(event.target.value)} placeholder={t('files.page.searchPlaceholder')} - className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" autoCapitalize="none" autoCorrect="off" />
+
@@ -411,7 +442,7 @@ export default function FilesPage() {
) : null} -
+
{showGitErrorBanner && activeTab === 'changes' ? (
@@ -441,6 +472,7 @@ export default function FilesPage() { ) ) : activeTab === 'directories' ? ( string }) { + const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors() + + return ( +
+
+ {props.t('settings.display.themeColors.title')} + {hasAnyCustom && ( + + )} +
+
{props.t('settings.display.themeColors.description')}
+
+ {keys.map((key) => { + const value = getPickerValue(key.id) + const customized = isCustomized(key.id) + return ( +
+ {props.t(key.labelKey)} +
+ {customized && ( + + )} + +
+
+ ) + })} +
+
+ ) +} + export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const { api } = useAppContext() @@ -338,6 +399,7 @@ export default function SettingsPage() { const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() + const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() const { toolGroupBackground, userMessageBackground, @@ -733,6 +795,7 @@ export default function SettingsPage() {
)}
+
+
+ ) + } + + const { payload } = load + + return ( +
+
+
+
+
{t('share.title')}
+ +
+
+ {t('share.subtitle')} +
+
+
+ +
+
+ + +
+
+ {t('share.recentSessions')} +
+ {pickerSessions === null ? ( + + ) : pickerSessions.length === 0 ? ( +
+ {t('share.noActiveSessions')} +
+ ) : ( +
    + {pickerSessions.map((session) => ( +
  • + +
  • + ))} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/web/src/sw.ts b/web/src/sw.ts index ebe55dc0a7..4ed4847914 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -3,6 +3,14 @@ import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' +import { + cleanupExpiredShareTransfers, + ingestShareRequest, + putShareTransfer, +} from './lib/shareTransfer' +import { shareTargetPathname } from './lib/sharePath' + +const sharePath = shareTargetPathname() declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array @@ -91,6 +99,16 @@ registerRoute( }) ) +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + self.addEventListener('push', (event) => { const payload = event.data?.json() as PushPayload | undefined if (!payload) { @@ -121,3 +139,41 @@ self.addEventListener('notificationclick', (event) => { const url = data?.url ?? '/' event.waitUntil(self.clients.openWindow(url)) }) + +// Web Share Target — manifest declares POST /share, Android Chrome posts a +// multipart form with title/text/url/files. Stash in IDB so the SPA route +// can read it after the 303 redirect (which converts POST -> GET). +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'POST') return + const url = new URL(request.url) + if (url.pathname !== sharePath) return + + event.respondWith(handleShareTarget(request)) +}) + +async function handleShareTarget(request: Request): Promise { + // Resolve to absolute URLs because Response.redirect throws on relative + // input per the Fetch spec; Chrome currently tolerates relative paths + // but the SW spec is explicit and the cost of resolving is one line. + const origin = self.location.origin + try { + const { redirectTo } = await ingestShareRequest(request, { put: putShareTransfer }) + return Response.redirect(new URL(redirectTo, origin).toString(), 303) + } catch (error) { + // Surface a minimal page if IDB write fails — don't 5xx silently or + // the user gets a Chrome error sheet instead of useful UI. + console.error('share-target ingest failed', error) + return Response.redirect(new URL(`${sharePath}?error=ingest`, origin).toString(), 303) + } +} + +// Best-effort GC for stale share transfers (TTL-only — never blocks +// anything else). 1h TTL is set in shareTransfer.ts. +self.addEventListener('activate', (event) => { + event.waitUntil( + cleanupExpiredShareTransfers().catch((error) => { + console.warn('share-transfer cleanup failed', error) + }) + ) +}) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b9f5d0f762..89846a75b2 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -28,6 +28,9 @@ export type { OpencodeModelsResponse, OpencodeModelSummary, PathExistsResponse, + PiModelSummary, + PiModelsResponse, + PiThinkingLevelMap, SlashCommand, SlashCommandsResponse, SessionResponse, @@ -43,6 +46,8 @@ export type { Metadata, PermissionMode, Machine, + PendingRequest, + PendingRequestKind, RunnerState, Session, SessionPatch, diff --git a/web/vite.config.ts b/web/vite.config.ts index 58ab142268..2f2e29b64d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { shareTargetPathnameFromBase } from './src/lib/sharePath' const base = process.env.VITE_BASE_URL || '/' +const shareAction = shareTargetPathnameFromBase(base) const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' const appVersion = readAppVersion() @@ -65,7 +67,8 @@ export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', + // User-controlled reload avoids mid-session surprise reloads (autoUpdate reloads all tabs). + registerType: 'prompt', includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], strategies: 'injectManifest', srcDir: 'src', @@ -99,7 +102,38 @@ export default defineConfig({ type: 'image/png', purpose: 'any' } - ] + ], + // Web Share Target — Android Chrome routes POSTs to /share + // when the user picks HAPI in the system share sheet. The + // service worker (`web/src/sw.ts`) intercepts POST /share, + // stashes the multipart payload in IndexedDB, and 303- + // redirects to /share?id= for the SPA picker. + // `*/*` is the broad fallback; explicit MIME prefixes stay + // first because some Chrome versions only honor declared + // prefixes when surfacing in the share sheet. + share_target: { + action: shareAction, + method: 'POST', + enctype: 'multipart/form-data', + params: { + title: 'title', + text: 'text', + url: 'url', + files: [ + { + name: 'files', + accept: [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/json', + 'application/zip', + '*/*' + ] + } + ] + } + } }, injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}']