diff --git a/cli/README.md b/cli/README.md index a9729204..0cc2f01c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -43,6 +43,7 @@ Choose a supported coding agent from your terminal and control its sessions remo DSH is remote-only and its ACP server must be configured separately. - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. +- `hapi spawn-peer --dir PATH --name TITLE --message-file -` - Spawn a session and deliver a required first message. Optional `--machine ID|hostname` targets another runner (resolved via GET /api/machines); `--dir` must exist on the target. Machine spawn HTTP 200 is not a working peer; this command fails if the remit does not land. Prefer MCP `spawn_peer`. - `hapi usage [--since 24h|7d||] [--until …] [--json]` - When agents were working across every machine on the hub, from `GET /api/activity`. `--json` prints one `hapi.activity.v1` report for other tools (time trackers); errors go to stderr with exit codes 2 (arguments), 3 (no token or refused), 4 (hub too old), 1 (other). - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. - `hapi set-mode ` - Change the permission mode of an existing session, like the web permission picker. Active sessions switch immediately; inactive ones use it on next resume. An invalid mode prints the modes allowed for that agent. @@ -276,6 +277,11 @@ composition owns the overall permission policy. For peer discovery and messaging, use the session's MCP `list_peers`, `inspect_peer`, and `ping_peer` tools, or the corresponding CLI commands. +To create a new peer with work attached, use MCP `spawn_peer` or `hapi spawn-peer`. +The first message is required and verified in the transcript before success; +machine-spawn HTTP 200 alone does not prove the task was delivered. In-session +spawns add a Parent `[title](/sessions/)` citation so parent renames do not +change its identity. On a remote runner host, configure the matching hub URL and token so shell commands reach the same hub (`hapi auth login` saves the token). diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index c46248d8..d24f780f 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -19,7 +19,8 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID'; * `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling. * To discover / read / message another session, prefer MCP `list_peers` / * `inspect_peer` / `ping_peer` (or `hapi ping-peer --list` / `inspect-peer` / - * `ping-peer`) - do not reinvent JWT+curl. + * `ping-peer`). To spawn a new peer with work, prefer MCP `spawn_peer` or + * `hapi spawn-peer` - do not reinvent JWT+curl or stuff `message` into machine spawn. * * For lazy Codex sessions the id must only be exported after the hub row is * materialized — exporting the provisional id early makes GET /api/sessions/:id diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index 7872f535..b81ffe26 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -76,7 +76,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer', 'skill_lookup'], stop: harness.stopServer } }) @@ -169,7 +169,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup', + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,spawn_peer,skill_lookup', '--hapi-session-id', 'hub-session-acp-1', ]) diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index dd242018..066e1d06 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -152,8 +152,8 @@ export async function runClaude(options: StartOptions = {}): Promise { logger.debug('[start] Failed to update session metadata:', error); }); - // Start HAPI MCP server - const happyServer = await startHappyServer(session); + // Start HAPI MCP server (pass session cwd so spawn_peer "." anchors here) + const happyServer = await startHappyServer(session, { workingDirectory }); logger.debug(`[START] HAPI MCP server started at ${happyServer.url}`); const formatFailureReason = (message: string): string => { diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index e8c27b7f..fc456ce6 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ApiSessionClient } from '@/api/apiSession' import * as peerMessages from '@/modules/pingPeer/pingPeer' +import * as peerSpawn from '@/modules/spawnPeer/spawnPeer' import { startHappyServer, toClaudeAllowedHapiMcpTools } from './startHappyServer' type ToolResult = { @@ -45,6 +46,8 @@ describe('startHappyServer skill_lookup', () => { async function connect(enableSkillLookup = true): Promise { sendAgentMessage = vi.fn() const sessionClient = { + sessionId: 'parent-id', + getMetadata: () => ({ name: 'Parent', path: workingDirectory, flavor: 'codex' }), updateMetadata: vi.fn(), sendAgentMessage, sendClaudeSessionMessage: vi.fn() @@ -114,6 +117,7 @@ describe('startHappyServer skill_lookup', () => { 'display_video', 'display_media', 'ping_peer', + 'spawn_peer', 'inspect_peer', 'list_peers' ]) @@ -177,6 +181,42 @@ describe('startHappyServer skill_lookup', () => { } }) + it('forwards cross-machine spawn settings with the calling session identity and cwd', async () => { + const spawn = vi.spyOn(peerSpawn, 'spawnPeer').mockResolvedValue({ + sessionId: 'child-id', name: 'Worker' + }) + try { + const mcp = await connect(false) + const result = await mcp.callTool({ + name: 'spawn_peer', + arguments: { + directory: '/srv/project', + message: 'Run the tests', + machine: 'remote-host', + agent: 'codex', + model: 'gpt-5.6-sol', + effort: 'high', + permissionMode: 'default', + }, + }) as ToolResult + expect(result.isError).toBe(false) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + directory: '/srv/project', + cwd: workingDirectory, + message: 'Run the tests', + machine: 'remote-host', + agent: 'codex', + model: 'gpt-5.6-sol', + effort: 'high', + permissionMode: 'default', + parent: { sessionId: 'parent-id', name: 'Parent', agentSessionId: null }, + requireParent: true, + })) + } finally { + spawn.mockRestore() + } + }) + it('displays audio through display_media and emits a generated media message', async () => { const path = join(sandboxDir, 'sample.wav') await writeFile(path, Buffer.from('RIFFxxxxWAVE')) @@ -231,12 +271,13 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']) + expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer']) expect(tools.tools.map((tool) => tool.name)).toEqual([ 'display_image', 'display_video', 'display_media', 'ping_peer', + 'spawn_peer', 'inspect_peer', 'list_peers' ]) @@ -416,6 +457,7 @@ describe('toClaudeAllowedHapiMcpTools', () => { 'list_peers', 'ping_peer', 'inspect_peer', + 'spawn_peer', 'skill_lookup' ])).toEqual([ 'mcp__hapi__change_title', @@ -428,6 +470,42 @@ describe('toClaudeAllowedHapiMcpTools', () => { }) }) +describe('resolveMcpSpawnPeerCwd', () => { + it('prefers launcher workingDirectory over skillLookup and session path', async () => { + const { resolveMcpSpawnPeerCwd } = await import('./startHappyServer') + expect(resolveMcpSpawnPeerCwd({ + workingDirectory: '/launcher/cwd', + skillWorkingDirectory: '/skill/cwd', + sessionPath: '/session/path', + })).toBe('/launcher/cwd') + }) + + it('prefers skillLookup workingDirectory over session metadata path', async () => { + const { resolveMcpSpawnPeerCwd } = await import('./startHappyServer') + expect(resolveMcpSpawnPeerCwd({ + skillWorkingDirectory: '/skill/cwd', + sessionPath: '/session/path', + })).toBe('/skill/cwd') + }) + + it('falls back to session metadata.path when skillLookup is absent', async () => { + const { resolveMcpSpawnPeerCwd } = await import('./startHappyServer') + expect(resolveMcpSpawnPeerCwd({ + skillWorkingDirectory: undefined, + sessionPath: '/session/path', + })).toBe('/session/path') + expect(resolveMcpSpawnPeerCwd({ + skillWorkingDirectory: ' ', + sessionPath: ' /session/path ', + })).toBe('/session/path') + }) + + it('returns undefined when neither cwd source is available', async () => { + const { resolveMcpSpawnPeerCwd } = await import('./startHappyServer') + expect(resolveMcpSpawnPeerCwd({})).toBeUndefined() + }) +}) + describe('startHappyServer peer tools kill switch', () => { const originalFlag = process.env.HAPI_DISABLE_PEER_TOOLS let client: Client | null @@ -462,10 +540,10 @@ describe('startHappyServer peer tools kill switch', () => { it('registers peer tools by default', async () => { delete process.env.HAPI_DISABLE_PEER_TOOLS const { toolNames } = await startServer() - expect(toolNames).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + expect(toolNames).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer'])) const tools = await client!.listTools() const names = tools.tools.map((tool) => tool.name) - expect(names).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + expect(names).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer'])) }) it('omits peer tools from registration and toolNames when HAPI_DISABLE_PEER_TOOLS is set', async () => { @@ -477,6 +555,7 @@ describe('startHappyServer peer tools kill switch', () => { expect(names).not.toContain('list_peers') expect(names).not.toContain('ping_peer') expect(names).not.toContain('inspect_peer') + expect(names).not.toContain('spawn_peer') expect(names).toEqual(expect.arrayContaining(['display_image', 'display_video', 'display_media'])) }) }) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 176fb9f6..8fdbd63b 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -22,15 +22,20 @@ import { import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; import { resolveSkill } from "@/modules/common/skills"; +import { SESSION_NAME_MAX_LENGTH, toSessionSummaryMetadata } from '@hapi/protocol' import { isPeerToolsDisabled } from "@/modules/common/peerTools"; import { INSPECT_PEER_TOOL_DESCRIPTION, PEER_DELIVERY_MODE_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, + SPAWN_PEER_TOOL_DESCRIPTION, } from '@hapi/protocol/sessionCitation' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol/modes' +import { PermissionModeSchema } from '@hapi/protocol/schemas' import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspectPeer, listPeerSessions, peerListFetchLimit, pingPeer } from "@/modules/pingPeer/pingPeer"; import { applySessionDisplayRename, normalizeSessionDisplayTitle } from "@/agent/sessionDisplayRename"; +import { SpawnPeerError, spawnPeer } from "@/modules/spawnPeer/spawnPeer"; type StartHappyServerOptions = { /** @@ -40,24 +45,50 @@ type StartHappyServerOptions = { */ emitTitleSummary?: boolean; enableChangeTitle?: boolean; + /** + * Session project cwd for resolving relative `spawn_peer` directories. + * Prefer the launcher's effective cwd (including Codex `--cd` overrides). + * Falls back to `skillLookup.workingDirectory`, then hub metadata.path. + */ + workingDirectory?: string; skillLookup?: { workingDirectory: string; flavor: string; }; }; +/** + * Resolve the base cwd for MCP spawn_peer relative directories. + * Prefer launcher workingDirectory (Codex --cd), then skillLookup, then + * session metadata.path so bridges without skillLookup still anchor to the + * session tree instead of the long-lived HAPI process cwd. + */ +export function resolveMcpSpawnPeerCwd(options: { + workingDirectory?: string | null + skillWorkingDirectory?: string | null + sessionPath?: string | null +}): string | undefined { + const fromLauncher = (options.workingDirectory ?? '').trim() + if (fromLauncher) return fromLauncher + const fromSkill = (options.skillWorkingDirectory ?? '').trim() + if (fromSkill) return fromSkill + const fromSession = (options.sessionPath ?? '').trim() + return fromSession || undefined +} + /** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */ const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set([ 'display_media', 'display_video', 'ping_peer', - 'inspect_peer' + 'inspect_peer', + 'spawn_peer' ]); /** * Map HAPI MCP tool names to Claude `--allowedTools` entries. - * Keeps `display_media` / `display_video` (arbitrary local-path readers), `ping_peer`, and - * `inspect_peer` off the auto-allow list so they still prompt. + * Keeps `display_media` / `display_video` (arbitrary local-path readers), `ping_peer`, + * `inspect_peer`, and `spawn_peer` off the auto-allow list so they still prompt. * `list_peers` stays allowed (discovery shortlist only). */ export function toClaudeAllowedHapiMcpTools(toolNames: string[]): string[] { @@ -70,7 +101,8 @@ function createHapiMcpServer( client: ApiSessionClient, emitTitleSummary: boolean, enableChangeTitle: boolean, - skillLookup: StartHappyServerOptions['skillLookup'] + skillLookup: StartHappyServerOptions['skillLookup'], + workingDirectory: string | undefined ): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); @@ -138,6 +170,31 @@ function createHapiMcpServer( deliveryMode: PeerDeliveryModeSchema.optional().describe(PEER_DELIVERY_MODE_DESCRIPTION), }); + const spawnPeerInputSchema: z.ZodTypeAny = z.object({ + directory: z.string().trim().min(1) + .describe('Working directory for the new session on the target machine (must exist there)'), + message: z.string().min(1).describe('Required first user message (the remit). Empty spawn is a failed spawn.'), + name: z.string().trim().min(1).max(SESSION_NAME_MAX_LENGTH).optional().describe('Session display name'), + machine: z.string().trim().min(1).optional() + .describe( + 'Optional target runner: hub machine UUID or hostname (metadata.host / displayName from GET /api/machines). ' + + 'Omit to spawn on this host.' + ), + agent: z.enum(CREATABLE_AGENT_FLAVORS as unknown as [string, ...string[]]).optional() + .describe('Agent flavor override. When omitted, uses hub peerSpawnDefaults then stock claude.'), + model: z.string().trim().min(1).optional() + .describe('Model override for the resolved agent flavor.'), + effort: z.string().trim().min(1).optional() + .describe('Effort override (flavor-dependent).'), + sessionType: z.enum(['simple', 'worktree']).optional() + .describe('simple or worktree. Default simple (use directory as cwd). worktree creates a new tree from directory.'), + permissionMode: PermissionModeSchema.optional() + .describe( + 'Permission mode for the new session. Omit to use hub/stock default (yolo). ' + + 'Pass only to tighten or when the operator names a mode — do not clone the parent session.' + ), + }); + const maxInlineMediaBytes = 25 * 1024 * 1024; // HAPI_DISABLE_PEER_TOOLS=1: peer tools are neither registered nor @@ -364,6 +421,75 @@ function createHapiMcpServer( } }); + mcp.registerTool('spawn_peer', { + description: SPAWN_PEER_TOOL_DESCRIPTION, + title: 'Spawn Peer Session', + inputSchema: spawnPeerInputSchema, + }, async (args: { + directory: string + message: string + name?: string + machine?: string + agent?: string + model?: string + effort?: string + sessionType?: 'simple' | 'worktree' + permissionMode?: string + }) => { + logger.debug('[hapiMCP] spawn_peer:', args.directory, args.machine ? `machine=${args.machine}` : ''); + try { + const metadata = client.getMetadata() + const summaryMeta = toSessionSummaryMetadata(metadata) + const result = await spawnPeer({ + directory: args.directory, + cwd: resolveMcpSpawnPeerCwd({ + workingDirectory, + skillWorkingDirectory: skillLookup?.workingDirectory, + sessionPath: metadata?.path, + }), + message: args.message, + name: args.name, + machine: args.machine, + agent: args.agent as Parameters[0]['agent'], + model: args.model, + effort: args.effort, + sessionType: args.sessionType, + permissionMode: args.permissionMode as Parameters[0]['permissionMode'], + parent: { + sessionId: client.sessionId, + name: metadata?.name ?? null, + agentSessionId: summaryMeta?.agentSessionId ?? null, + }, + requireParent: true, + }); + return { + content: [ + { + type: 'text' as const, + text: `Spawned ${result.sessionId} (${result.name})`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof SpawnPeerError + ? error.message + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] spawn_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to spawn peer: ${message}`, + }, + ], + isError: true, + }; + } + }); + mcp.registerTool('inspect_peer', { description: INSPECT_PEER_TOOL_DESCRIPTION, title: 'Inspect Peer Session', @@ -404,7 +530,7 @@ function createHapiMcpServer( }); mcp.registerTool('list_peers', { - description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', + description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id, or spawn_peer to create a new peer with a remit.', title: 'List Peer Sessions', inputSchema: listPeersInputSchema, }, async (args: { limit?: number }) => { @@ -514,7 +640,13 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH const mcps = new Map(); const createMcpTransport = () => { - const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup); + const mcp = createHapiMcpServer( + client, + emitTitleSummary, + enableChangeTitle, + options.skillLookup, + options.workingDirectory + ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { @@ -574,7 +706,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH 'display_image', 'display_video', 'display_media', - ...(peerToolsDisabled ? [] : (['list_peers', 'ping_peer', 'inspect_peer'] as const)), + ...(peerToolsDisabled ? [] : (['list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer'] as const)), ]; if (options.skillLookup) { toolNames.push('skill_lookup'); diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 1ed7241e..8a49b1d0 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -17,6 +17,7 @@ const BASE_SYSTEM_PROMPT = (() => trimIdent(` inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', listPeersTool: 'mcp__hapi__list_peers', + spawnTool: 'mcp__hapi__spawn_peer', })} `))(); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 64a6bc70..587f9324 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -89,8 +89,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const cwdOverride = parseCodexCliOverrides(session.codexArgs).cwd; const effectiveCodexCwd = cwdOverride ? resolve(session.path, cwdOverride) : session.path; - // Start hapi hub for MCP bridge (same as remote mode) - const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); + // Start hapi hub for MCP bridge (same as remote mode). Pass effective cwd so + // spawn_peer "." resolves under Codex --cd, not the HAPI process cwd. + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + workingDirectory: effectiveCodexCwd, + skillLookup: { workingDirectory: effectiveCodexCwd, flavor: 'codex' }, + }); logger.debug(`[codex-local]: Started hapi MCP bridge server at ${happyServer.url}`); const reportTranscriptSyncFailure = (transcriptPath: string, error: unknown): void => { diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 33b72e0a..66d13d75 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -3375,7 +3375,10 @@ describe('codexRemoteLauncher', () => { await codexRemoteLauncher(session as never); - expect(harness.bridgeOptions).toEqual([{ emitTitleSummary: false }]); + expect(harness.bridgeOptions).toEqual([{ + emitTitleSummary: false, + workingDirectory: '/tmp/hapi-update', + }]); expect(summaryMessages).toEqual([]); expect(getMetadata().name).toBe('Parent Title'); expect(codexMessages).toContainEqual(expect.objectContaining({ diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index e60ba76c..16df36ca 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -3596,7 +3596,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { // leak into the parent HAPI session. Defer the side effect until // parent-thread mcp_tool_call_end reaches this launcher; child // events are filtered above by thread id. - emitTitleSummary: false + emitTitleSummary: false, + workingDirectory: session.path, }); this.happyServer = happyServer; let mcpServers: CodexMcpServersConfig = hapiMcpServers; diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index 6b54af07..a8930dd7 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -170,6 +170,23 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { 'inspect_peer' ]) }) + it('registers spawn_peer when included in --tools', async () => { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'change_title,display_image,display_video,display_media,spawn_peer' + ]) + + expect([...harness.tools.keys()]).toEqual([ + 'change_title', + 'display_image', + 'display_video', + 'display_media', + 'spawn_peer' + ]) + }) + it('registers list_peers when included in --tools', async () => { await runHappyMcpStdioBridge([ '--url', @@ -197,7 +214,7 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,list_peers,ping_peer,inspect_peer' + 'change_title,list_peers,ping_peer,inspect_peer,spawn_peer' ]) expect([...harness.tools.keys()]).toEqual(['change_title']) @@ -218,6 +235,7 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { 'display_video', 'display_media', 'ping_peer', + 'spawn_peer', 'inspect_peer', 'list_peers' ]) diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index 3788905a..7ff0218d 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -1,7 +1,7 @@ /** * HAPI MCP STDIO Bridge * - * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`. + * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, `inspect_peer`, and `spawn_peer`. * On invocation it forwards the tool call to an existing HAPI HTTP MCP server * using the StreamableHTTPClientTransport. * @@ -23,10 +23,14 @@ import { PEER_DELIVERY_MODE_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, + SPAWN_PEER_TOOL_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; +import { SESSION_NAME_MAX_LENGTH } from '@hapi/protocol'; +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol/modes'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { isPeerToolsDisabled, PEER_TOOL_NAMES } from '@/modules/common/peerTools'; -const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; +const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer']; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; @@ -242,6 +246,56 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + const spawnPeerInputSchema: z.ZodTypeAny = z.object({ + directory: z.string().trim().min(1) + .describe('Working directory for the new session on the target machine (must exist there)'), + message: z.string().min(1).describe('Required first user message (the remit). Empty spawn is a failed spawn.'), + name: z.string().trim().min(1).max(SESSION_NAME_MAX_LENGTH).optional().describe('Session display name'), + machine: z.string().trim().min(1).optional() + .describe( + 'Optional target runner: hub machine UUID or hostname (metadata.host / displayName from GET /api/machines). ' + + 'Omit to spawn on this host.' + ), + agent: z.enum(CREATABLE_AGENT_FLAVORS as unknown as [string, ...string[]]).optional() + .describe('Agent flavor override. When omitted, uses hub peerSpawnDefaults then stock claude.'), + model: z.string().trim().min(1).optional() + .describe('Model override for the resolved agent flavor.'), + effort: z.string().trim().min(1).optional() + .describe('Effort override (flavor-dependent).'), + sessionType: z.enum(['simple', 'worktree']).optional() + .describe('simple or worktree. Default simple (use directory as cwd). worktree creates a new tree from directory.'), + permissionMode: PermissionModeSchema.optional() + .describe( + 'Permission mode for the new session. Omit to use hub/stock default (yolo). ' + + 'Pass only to tighten or when the operator names a mode — do not clone the parent session.' + ), + }); + + if (toolNames.has('spawn_peer')) { + server.registerTool( + 'spawn_peer', + { + description: SPAWN_PEER_TOOL_DESCRIPTION, + title: 'Spawn Peer Session', + inputSchema: spawnPeerInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'spawn_peer', arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { type: 'text' as const, text: `Failed to spawn peer: ${error instanceof Error ? error.message : String(error)}` }, + ], + isError: true, + }; + } + } + ); + } + const inspectPeerInputSchema: z.ZodTypeAny = z.object({ sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION), messageLimit: z.number().int().min(1).max(100).optional().describe( @@ -284,7 +338,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { server.registerTool( 'list_peers', { - description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', + description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id, or spawn_peer to create a new peer with a remit.', title: 'List Peer Sessions', inputSchema: listPeersInputSchema, }, diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index efba2583..ad66b538 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ return { url: 'http://127.0.0.1:43006/', toolNames: options.skillLookup - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'], + ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer', 'skill_lookup'] + : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer'], stop: vi.fn() } }) @@ -64,6 +64,8 @@ describe('buildHapiMcpBridge skill lookup config', () => { expect(harness.startOptions).toEqual({ emitTitleSummary: undefined, + enableChangeTitle: undefined, + workingDirectory: '/repo', skillLookup }) expect(harness.cliArgs).toEqual([ @@ -71,7 +73,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup', + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,spawn_peer,skill_lookup', '--hapi-session-id', 'hub-session-1', ]) @@ -93,7 +95,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer', + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,spawn_peer', '--hapi-session-id', 'hub-session-1', ]) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 712f48b8..bcab157e 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -49,6 +49,8 @@ export interface HapiMcpBridgeOptions { exportSessionEnv?: boolean; emitTitleSummary?: boolean; enableChangeTitle?: boolean; + /** Session project cwd for relative spawn_peer directories (Codex --cd aware). */ + workingDirectory?: string; skillLookup?: { workingDirectory: string; flavor: string; @@ -83,6 +85,7 @@ export async function buildHapiMcpBridge( const happyServer = await startHappyServer(client, { emitTitleSummary: options.emitTitleSummary, enableChangeTitle: options.enableChangeTitle, + workingDirectory: options.workingDirectory ?? options.skillLookup?.workingDirectory, skillLookup: options.skillLookup }); // Stamp the hub row id so orphanReap can find MCP helpers after the agent @@ -119,9 +122,9 @@ export async function buildHapiMcpBridge( approval_mode: 'approve' }; } - // ping_peer / inspect_peer are registered on the HTTP MCP server / stdio - // bridge, but are not auto-approved: they target another session (resume + - // inject, or read peer histories). + // ping_peer / inspect_peer / spawn_peer are registered on the HTTP MCP + // server / stdio bridge, but are not auto-approved: they target another + // session (resume + inject, read peer histories, or create + inject). if (options.skillLookup) { tools.skill_lookup = { approval_mode: 'approve' diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index 71d74583..8ed6b658 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -29,6 +29,7 @@ export const TITLE_INSTRUCTION = trimIdent(` inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', listPeersTool: 'functions.hapi__list_peers', + spawnTool: 'functions.hapi__spawn_peer', })} `); diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index fe49283f..8fa70881 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -21,6 +21,7 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' import { inspectPeerCommand } from './inspectPeer' +import { spawnPeerCommand } from './spawnPeer' import { usageCommand } from './usage' import { setModeCommand } from './setMode' import type { CommandContext, CommandDefinition } from './types' @@ -65,6 +66,7 @@ const COMMANDS: CommandDefinition[] = [ notifyCommand, pingPeerCommand, inspectPeerCommand, + spawnPeerCommand, setModeCommand, usageCommand ] diff --git a/cli/src/commands/spawnPeer.test.ts b/cli/src/commands/spawnPeer.test.ts new file mode 100644 index 00000000..7912592f --- /dev/null +++ b/cli/src/commands/spawnPeer.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { SpawnPeerError } from '@/modules/spawnPeer/spawnPeer' +import { handleSpawnPeerCommand, parseSpawnPeerArgs } from './spawnPeer' + +describe('parseSpawnPeerArgs', () => { + it('parses --dir, --name, and positional message', () => { + expect(parseSpawnPeerArgs([ + '--dir', '/tmp/project', + '--name', 'Peer', + 'hello remit' + ])).toEqual({ + help: false, + directory: '/tmp/project', + name: 'Peer', + message: 'hello remit' + }) + }) + + it('parses --message-file, --agent, --session-type, and --wait', () => { + expect(parseSpawnPeerArgs([ + '--dir', '/tmp/wt', + '--name', 'Peer', + '--message-file', 'brief.md', + '--agent', 'cursor', + '--session-type', 'worktree', + '--wait', '30' + ])).toEqual({ + help: false, + directory: '/tmp/wt', + name: 'Peer', + messageFile: 'brief.md', + agent: 'cursor', + sessionType: 'worktree', + waitActiveSecs: 30 + }) + }) + + it('parses --machine UUID or hostname', () => { + expect(parseSpawnPeerArgs([ + '--dir', '/tmp/wt', + '--name', 'Peer', + '--machine', 'oos-llm-lab', + 'hello remit' + ])).toEqual({ + help: false, + directory: '/tmp/wt', + name: 'Peer', + machine: 'oos-llm-lab', + message: 'hello remit' + }) + expect(parseSpawnPeerArgs([ + '--dir=/tmp/wt', + '--name=Peer', + '--machine=5f5a87e8-25b2-4732-ba4c-aba95f695bd7', + 'hello' + ]).machine).toBe('5f5a87e8-25b2-4732-ba4c-aba95f695bd7') + }) + + it('parses --help', () => { + expect(parseSpawnPeerArgs(['--help']).help).toBe(true) + }) + + it('rejects unknown flags', () => { + expect(() => parseSpawnPeerArgs(['--host', 'evil'])).toThrow(SpawnPeerError) + }) + + it('rejects --session-type other than simple|worktree', () => { + expect(() => parseSpawnPeerArgs([ + '--dir', '/tmp/x', + '--name', 'P', + '--session-type', 'relocate' + ])).toThrow(SpawnPeerError) + }) +}) + +describe('handleSpawnPeerCommand', () => { + it('rejects --name over 255 characters before touching the hub', async () => { + await expect(handleSpawnPeerCommand([ + '--dir', '/tmp/project', + '--name', 'n'.repeat(256), + 'do the work' + ])).rejects.toMatchObject({ code: 'bad_args' }) + }) +}) diff --git a/cli/src/commands/spawnPeer.ts b/cli/src/commands/spawnPeer.ts new file mode 100644 index 00000000..fac9619a --- /dev/null +++ b/cli/src/commands/spawnPeer.ts @@ -0,0 +1,363 @@ +import { readFile } from 'node:fs/promises' +import chalk from 'chalk' +import { SESSION_NAME_MAX_LENGTH } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS, type AgentFlavor, type PermissionMode } from '@hapi/protocol/modes' +import { PermissionModeSchema } from '@hapi/protocol/schemas' +import { initializeToken } from '@/ui/tokenInit' +import { + SpawnPeerError, + exitCodeForSpawnPeerError, + spawnPeer +} from '@/modules/spawnPeer/spawnPeer' +import type { CommandDefinition } from './types' + +type ParsedSpawnPeerArgs = { + help: boolean + directory?: string + name?: string + message?: string + messageFile?: string + agent?: AgentFlavor + model?: string + effort?: string + sessionType?: 'simple' | 'worktree' + permissionMode?: PermissionMode + machine?: string + waitActiveSecs?: number +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi spawn-peer')} - Spawn a HAPI session and deliver a required first message + +${chalk.bold('Usage:')} + hapi spawn-peer --dir PATH --name TITLE --message-file - + hapi spawn-peer --dir PATH --name TITLE --message-file + hapi spawn-peer --dir PATH --name TITLE + +${chalk.bold('Notes:')} + Machine spawn (POST /api/machines/:id/spawn) creates an empty composer. + Do not put the remit in the spawn JSON - the hub 400s message/prompt/text. + This command spawns, optionally renames, delivers via the ping-peer path, + then exits non-zero if the new session still has no user message. + Same hub token/namespace as this CLI. Prefer MCP spawn_peer in-session. + When HAPI_SESSION_ID is set, the remit is auto-stamped with a durable + Parent [title](/sessions/) chip (rename-proof identity). + +${chalk.bold('Options:')} + --dir PATH Working directory on the target machine (required; relative paths resolve here, not in the runner) + --name TITLE Session display name (required, 1-255 chars) + --message-file PATH|- Remit text (or - for stdin) + --machine ID|hostname Target runner (UUID or hostname from GET /api/machines; default: this host) + --agent NAME Agent flavor (default: hub peerSpawnDefaults, else claude) + --model ID Model override for the resolved agent flavor + --effort LEVEL Effort override (flavor-dependent; e.g. claude high) + --session-type TYPE simple | worktree (default: simple; worktree creates a new tree from PATH) + --permission-mode MODE Optional; omit = hub/stock default (yolo). Pass to tighten or when named; not cloned from parent + --wait SECONDS Active/verify timeout (default 60, or HAPI_WAIT_ACTIVE_SECS) + + Cross-machine: --dir must exist on the TARGET host. Local existence is not required when + --machine resolves to a different runner than this CLI's settings.machineId. + +${chalk.bold('Env:')} + HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) + HAPI_WAIT_ACTIVE_SECS (default 60; overridable with --wait) + HAPI_SESSION_ID / HAPI_SESSION_NAME / HAPI_AGENT_SESSION_ID (Parent stamp when set) +`) +} + +export function parseSpawnPeerArgs(args: string[]): ParsedSpawnPeerArgs { + const result: ParsedSpawnPeerArgs = { + help: false + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--dir') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--dir requires a path') + } + result.directory = value + continue + } + if (arg.startsWith('--dir=')) { + const value = arg.slice('--dir='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--dir requires a path') + } + result.directory = value + continue + } + if (arg === '--name') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--name requires a title') + } + result.name = value + continue + } + if (arg.startsWith('--name=')) { + const value = arg.slice('--name='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--name requires a title') + } + result.name = value + continue + } + if (arg === '--message-file') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--message-file requires a path (or - for stdin)') + } + result.messageFile = value + continue + } + if (arg.startsWith('--message-file=')) { + const value = arg.slice('--message-file='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--message-file requires a path (or - for stdin)') + } + result.messageFile = value + continue + } + if (arg === '--agent') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--agent requires a flavor') + } + result.agent = parseAgent(value) + continue + } + if (arg.startsWith('--agent=')) { + result.agent = parseAgent(arg.slice('--agent='.length)) + continue + } + if (arg === '--model') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--model requires a model id') + } + result.model = value + continue + } + if (arg.startsWith('--model=')) { + const value = arg.slice('--model='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--model requires a model id') + } + result.model = value + continue + } + if (arg === '--effort') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--effort requires a level') + } + result.effort = value + continue + } + if (arg.startsWith('--effort=')) { + const value = arg.slice('--effort='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--effort requires a level') + } + result.effort = value + continue + } + if (arg === '--session-type') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--session-type requires simple or worktree') + } + result.sessionType = parseSessionType(value) + continue + } + if (arg.startsWith('--session-type=')) { + result.sessionType = parseSessionType(arg.slice('--session-type='.length)) + continue + } + if (arg === '--permission-mode') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--permission-mode requires a mode') + } + result.permissionMode = parsePermissionMode(value) + continue + } + if (arg.startsWith('--permission-mode=')) { + result.permissionMode = parsePermissionMode(arg.slice('--permission-mode='.length)) + continue + } + if (arg === '--machine') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--machine requires a UUID or hostname') + } + result.machine = value + continue + } + if (arg.startsWith('--machine=')) { + const value = arg.slice('--machine='.length) + if (!value) { + throw new SpawnPeerError('bad_args', '--machine requires a UUID or hostname') + } + result.machine = value + continue + } + if (arg === '--wait') { + const value = args[++i] + if (!value) { + throw new SpawnPeerError('bad_args', '--wait requires seconds') + } + result.waitActiveSecs = Number(value) + continue + } + if (arg.startsWith('--wait=')) { + result.waitActiveSecs = Number(arg.slice('--wait='.length)) + continue + } + if (arg.startsWith('-')) { + throw new SpawnPeerError('bad_args', `unexpected flag: ${arg}`) + } + if (result.message === undefined) { + result.message = arg + continue + } + throw new SpawnPeerError('bad_args', `unexpected arg: ${arg}`) + } + + if (result.waitActiveSecs !== undefined && (!Number.isFinite(result.waitActiveSecs) || result.waitActiveSecs <= 0)) { + throw new SpawnPeerError('bad_args', '--wait must be a positive number of seconds') + } + + return result +} + +function parseAgent(value: string): AgentFlavor { + if (!(CREATABLE_AGENT_FLAVORS as readonly string[]).includes(value)) { + throw new SpawnPeerError( + 'bad_args', + `unsupported --agent '${value}' (expected ${CREATABLE_AGENT_FLAVORS.join(', ')})` + ) + } + return value as AgentFlavor +} + +function parseSessionType(value: string): 'simple' | 'worktree' { + if (value !== 'simple' && value !== 'worktree') { + throw new SpawnPeerError('bad_args', '--session-type must be simple or worktree') + } + return value +} + +function parsePermissionMode(value: string): PermissionMode { + const parsed = PermissionModeSchema.safeParse(value) + if (!parsed.success) { + throw new SpawnPeerError('bad_args', `unsupported --permission-mode '${value}'`) + } + return parsed.data +} + +async function readMessage(parsed: ParsedSpawnPeerArgs): Promise { + if (parsed.messageFile !== undefined) { + if (parsed.message !== undefined) { + throw new SpawnPeerError('bad_args', 'provide message as an argument or --message-file, not both') + } + if (parsed.messageFile === '-') { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf8') + } + return await readFile(parsed.messageFile, 'utf8') + } + return parsed.message ?? '' +} + +function envWaitActiveSecs(): number | undefined { + const raw = process.env.HAPI_WAIT_ACTIVE_SECS + if (!raw) { + return undefined + } + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) { + throw new SpawnPeerError('bad_args', 'HAPI_WAIT_ACTIVE_SECS must be a positive number') + } + return value +} + +export async function handleSpawnPeerCommand(args: string[]): Promise { + const parsed = parseSpawnPeerArgs(args) + if (parsed.help) { + showHelp() + return + } + + if (!parsed.directory) { + showHelp() + throw new SpawnPeerError('bad_args', 'missing --dir; usage: hapi spawn-peer --dir PATH --name TITLE --message-file -') + } + const name = parsed.name?.trim() ?? '' + if (!name || name.length > SESSION_NAME_MAX_LENGTH) { + showHelp() + throw new SpawnPeerError( + 'bad_args', + `--name must be 1-${SESSION_NAME_MAX_LENGTH} characters` + ) + } + + await initializeToken() + + const message = await readMessage(parsed) + if (!message.trim()) { + throw new SpawnPeerError( + 'bad_args', + 'missing message; provide as arg, --message-file PATH, or --message-file -' + ) + } + + const result = await spawnPeer({ + directory: parsed.directory, + message, + name, + agent: parsed.agent, + model: parsed.model, + effort: parsed.effort, + sessionType: parsed.sessionType, + permissionMode: parsed.permissionMode, + machine: parsed.machine, + waitActiveSecs: parsed.waitActiveSecs ?? envWaitActiveSecs(), + onProgress: (line) => console.log(`hapi spawn-peer: ${line}`) + }) + + console.log(chalk.green(`hapi spawn-peer: OK ${result.sessionId} name="${result.name}"`)) +} + +export const spawnPeerCommand: CommandDefinition = { + name: 'spawn-peer', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handleSpawnPeerCommand(commandArgs) + } catch (error) { + if (error instanceof SpawnPeerError) { + console.error(chalk.red('hapi spawn-peer:'), error.message) + process.exit(exitCodeForSpawnPeerError(error)) + } + console.error( + chalk.red('hapi spawn-peer:'), + error instanceof Error ? error.message : 'Unknown error' + ) + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts index 18c15888..c2ea2c1a 100644 --- a/cli/src/modules/common/peerTools.ts +++ b/cli/src/modules/common/peerTools.ts @@ -1,18 +1,18 @@ /** * Kill switch for the peer-session MCP tools (`list_peers` / `inspect_peer` / - * `ping_peer`). + * `ping_peer` / `spawn_peer`). * * Set `HAPI_DISABLE_PEER_TOOLS=1` in the runner environment to stop wrapped - * agents from browsing or messaging other HAPI sessions (peer pollution): + * agents from browsing, messaging, or spawning other HAPI sessions: * the tools are not registered on the session MCP server, not advertised via * `--allowedTools` / `--tools`, and the peer steer block is dropped from * flavor system prompts. The core module refuses as defense in depth, so - * `hapi ping-peer` / `inspect-peer` from inside a wrapped agent shell fails + * `hapi ping-peer` / `inspect-peer` / `spawn-peer` from inside a wrapped agent shell fails * loudly instead of silently bypassing the switch. Operators who keep the * variable unset in their interactive shell keep full CLI access. */ -export const PEER_TOOL_NAMES = ['list_peers', 'ping_peer', 'inspect_peer'] as const; +export const PEER_TOOL_NAMES = ['list_peers', 'ping_peer', 'inspect_peer', 'spawn_peer'] as const; export function isPeerToolsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { return ['true', '1', 'yes'].includes((env.HAPI_DISABLE_PEER_TOOLS ?? '').toLowerCase()); diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 6febd9a4..ae4350a6 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -62,6 +62,26 @@ describe('resolveToolAutoApprovalDecision ping_peer', () => { }) }) +describe('resolveToolAutoApprovalDecision spawn_peer', () => { + it.each([ + 'spawn_peer', + 'mcp__hapi__spawn_peer', + 'hapi_spawn_peer', + 'Spawn Peer Session' + ])('does not auto-approve %s in default mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull() + }) + + it.each([ + 'spawn_peer', + 'mcp__hapi__spawn_peer', + 'hapi_spawn_peer', + 'Spawn Peer Session' + ])('does not auto-approve %s in read-only mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('read-only', toolName, 'call-1')).toBeNull() + }) +}) + describe('resolveToolAutoApprovalDecision inspect_peer', () => { it.each([ 'inspect_peer', diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 860c5426..679efe75 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -39,17 +39,20 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ // ACP permission requests often surface MCP tool title, not the snake_case name. 'list peer sessions' ]); -// ping_peer / inspect_peer intentionally omitted from always-approve: they can -// resume+inject into another session or read peer histories, so permission -// modes must still gate them. Treat both as write-like in read-only so ACP -// titles such as "Ping Peer Session" / "Inspect Peer Session" also require -// approval. list_peers is discovery-only and is auto-approved above. +// ping_peer / inspect_peer / spawn_peer intentionally omitted from always-approve: +// they can resume+inject into another session, read peer histories, or create a +// new session with an injected remit, so permission modes must still gate them. +// Treat them as write-like in read-only so ACP titles such as "Ping Peer Session" +// / "Inspect Peer Session" / "Spawn Peer Session" also require approval. +// list_peers is discovery-only and is auto-approved above. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; const SENSITIVE_TOOL_NAME_HINTS = [ 'ping_peer', 'ping peer', 'inspect_peer', 'inspect peer', + 'spawn_peer', + 'spawn peer', ]; const AUTO_APPROVE_WRITE_TOOL_HINTS = [ 'write', diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index 23a26770..7c1dfa93 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -347,6 +347,117 @@ describe('pingPeer', () => { }) }) + it('delivers to a known sessionId without listing sessions', async () => { + const sessionId = 'dddddddd-1111-1111-1111-111111111111' + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(body).toEqual({ text: 'remit', localId: expect.stringMatching(/^ping-peer-/) }) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(sessionId)) { + throw new Error('list must not be called when sessionId is known') + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { + session: { + id: sessionId, + active: true, + metadata: { name: 'Spawned', flavor: 'cursor' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionId, + message: 'remit', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + expect(result).toEqual({ + sessionId, + name: 'Spawned', + resumed: false, + delivery: 'accepted', + localId: expect.stringMatching(/^ping-peer-/), + }) + }) + + it('waitForInitialActive polls until active without posting /resume', async () => { + const sessionId = 'eeeeeeee-1111-1111-1111-111111111111' + let active = false + let polls = 0 + let resumeCalls = 0 + + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/resume`)) { + resumeCalls += 1 + throw new Error('resume must not run for a just-spawned session') + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(active).toBe(true) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${sessionId}`)) { + polls += 1 + if (polls >= 3) { + active = true + } + return { + status: 200, + data: { + session: { + id: sessionId, + active, + metadata: { name: 'Fresh', flavor: 'claude' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionId, + message: 'remit', + waitForInitialActive: true, + waitActiveSecs: 10, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + sleepCalls.push(ms) + nowMs += ms + } + }) + + expect(result.resumed).toBe(false) + expect(resumeCalls).toBe(0) + expect(sleepCalls.length).toBeGreaterThan(0) + }) + it('maps resume failures to resume_failed', async () => { const sessionId = 'deadbeef-1111-1111-1111-111111111111' const http = createHttpMock({ diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index ec470f6c..4dbe4933 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -80,10 +80,19 @@ export type PingPeerSessionSummary = { } export type PingPeerOptions = { - sessionIdPrefix: string + sessionIdPrefix?: string + /** When set, skip list+prefix resolve and load this session directly. */ + sessionId?: string message: string deliveryMode?: PeerDeliveryMode waitActiveSecs?: number + /** + * Fresh spawn: poll until active instead of POST /resume on the first + * inactive snapshot. Machine spawn can return before the hub row is + * active; resume would launch a second child (no existingSessionId on + * the original spawn, so runner dedupe does not coalesce). + */ + waitForInitialActive?: boolean apiUrl?: string accessToken?: string http?: AxiosInstance @@ -568,9 +577,10 @@ function getSteeringUnavailableReason(session: PingPeerSessionSummary): string | export async function pingPeer(options: PingPeerOptions): Promise { assertPeerToolsEnabled() - const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') + const knownSessionId = (options.sessionId ?? '').trim() + const prefix = knownSessionId ? '' : normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') const message = options.message ?? '' - if (!prefix) { + if (!knownSessionId && !prefix) { throw new PingPeerError('bad_args', 'session id prefix is required') } if (!message) { @@ -595,8 +605,9 @@ export async function pingPeer(options: PingPeerOptions): Promise { + it('returns exact UUID id matches', () => { + expect(resolveSpawnMachineIdFromList( + '5f5a87e8-25b2-4732-ba4c-aba95f695bd7', + MACHINES + )).toBe('5f5a87e8-25b2-4732-ba4c-aba95f695bd7') + }) + + it('passthrough UUID when not in the online list', () => { + expect(resolveSpawnMachineIdFromList( + 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + MACHINES + )).toBe('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee') + }) + + it('resolves hostname via metadata.host', () => { + expect(resolveSpawnMachineIdFromList('oos-linux', MACHINES)) + .toBe('5f5a87e8-25b2-4732-ba4c-aba95f695bd7') + }) + + it('resolves metadata.name and displayName', () => { + expect(resolveSpawnMachineIdFromList('homelab', MACHINES)) + .toBe('f9bb3c9e-43fd-41ca-9e4f-a0b0414b9026') + expect(resolveSpawnMachineIdFromList('windows-box', MACHINES)) + .toBe('Teemo') + }) + + it('resolves non-UUID machine ids by exact id', () => { + expect(resolveSpawnMachineIdFromList('Teemo', MACHINES)).toBe('Teemo') + }) + + it('returns null for unknown hostnames', () => { + expect(resolveSpawnMachineIdFromList('no-such-host', MACHINES)).toBeNull() + }) + + it('rejects ambiguous hostname / displayName matches', () => { + const dupHosts = [ + { + id: 'aaaaaaaa-bbbb-4ccc-8ddd-111111111111', + metadata: { host: 'shared-lab', displayName: 'lab' }, + }, + { + id: 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + metadata: { host: 'other', name: 'shared-lab' }, + }, + ] + expect(() => resolveSpawnMachineIdFromList('shared-lab', dupHosts)) + .toThrow(AmbiguousSpawnMachineError) + expect(() => resolveSpawnMachineIdFromList('shared-lab', dupHosts)) + .toThrow(/matches 2 online runners/i) + }) + + it('trims whitespace', () => { + expect(resolveSpawnMachineIdFromList(' oos-linux ', MACHINES)) + .toBe('5f5a87e8-25b2-4732-ba4c-aba95f695bd7') + }) +}) + +describe('isUuidMachineSelector', () => { + it('accepts RFC-shaped UUIDs', () => { + expect(isUuidMachineSelector('5f5a87e8-25b2-4732-ba4c-aba95f695bd7')).toBe(true) + }) + + it('rejects hostnames', () => { + expect(isUuidMachineSelector('oos-linux')).toBe(false) + }) +}) diff --git a/cli/src/modules/spawnPeer/resolveSpawnMachineId.ts b/cli/src/modules/spawnPeer/resolveSpawnMachineId.ts new file mode 100644 index 00000000..30aa073e --- /dev/null +++ b/cli/src/modules/spawnPeer/resolveSpawnMachineId.ts @@ -0,0 +1,106 @@ +/** + * Resolve a spawn-peer `--machine` / MCP `machine` selector to a hub machine id. + * + * Matches estate `hapi-spawn-peer.sh`: UUID (or exact id) passthrough; otherwise + * hostname / metadata.host / metadata.name via GET /api/machines. + * Label matches that hit more than one online machine fail closed (hub does + * not enforce unique hostnames / display names). + */ + +export type HubMachineListEntry = { + id?: unknown + hostname?: unknown + host?: unknown + metadata?: { + host?: unknown + hostname?: unknown + name?: unknown + displayName?: unknown + } | null +} + +const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +export class AmbiguousSpawnMachineError extends Error { + readonly matches: string[] + + constructor(selector: string, matches: string[]) { + const unique = [...new Set(matches)] + super( + `machine=${selector} matches ${unique.length} online runners ` + + `(${unique.join(', ')}). Use a machine UUID instead.` + ) + this.name = 'AmbiguousSpawnMachineError' + this.matches = unique + } +} + +function asNonEmptyString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function machineLabelCandidates(entry: HubMachineListEntry): string[] { + const meta = entry.metadata && typeof entry.metadata === 'object' ? entry.metadata : null + return [ + asNonEmptyString(entry.id), + asNonEmptyString(entry.hostname), + asNonEmptyString(entry.host), + asNonEmptyString(meta?.host), + asNonEmptyString(meta?.hostname), + asNonEmptyString(meta?.name), + asNonEmptyString(meta?.displayName), + ].filter(Boolean) +} + +/** + * Pure resolver against an already-fetched machine list. + * Returns the machine id, or null when no match. + * Throws AmbiguousSpawnMachineError when a non-id label matches 2+ machines. + */ +export function resolveSpawnMachineIdFromList( + want: string, + machines: HubMachineListEntry[] +): string | null { + const needle = want.trim() + if (!needle) { + return null + } + + // Exact id match (UUID or non-UUID ids like "Teemo") + for (const entry of machines) { + const id = asNonEmptyString(entry.id) + if (id && id === needle) { + return id + } + } + + // UUID-shaped selectors may target a briefly offline machine; passthrough + // like the estate wrapper (spawn will 404 if truly unknown). + if (UUID_RE.test(needle)) { + return needle + } + + const matches: string[] = [] + for (const entry of machines) { + const id = asNonEmptyString(entry.id) + if (!id) continue + const labels = machineLabelCandidates(entry) + if (labels.some((label) => label === needle)) { + matches.push(id) + } + } + + const unique = [...new Set(matches)] + if (unique.length > 1) { + throw new AmbiguousSpawnMachineError(needle, unique) + } + if (unique.length === 1) { + return unique[0]! + } + + return null +} + +export function isUuidMachineSelector(want: string): boolean { + return UUID_RE.test(want.trim()) +} diff --git a/cli/src/modules/spawnPeer/spawnPeer.test.ts b/cli/src/modules/spawnPeer/spawnPeer.test.ts new file mode 100644 index 00000000..030ad0d9 --- /dev/null +++ b/cli/src/modules/spawnPeer/spawnPeer.test.ts @@ -0,0 +1,2124 @@ +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SpawnPeerError, + exitCodeForSpawnPeerError, + isCrossPlatformAbsolutePath, + resolveSpawnPeerDirectory, + spawnPeer +} from './spawnPeer' + +type MockResponse = { + status: number + data: unknown +} + +const SESSION_ID = 'cccccccc-1111-1111-1111-111111111111' +const MACHINE_ID = 'machine-abc' + +const STOCK_RESOLVED_HUB_DEFAULTS = { + agent: 'claude' as const, + permissionMode: 'bypassPermissions' as const, + models: { claude: 'sonnet' } +} + +function createHttpMock(handlers: { + post?: (url: string, body?: unknown) => MockResponse | Promise + get?: (url: string, config?: { params?: Record }) => MockResponse | Promise + patch?: (url: string, body?: unknown) => MockResponse | Promise +}) { + return { + post: vi.fn(async (url: string, body?: unknown) => { + if (!handlers.post) { + throw new Error(`unexpected POST ${url}`) + } + return handlers.post(url, body) + }), + get: vi.fn(async (url: string, config?: { params?: Record }) => { + if (handlers.get) { + try { + return await handlers.get(url, config) + } catch (error) { + if (typeof url === 'string' && url.endsWith('/api/hub-settings')) { + return { + status: 200, + data: { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: STOCK_RESOLVED_HUB_DEFAULTS + } + } + } + throw error + } + } + if (typeof url === 'string' && url.endsWith('/api/hub-settings')) { + return { + status: 200, + data: { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: STOCK_RESOLVED_HUB_DEFAULTS + } + } + } + throw new Error(`unexpected GET ${url}`) + }), + patch: vi.fn(async (url: string, body?: unknown) => { + if (!handlers.patch) { + throw new Error(`unexpected PATCH ${url}`) + } + return handlers.patch(url, body) + }) + } +} +function userMessageRow(text: string) { + return { + id: 'msg-1', + createdAt: 1, + content: { role: 'user', content: { text } } + } +} + +describe('resolveSpawnPeerDirectory', () => { + it('detects POSIX and Windows absolute paths on either host OS', () => { + expect(isCrossPlatformAbsolutePath('/srv/repo')).toBe(true) + expect(isCrossPlatformAbsolutePath('C:\\Users\\ops\\repo')).toBe(true) + expect(isCrossPlatformAbsolutePath('C:/Users/ops/repo')).toBe(true) + expect(isCrossPlatformAbsolutePath('\\\\server\\share')).toBe(true) + expect(isCrossPlatformAbsolutePath('\\repo')).toBe(true) + expect(isCrossPlatformAbsolutePath('relative/dir')).toBe(false) + expect(isCrossPlatformAbsolutePath('.')).toBe(false) + }) + + it('preserves foreign absolute roots for cross-host; resolves relative against cwd', () => { + expect(resolveSpawnPeerDirectory('C:\\Users\\ops\\repo', { + cwd: '/home/agent', + crossHost: true, + })).toBe('C:\\Users\\ops\\repo') + expect(resolveSpawnPeerDirectory('/srv/repo', { + cwd: 'C:\\Users\\ops\\session', + crossHost: true, + })).toBe('/srv/repo') + expect(resolveSpawnPeerDirectory('peer-dir', { + cwd: '/repo-b', + crossHost: true, + })).toBe(resolve('/repo-b', 'peer-dir')) + expect(resolveSpawnPeerDirectory('.', { + cwd: '/repo-b', + crossHost: false, + })).toBe(resolve('/repo-b', '.')) + }) + + it('uses native resolve for local spawn so Windows drive-less rooted paths bind to cwd drive', () => { + // On any host OS, native path.resolve against a Windows cwd drive-prefixes `\repo`. + expect(resolveSpawnPeerDirectory('\\repo', { + cwd: 'D:\\work\\session', + crossHost: false, + })).toBe(resolve('D:\\work\\session', '\\repo')) + // Cross-host keeps the drive-less root for the target Windows runner. + expect(resolveSpawnPeerDirectory('\\repo', { + cwd: 'D:\\work\\session', + crossHost: true, + })).toBe('\\repo') + }) +}) + +describe('spawnPeer', () => { + let nowMs: number + + beforeEach(() => { + vi.stubEnv('HAPI_DISABLE_PEER_TOOLS', '') + nowMs = 1_000_000 + // Unit tests must not inherit the wrapping HAPI session identity. + delete process.env.HAPI_SESSION_ID + delete process.env.HAPI_SESSION_NAME + delete process.env.HAPI_AGENT_SESSION_ID + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('refuses disabled peer tools before authenticating or spawning a child', async () => { + vi.stubEnv('HAPI_DISABLE_PEER_TOOLS', '1') + const http = createHttpMock({}) + await expect(spawnPeer({ + directory: '/srv/project', + message: 'Run the tests', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + })).rejects.toMatchObject({ code: 'disabled' }) + expect(http.post).not.toHaveBeenCalled() + expect(http.get).not.toHaveBeenCalled() + }) + + it('rejects an empty remit', async () => { + await expect(spawnPeer({ + directory: '/tmp/project', + message: ' ', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test' + })).rejects.toMatchObject({ code: 'bad_args' }) + }) + + it('rejects a blank remit even when a Parent stamp would otherwise fill it', async () => { + // Regression: ensureParentStamp used to turn '' into "## Parent\n..." so the + // post-stamp emptiness check passed and idle sessions could be created. + process.env.HAPI_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + process.env.HAPI_SESSION_NAME = 'Parent' + await expect(spawnPeer({ + directory: '/tmp/project', + message: ' ', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test' + })).rejects.toMatchObject({ + code: 'bad_args', + message: expect.stringMatching(/empty remit/i), + }) + }) + + it('rejects a missing directory', async () => { + await expect(spawnPeer({ + directory: '', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test' + })).rejects.toMatchObject({ code: 'bad_args' }) + }) + + it('rejects a permissionMode the selected agent does not support before spawn', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + agent: 'codex', + permissionMode: 'bypassPermissions', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null + })).rejects.toMatchObject({ code: 'bad_args' }) + + expect(http.post).toHaveBeenCalledTimes(1) + }) + + it('accepts read-only when agent is omitted and hub default is Codex', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/hub-settings')) { + return { + status: 200, + data: { + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'yolo', + models: {} + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [userMessageRow('do the work')] } } + } + if (url.includes('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ id: SESSION_ID, active: true, metadata: { flavor: 'codex' } }], + session: { id: SESSION_ID, active: true, metadata: { flavor: 'codex' } } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + permissionMode: 'read-only', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody).toMatchObject({ + agent: 'codex', + permissionMode: 'read-only' + }) + }) + + it('rejects plan when agent is omitted and hub default is Codex', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/hub-settings')) { + return { + status: 200, + data: { + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'yolo', + models: {} + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + permissionMode: 'plan', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ code: 'bad_args' }) + }) + + it('aborts when hub spawn defaults cannot be loaded', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/hub-settings')) { + return { status: 500, data: { error: 'boom' } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ + code: 'spawn_failed', + message: expect.stringMatching(/hub spawn defaults/i) + }) + expect(http.post).toHaveBeenCalledTimes(1) + }) + + + it('omits permissionMode for pi (empty launch catalog)', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(SESSION_ID)) { + return { + status: 200, + data: { + sessions: [{ + id: SESSION_ID, + active: true, + metadata: { flavor: 'pi' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { flavor: 'pi' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [userMessageRow('do the work')] } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + agent: 'pi', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + waitActiveSecs: 1, + now: () => nowMs, + sleep: async () => { nowMs += 500 } + }) + + expect(spawnedBody?.agent).toBe('pi') + expect(spawnedBody).not.toHaveProperty('permissionMode') + }) + +it('resolves relative directory against cwd (MCP session working directory)', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [userMessageRow('do the work')] } } + } + if (url.includes('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ id: SESSION_ID, active: true, metadata: { flavor: 'claude' } }], + session: { id: SESSION_ID, active: true, metadata: { flavor: 'claude' } } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '.', + cwd: '/repo-b', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null + }) + + expect(spawnedBody?.directory).toBe('/repo-b') + }) + + it('rejects a Claude-illegal permissionMode when agent is omitted (hub default Claude)', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + permissionMode: 'yolo', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: { + agent: 'claude', + permissionMode: 'bypassPermissions', + models: { claude: 'sonnet' } + } + })).rejects.toMatchObject({ code: 'bad_args' }) + + expect(http.post).toHaveBeenCalledTimes(1) + }) + + it('rejects a name longer than the hub rename max before spawn', async () => { + const http = createHttpMock({ + post: (url) => { + throw new Error(`spawn must not run; unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + name: 'n'.repeat(256), + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ code: 'bad_args' }) + + expect(http.post).not.toHaveBeenCalled() + }) + + it('maps spawn type=error to spawn_failed', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'error', message: 'no runner' } } + } + throw new Error(`unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ code: 'spawn_failed' }) + expect(http.post).toHaveBeenCalledTimes(2) + }) + + it('maps missing sessionId after spawn HTTP 200 to spawn_failed', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success' } } + } + throw new Error(`unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ code: 'spawn_failed' }) + }) + + it('spawns, renames, delivers remit, and verifies a user message', async () => { + let spawnedBody: Record | undefined + let delivered: unknown + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + delivered = body + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('happy-path spawn must not archive the child') + } + throw new Error(`unexpected POST ${url}`) + }, + patch: (url, body) => { + expect(url.endsWith(`/api/sessions/${SESSION_ID}`)).toBe(true) + expect(body).toEqual({ name: 'Peer #1509: spawn-peer remit' }) + return { status: 200, data: { ok: true } } + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(SESSION_ID)) { + return { + status: 200, + data: { + sessions: [{ + id: SESSION_ID, + active: true, + metadata: { name: 'Peer #1509: spawn-peer remit', flavor: 'cursor' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Peer #1509: spawn-peer remit', flavor: 'cursor' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('do the work')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await spawnPeer({ + directory: '/home/u/coding/hapi/worktrees/spawn-peer-remit', + message: 'do the work', + name: 'Peer #1509: spawn-peer remit', + agent: 'cursor', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(result).toEqual({ + sessionId: SESSION_ID, + name: 'Peer #1509: spawn-peer remit' + }) + expect(spawnedBody).toMatchObject({ + directory: '/home/u/coding/hapi/worktrees/spawn-peer-remit', + agent: 'cursor', + permissionMode: 'yolo', + sessionType: 'simple' + }) + expect(spawnedBody).not.toHaveProperty('message') + expect(spawnedBody).not.toHaveProperty('prompt') + expect(spawnedBody).not.toHaveProperty('text') + expect(spawnedBody).not.toHaveProperty('yolo') + expect(spawnedBody).toHaveProperty('permissionMode', 'yolo') + expect(delivered).toEqual({ text: 'do the work', localId: expect.stringMatching(/^ping-peer-/) }) + expect(http.patch).toHaveBeenCalledTimes(1) + }) + + it('waits for a freshly spawned inactive session without posting /resume', async () => { + let resumeCalls = 0 + let sessionGets = 0 + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/resume`)) { + resumeCalls += 1 + throw new Error('spawn-peer must not resume a just-spawned child') + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + sessionGets += 1 + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: sessionGets >= 3, + metadata: { name: 'Fresh', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 10, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + }) + + expect(resumeCalls).toBe(0) + }) + + it('resolves a relative directory in the caller before the spawn POST', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Rel', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: 'relative-peer-dir', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody?.directory).toBe(resolve('relative-peer-dir')) + }) + + it('preserves a Windows absolute directory for cross-host spawn (no Linux resolve)', async () => { + const REMOTE_ID = 'win-runner-1' + const LOCAL_ID = 'linux-local' + const winDir = 'C:\\Users\\ops\\coding\\hapi' + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${REMOTE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/machines')) { + return { + status: 200, + data: { + machines: [{ id: REMOTE_ID, hostname: 'teemo-win', metadata: { host: 'teemo-win' } }] + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'WinPeer', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: winDir, + message: 'brief', + machine: 'teemo-win', + localMachineId: LOCAL_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + cwd: '/home/agent/session', + }) + + expect(spawnedBody?.directory).toBe(winDir) + expect(String(spawnedBody?.directory)).not.toContain('/home/agent/session') + }) + + it('preserves a POSIX absolute directory even when cwd is set (Windows-caller case)', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'PosixPeer', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/srv/repo', + message: 'brief', + machineId: MACHINE_ID, + // Pretend local machine differs so we skip local existsSync + localMachineId: 'other-machine', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + cwd: 'C:\\Users\\ops\\session', + }) + + expect(spawnedBody?.directory).toBe('/srv/repo') + }) + + it('does not archive when ping-peer delivery throws and transcript cannot be verified', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive when transcript verification is unavailable') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { status: 404, data: { error: 'missing' } } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 404, data: { error: 'missing' } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ code: 'verify_failed' }) + + expect(http.post).not.toHaveBeenCalledWith( + `http://hub.test/api/sessions/${SESSION_ID}/archive`, + expect.anything(), + expect.anything() + ) + }) + + it('keeps the child when ping-peer throws but the remit is already in the transcript', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + throw new Error('socket hang up after hub accepted the remit') + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive a child that already has the remit') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Kept', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(result).toEqual({ sessionId: SESSION_ID, name: SESSION_ID.slice(0, 8) }) + }) + + it('does not report a requested name when rename failed and ping-peer threw', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + throw new Error('socket hang up after hub accepted the remit') + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive a child that already has the remit') + } + throw new Error(`unexpected POST ${url}`) + }, + patch: () => ({ status: 409, data: { error: 'rename rejected' } }), + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Untitled', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + name: 'Peer that was never renamed', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(result.name).toBe(SESSION_ID.slice(0, 8)) + expect(result.name).not.toBe('Peer that was never renamed') + }) + + it('retries a thrown transcript GET and does not archive when verification never succeeds', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive when transcript verification failed') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Empty', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + throw new Error('temporary hub disconnect') + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'this remit must land', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ + code: 'verify_failed', + message: expect.stringMatching(new RegExp(`left child running[\\s\\S]*${SESSION_ID}`, 'i')) + }) + + expect(http.post).not.toHaveBeenCalledWith( + `http://hub.test/api/sessions/${SESSION_ID}/archive`, + expect.anything(), + expect.anything() + ) + }) + + it('retries a thrown transcript GET then succeeds when the remit appears', async () => { + let messageGets = 0 + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive after remit is visible') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Recovered', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + messageGets += 1 + if (messageGets === 1) { + throw new Error('temporary hub disconnect') + } + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 10, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + }) + + expect(result.sessionId).toBe(SESSION_ID) + expect(messageGets).toBeGreaterThan(1) + }) + + it('does not archive when transcript pagination is exhausted while hasMore stays true', async () => { + // Cap is 40 pages; if every page says hasMore, remit may still exist older. + let messageGets = 0 + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive when pagination is inconclusive') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Busy', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + messageGets += 1 + return { + status: 200, + data: { + messages: [{ + content: { + role: 'user', + content: [{ type: 'text', text: 'unrelated noise' }] + } + }], + page: { + hasMore: true, + nextBeforeAt: 1_000_000 - messageGets, + nextBeforeSeq: messageGets + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'this remit is buried past the page cap', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ code: 'verify_failed' }) + + expect(messageGets).toBeGreaterThanOrEqual(40) + expect(http.post).not.toHaveBeenCalledWith( + `http://hub.test/api/sessions/${SESSION_ID}/archive`, + expect.anything(), + expect.anything() + ) + }) + + it('finds the remit on an older messages page instead of archiving', async () => { + const messageGets: Array | undefined> = [] + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not archive when remit is on an older page') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url, config) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Busy child', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + messageGets.push(config?.params) + if (!config?.params?.beforeAt) { + return { + status: 200, + data: { + messages: [userMessageRow('later assistant chatter')], + page: { + hasMore: true, + nextBeforeAt: 1_700_000_000_000, + nextBeforeSeq: 10 + } + } + } + } + expect(config.params).toMatchObject({ + beforeAt: 1_700_000_000_000, + beforeSeq: 10, + limit: 50 + }) + return { + status: 200, + data: { + messages: [userMessageRow('do the work')], + page: { hasMore: false } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 10, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + }) + + expect(result.sessionId).toBe(SESSION_ID) + expect(messageGets.length).toBeGreaterThanOrEqual(2) + }) + + it('fails closed when spawn+send succeed but the session still has no user message', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not auto-archive on empty remit') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(SESSION_ID)) { + return { + status: 200, + data: { + sessions: [{ + id: SESSION_ID, + active: true, + metadata: { name: 'Empty', flavor: 'claude' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Empty', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [] } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'this remit must land', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ + code: 'empty_session', + message: expect.stringMatching(new RegExp(`left child running[\\s\\S]*${SESSION_ID}`, 'i')) + }) + + expect(http.post).not.toHaveBeenCalledWith( + `http://hub.test/api/sessions/${SESSION_ID}/archive`, + expect.anything(), + expect.anything() + ) + }) + + it('reports verify_failed when transcript becomes unavailable after an empty read', async () => { + let messageGets = 0 + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not auto-archive when verify is unavailable') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Unread', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + messageGets += 1 + if (messageGets === 1) { + return { status: 200, data: { messages: [] } } + } + return { status: 503, data: { error: 'hub unavailable' } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'this remit must land', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ + code: 'verify_failed', + message: expect.stringMatching(/transcript unread/i) + }) + + expect(messageGets).toBeGreaterThanOrEqual(2) + expect(http.post).not.toHaveBeenCalledWith( + `http://hub.test/api/sessions/${SESSION_ID}/archive`, + expect.anything(), + expect.anything() + ) + }) + + it('applies hub peerSpawnDefaults when explicit args are omitted', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/hub-settings')) { + return { + status: 200, + data: { + sessionSummaryContract: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'read-only', + models: { codex: 'gpt-5' } + } + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Hub defaults', flavor: 'codex' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody).toMatchObject({ + agent: 'codex', + permissionMode: 'read-only', + model: 'gpt-5' + }) + }) + + it('forwards explicit model and effort overrides', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Named', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + agent: 'claude', + model: 'opus', + effort: 'high', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody).toMatchObject({ + agent: 'claude', + model: 'opus', + effort: 'high' + }) + }) + + it('maps Codex and OpenCode effort overrides to modelReasoningEffort', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Named', flavor: 'codex' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + agent: 'codex', + effort: 'high', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody).toMatchObject({ + agent: 'codex', + modelReasoningEffort: 'high' + }) + expect(spawnedBody).not.toHaveProperty('effort') + }) + + it('passes an explicit permissionMode and does not clone yolo', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(SESSION_ID)) { + return { + status: 200, + data: { + sessions: [{ + id: SESSION_ID, + active: true, + metadata: { name: 'Named', flavor: 'claude' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Named', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'brief', + permissionMode: 'default', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(spawnedBody).toMatchObject({ + directory: '/tmp/project', + agent: 'claude', + sessionType: 'simple', + permissionMode: 'default', + model: 'sonnet' + }) + expect(spawnedBody).not.toHaveProperty('yolo') + }) + + it('does not treat a different user message as a landed remit', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/archive`)) { + throw new Error('must not auto-archive when remit mismatch') + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Other', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('some other prompt')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'the actual remit', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 2, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + })).rejects.toMatchObject({ code: 'empty_session' }) + }) + + it('forwards an explicit worktree sessionType', async () => { + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'WT', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { messages: [userMessageRow('brief')] } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/repo', + message: 'brief', + sessionType: 'worktree', + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + expect(spawnedBody).toMatchObject({ sessionType: 'worktree' }) + }) + + it('stamps a durable Parent chip onto the remit before delivery', async () => { + const PARENT_ID = '4bd4d2b9-e114-4e03-af4c-03e9e4f7439e' + let deliveredText = '' + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/machines/${MACHINE_ID}/spawn`)) { + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + deliveredText = String((body as { text?: string }).text ?? '') + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith(`/api/sessions/${SESSION_ID}`)) { + return { + status: 200, + data: { + session: { + id: SESSION_ID, + active: true, + metadata: { name: 'Child', flavor: 'claude' } + } + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { + status: 200, + data: { + messages: [userMessageRow(deliveredText)] + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/tmp/project', + message: 'Own steps: implement P0', + parent: { + sessionId: PARENT_ID, + name: 'Producer', + agentSessionId: 'cursor-agent-xyz', + }, + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + }) + + expect(deliveredText).toContain(`## Parent`) + expect(deliveredText).toContain(`[Producer](/sessions/${PARENT_ID})`) + expect(deliveredText).toContain('agentSessionId: `cursor-agent-xyz`') + expect(deliveredText).toContain('Own steps: implement P0') + }) + + it('fail-closes in-session spawn when requireParent and parent id is missing', async () => { + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + requireParent: true, + parent: null, + machineId: MACHINE_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + })).rejects.toMatchObject({ + code: 'bad_args', + message: expect.stringMatching(/parent session id/i), + }) + }) + + it('maps exit codes', () => { + expect(exitCodeForSpawnPeerError(new SpawnPeerError('bad_args', 'x'))).toBe(2) + expect(exitCodeForSpawnPeerError(new SpawnPeerError('spawn_failed', 'x'))).toBe(3) + expect(exitCodeForSpawnPeerError(new SpawnPeerError('empty_session', 'x'))).toBe(4) + expect(exitCodeForSpawnPeerError(new SpawnPeerError('verify_failed', 'x'))).toBe(4) + expect(exitCodeForSpawnPeerError(new SpawnPeerError('send_failed', 'x'))).toBe(4) + }) + + it('preserves Windows absolute directory when spawning from a POSIX caller', async () => { + const REMOTE_ID = '5f5a87e8-25b2-4732-ba4c-aba95f695bd7' + const LOCAL_ID = 'local-machine-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + let spawnedBody: Record | undefined + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (typeof url === 'string' && url.includes('/api/machines/') && url.endsWith('/spawn')) { + spawnedBody = body as Record + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/machines')) { + return { + status: 200, + data: { + machines: [{ + id: REMOTE_ID, + metadata: { host: 'teemo' }, + }], + }, + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [userMessageRow('do the work')] } } + } + if (url.includes('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ id: SESSION_ID, active: true, metadata: { flavor: 'claude' } }], + session: { id: SESSION_ID, active: true, metadata: { flavor: 'claude' } }, + }, + } + } + throw new Error(`unexpected GET ${url}`) + }, + }) + + await spawnPeer({ + directory: 'C:\\repo', + message: 'do the work', + machine: 'teemo', + localMachineId: LOCAL_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + }) + + expect(spawnedBody?.directory).toBe('C:\\repo') + // Caller cwd must not prefix the Windows root (the bug this guards). + expect(String(spawnedBody?.directory)).not.toContain(resolve('.')) + }) + + it('resolves machine hostname via GET /api/machines and spawns there', async () => { + const REMOTE_ID = '5f5a87e8-25b2-4732-ba4c-aba95f695bd7' + const LOCAL_ID = 'local-machine-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + let spawnedUrl = '' + const progress: string[] = [] + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (typeof url === 'string' && url.includes('/api/machines/') && url.endsWith('/spawn')) { + spawnedUrl = url + return { status: 200, data: { type: 'success', sessionId: SESSION_ID } } + } + if (url.endsWith(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url} body=${JSON.stringify(body)}`) + }, + get: (url) => { + if (url.endsWith('/api/machines')) { + return { + status: 200, + data: { + machines: [ + { id: REMOTE_ID, metadata: { host: 'oos-llm-lab' } }, + { id: LOCAL_ID, metadata: { host: 'oos-linux' } }, + ] + } + } + } + if (url.includes(`/api/sessions/${SESSION_ID}/messages`)) { + return { status: 200, data: { messages: [userMessageRow('do the work')] } } + } + if (url.includes('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ id: SESSION_ID, active: true, metadata: { flavor: 'claude' } }], + session: { id: SESSION_ID, active: true, metadata: { flavor: 'claude' } } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await spawnPeer({ + directory: '/does/not/exist/on/this/host', + message: 'do the work', + machine: 'oos-llm-lab', + localMachineId: LOCAL_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + onProgress: (line) => progress.push(line), + }) + + expect(spawnedUrl).toContain(`/api/machines/${REMOTE_ID}/spawn`) + expect(progress.some((line) => line.includes('cross-machine spawn'))).toBe(true) + expect(http.get).toHaveBeenCalledWith( + 'http://hub.test/api/machines', + expect.objectContaining({ headers: expect.any(Object) }) + ) + }) + + it('rejects unknown machine hostnames before spawn', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/machines')) { + return { status: 200, data: { machines: [] } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machine: 'no-such-host', + localMachineId: 'local-1', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + })).rejects.toMatchObject({ + code: 'bad_args', + message: expect.stringMatching(/no hub machine matched/i), + }) + }) + + it('rejects ambiguous machine hostnames before spawn', async () => { + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/machines')) { + return { + status: 200, + data: { + machines: [ + { id: 'id-a', metadata: { host: 'dup-lab' } }, + { id: 'id-b', metadata: { name: 'dup-lab' } }, + ] + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/tmp/project', + message: 'do the work', + machine: 'dup-lab', + localMachineId: 'local-1', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + })).rejects.toMatchObject({ + code: 'ambiguous', + message: expect.stringMatching(/matches 2 online runners/i), + }) + }) + + it('requires a local directory when targeting this machine', async () => { + const LOCAL_ID = 'local-only-machine' + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`spawn must not run; unexpected POST ${url}`) + } + }) + + await expect(spawnPeer({ + directory: '/definitely/missing/spawn-peer-dir-1931', + message: 'do the work', + machineId: LOCAL_ID, + localMachineId: LOCAL_ID, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never, + hubPeerSpawnDefaults: null, + })).rejects.toMatchObject({ + code: 'bad_args', + message: expect.stringMatching(/directory not found on this host/i), + }) + }) +}) diff --git a/cli/src/modules/spawnPeer/spawnPeer.ts b/cli/src/modules/spawnPeer/spawnPeer.ts new file mode 100644 index 00000000..6666533a --- /dev/null +++ b/cli/src/modules/spawnPeer/spawnPeer.ts @@ -0,0 +1,770 @@ +/** + * Spawn a new HAPI session with a required first user message (remit). + * + * Machine spawn (`POST /api/machines/:id/spawn`) creates an idle composer. + * A spawn body with `message`/`prompt`/`text` is HTTP 400 + * (`spawn_remit_not_supported`). This module is the fail-closed path: + * spawn → optional rename → ping-peer delivery → verify at least one + * user message. HTTP 200 + sessionId with 0 messages is a failed spawn + * (tiann/hapi#1509). + * + * Shared by `hapi spawn-peer` and MCP `spawn_peer`. Same hub JWT / + * namespace as ping-peer. Callers must not invent parallel auth. + */ + +import { existsSync, statSync } from 'node:fs' +import { posix, resolve as resolvePath, win32 } from 'node:path' +import axios, { type AxiosInstance } from 'axios' +import { isObject, SESSION_NAME_MAX_LENGTH } from '@hapi/protocol' +import { + CREATABLE_AGENT_FLAVORS, + getPermissionModesForFlavor, + type AgentFlavor, + type PermissionMode +} from '@hapi/protocol/modes' +import { + resolvePeerSpawnConfig, + ResolvedPeerSpawnDefaultsSchema, + type PeerSpawnDefaults, + type ResolvedPeerSpawnDefaults +} from '@hapi/protocol/peerSpawnDefaults' +import { + ParentStampError, + ensureParentStamp, + type PeerParentIdentity, +} from '@hapi/protocol/peerParentStamp' +import { configuration } from '@/configuration' +import { isPeerToolsDisabled } from '@/modules/common/peerTools' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' +import { readSettings } from '@/persistence' +import { + PingPeerError, + extractInspectMessageSnippet, + pingPeer +} from '@/modules/pingPeer/pingPeer' +import { + AmbiguousSpawnMachineError, + isUuidMachineSelector, + resolveSpawnMachineIdFromList, + type HubMachineListEntry, +} from '@/modules/spawnPeer/resolveSpawnMachineId' + +export type SpawnPeerErrorCode = + | 'bad_args' + | 'disabled' + | 'auth_failed' + | 'spawn_failed' + | 'empty_session' + | 'verify_failed' + | 'not_found' + | 'ambiguous' + | 'resume_failed' + | 'timeout' + | 'send_failed' + | 'apply_failed' + +export class SpawnPeerError extends Error { + readonly code: SpawnPeerErrorCode + + constructor(code: SpawnPeerErrorCode, message: string) { + super(message) + this.name = 'SpawnPeerError' + this.code = code + } +} + +export type SpawnPeerOptions = { + directory: string + message: string + name?: string + agent?: AgentFlavor + model?: string + effort?: string + sessionType?: 'simple' | 'worktree' + worktreeName?: string + permissionMode?: PermissionMode + /** + * Already-resolved hub machine id. Prefer this in tests. + * When omitted, falls back to `machine` then local settings.machineId. + */ + machineId?: string + /** + * Optional machine selector: UUID or hostname / metadata.host / displayName. + * Resolved via GET /api/machines (tiann/hapi#1931). + */ + machine?: string + /** + * Local runner machine id (defaults to settings.machineId). Used to decide + * cross-host dir checks and progress notes. + */ + localMachineId?: string + waitActiveSecs?: number + apiUrl?: string + accessToken?: string + /** Skip hub settings fetch (tests). */ + hubPeerSpawnDefaults?: PeerSpawnDefaults | null + /** + * Base directory for relative `directory` paths (MCP session cwd). + * Absolute directories are unchanged. Defaults to process.cwd(). + */ + cwd?: string + /** + * Orchestrator identity for rename-proof Parent stamp (heavygee/hapi#175). + * When omitted, falls back to `HAPI_SESSION_ID` / `HAPI_SESSION_NAME` / + * `HAPI_AGENT_SESSION_ID` env (in-session CLI). + */ + parent?: PeerParentIdentity | null + /** + * MCP / in-session: fail closed when parent id is missing. + * Outside-session CLI leaves this false and warns instead. + */ + requireParent?: boolean + http?: AxiosInstance + sleep?: (ms: number) => Promise + now?: () => number + onProgress?: (message: string) => void +} + +export type SpawnPeerResult = { + sessionId: string + name: string +} + +const DEFAULT_WAIT_ACTIVE_SECS = 60 +const POLL_VERIFY_MS = 1_000 + +const AUTH_RECOVERY_HINT = + 'On a remote runner, set HAPI_API_URL to the runner hub, and set CLI_API_TOKEN ' + + 'or run `hapi auth login` to save the token. Inside a HAPI session prefer MCP ' + + '`spawn_peer`, which uses the session CLI credentials.' + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Resolve parent identity from in-session env when callers omit `parent`. */ +export function parentIdentityFromEnv( + env: NodeJS.ProcessEnv = process.env +): PeerParentIdentity | null { + const sessionId = (env.HAPI_SESSION_ID ?? '').trim() + if (!sessionId) { + return null + } + return { + sessionId, + name: (env.HAPI_SESSION_NAME ?? '').trim() || null, + agentSessionId: (env.HAPI_AGENT_SESSION_ID ?? '').trim() || null, + } +} + +function resolveApiUrl(apiUrl?: string): string { + const raw = (apiUrl ?? configuration.apiUrl).trim().replace(/\/+$/, '') + if (!raw) { + throw new SpawnPeerError( + 'bad_args', + `HAPI API URL is empty. ${AUTH_RECOVERY_HINT}` + ) + } + return raw +} + +function resolveAccessToken(accessToken?: string): string { + let token = '' + try { + token = (accessToken ?? getAuthToken()).trim() + } catch { + token = (accessToken ?? '').trim() + } + if (!token) { + throw new SpawnPeerError( + 'bad_args', + `CLI_API_TOKEN is required (run \`hapi auth login\`). ${AUTH_RECOVERY_HINT}` + ) + } + return token +} + +function authHeaders(jwt: string): Record { + return buildHubRequestHeaders({ + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }) +} + +async function exchangeJwt( + apiUrl: string, + accessToken: string, + http: AxiosInstance +): Promise { + try { + const response = await http.post( + `${apiUrl}/api/auth`, + { accessToken }, + { + headers: buildHubRequestHeaders({ 'Content-Type': 'application/json' }), + timeout: 10_000, + validateStatus: () => true + } + ) + const token = typeof response.data?.token === 'string' ? response.data.token : '' + if (response.status < 200 || response.status >= 300 || !token) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SpawnPeerError( + 'auth_failed', + `failed to exchange access token for JWT (${detail}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } + return token + } catch (error) { + if (error instanceof SpawnPeerError) { + throw error + } + throw new SpawnPeerError( + 'auth_failed', + `failed to exchange access token for JWT (${error instanceof Error ? error.message : String(error)}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } +} + +async function fetchHubMachines( + apiUrl: string, + jwt: string, + http: AxiosInstance +): Promise { + try { + const response = await http.get(`${apiUrl}/api/machines`, { + headers: authHeaders(jwt), + timeout: 10_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SpawnPeerError( + 'spawn_failed', + `GET /api/machines failed (HTTP ${response.status})` + ) + } + const payload = response.data as { machines?: unknown } | unknown + const list = isObject(payload) && Array.isArray(payload.machines) + ? payload.machines + : Array.isArray(payload) + ? payload + : null + if (!list) { + throw new SpawnPeerError('spawn_failed', 'GET /api/machines returned no machines list') + } + return list as HubMachineListEntry[] + } catch (error) { + if (error instanceof SpawnPeerError) { + throw error + } + throw new SpawnPeerError( + 'spawn_failed', + `GET /api/machines failed (${error instanceof Error ? error.message : String(error)})` + ) + } +} + +async function resolveTargetMachineId(options: { + machineId?: string + machine?: string + localMachineId: string + apiUrl: string + jwt: string + http: AxiosInstance +}): Promise { + const direct = (options.machineId ?? '').trim() + if (direct) { + return direct + } + + const selector = (options.machine ?? '').trim() + if (selector) { + // UUID passthrough without a list round-trip (matches estate wrapper). + if (isUuidMachineSelector(selector)) { + return selector + } + const machines = await fetchHubMachines(options.apiUrl, options.jwt, options.http) + try { + const resolved = resolveSpawnMachineIdFromList(selector, machines) + if (!resolved) { + throw new SpawnPeerError( + 'bad_args', + `no hub machine matched machine=${selector} (try UUID or hostname from GET /api/machines)` + ) + } + return resolved + } catch (error) { + if (error instanceof AmbiguousSpawnMachineError) { + throw new SpawnPeerError('ambiguous', error.message) + } + throw error + } + } + + if (options.localMachineId) { + return options.localMachineId + } + + throw new SpawnPeerError( + 'bad_args', + `machineId is required (run \`hapi auth login\` / start the runner). ${AUTH_RECOVERY_HINT}` + ) +} + +function assertLocalSpawnDirectory(directory: string): void { + try { + if (!existsSync(directory) || !statSync(directory).isDirectory()) { + throw new SpawnPeerError( + 'bad_args', + `directory not found on this host: ${directory}` + ) + } + } catch (error) { + if (error instanceof SpawnPeerError) { + throw error + } + throw new SpawnPeerError( + 'bad_args', + `directory not found on this host: ${directory}` + ) + } +} + +/** + * True when `value` is absolute on POSIX or Windows. + * Used for remote spawns so caller-platform `path.resolve` does not rewrite + * foreign absolute roots (Linux→`C:\repo`, Windows→`/srv/repo`). + */ +export function isCrossPlatformAbsolutePath(value: string): boolean { + return posix.isAbsolute(value) || win32.isAbsolute(value) +} + +export type ResolveSpawnPeerDirectoryOptions = { + cwd?: string + /** + * When true, preserve POSIX/Windows absolute roots for the target host. + * When false (local spawn), use native `path.resolve` so Windows drive-less + * rooted paths like `\repo` bind to the caller's current drive. + */ + crossHost: boolean +} + +/** + * Resolve spawn `directory` for CLI/MCP after the target machine is known. + */ +export function resolveSpawnPeerDirectory( + rawDirectory: string, + options: ResolveSpawnPeerDirectoryOptions +): string { + const trimmed = rawDirectory.trim() + if (!trimmed) { + return trimmed + } + const cwd = options.cwd + if (options.crossHost && isCrossPlatformAbsolutePath(trimmed)) { + return trimmed + } + return cwd ? resolvePath(cwd, trimmed) : resolvePath(trimmed) +} + +async function fetchHubPeerSpawnDefaults( + apiUrl: string, + jwt: string, + http: AxiosInstance +): Promise { + try { + const response = await http.get(`${apiUrl}/api/hub-settings`, { + headers: authHeaders(jwt), + timeout: 10_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SpawnPeerError('spawn_failed', 'Cannot load hub spawn defaults') + } + return ResolvedPeerSpawnDefaultsSchema.parse( + (response.data as { peerSpawnDefaults?: unknown } | undefined)?.peerSpawnDefaults + ) + } catch (error) { + if (error instanceof SpawnPeerError) { + throw error + } + throw new SpawnPeerError('spawn_failed', 'Cannot load valid hub spawn defaults') + } +} + +function collapsedRemitNeedle(message: string): string { + return message.replace(/\s+/g, ' ').trim().slice(0, 800) +} + +type RemitVerifyResult = 'found' | 'absent' | 'unavailable' + +async function verifySessionRemit( + apiUrl: string, + jwt: string, + sessionId: string, + message: string, + http: AxiosInstance +): Promise { + const needle = collapsedRemitNeedle(message) + if (!needle) { + return 'absent' + } + // Paginate oldest-ward: a busy child can push the remit off the latest + // 50-row page before verification runs. Cap pages so a broken cursor cannot + // loop forever (50 × 40 = 2000 messages). + const pageLimit = 50 + const maxPages = 40 + let beforeAt: number | undefined + let beforeSeq: number | undefined + for (let pageIndex = 0; pageIndex < maxPages; pageIndex++) { + let response: { + status: number + data?: { + messages?: unknown + page?: { + hasMore?: unknown + nextBeforeAt?: unknown + nextBeforeSeq?: unknown + } + } + } + try { + response = await http.get( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, + { + headers: authHeaders(jwt), + params: { + limit: pageLimit, + ...(beforeAt !== undefined && beforeSeq !== undefined + ? { beforeAt, beforeSeq } + : {}) + }, + timeout: 20_000, + validateStatus: () => true + } + ) + } catch { + return 'unavailable' + } + if (response.status < 200 || response.status >= 300) { + return 'unavailable' + } + const rows = Array.isArray(response.data?.messages) ? response.data.messages : [] + for (const row of rows) { + if (!isObject(row)) continue + const snippet = extractInspectMessageSnippet(row.content) + if (snippet?.role === 'user' && snippet.text.includes(needle)) { + return 'found' + } + } + const page = response.data?.page + const hasMore = page?.hasMore === true + const nextBeforeAt = typeof page?.nextBeforeAt === 'number' ? page.nextBeforeAt : null + const nextBeforeSeq = typeof page?.nextBeforeSeq === 'number' ? page.nextBeforeSeq : null + if (!hasMore || nextBeforeAt === null || nextBeforeSeq === null) { + return 'absent' + } + beforeAt = nextBeforeAt + beforeSeq = nextBeforeSeq + } + // Page cap hit while hasMore remained true — remit may still exist older. + // Do not treat as observed-absent (would archive a live child). + return 'unavailable' +} + +export async function spawnPeer(options: SpawnPeerOptions): Promise { + if (isPeerToolsDisabled()) { + throw new SpawnPeerError( + 'disabled', + 'peer session tools are disabled on this machine (HAPI_DISABLE_PEER_TOOLS is set)' + ) + } + const rawDirectory = (options.directory ?? '').trim() + const onProgress = options.onProgress + if (!rawDirectory) { + throw new SpawnPeerError('bad_args', 'directory is required') + } + + const parent = options.parent !== undefined + ? options.parent + : parentIdentityFromEnv() + // Reject blank remits before Parent stamping. Stamping an empty body would + // produce a non-empty "## Parent" remit and bypass the idle-session guard. + const rawMessage = options.message ?? '' + if (!rawMessage.trim()) { + throw new SpawnPeerError( + 'bad_args', + 'message is required; empty remit would create an idle session' + ) + } + let message: string + try { + const stamped = ensureParentStamp(rawMessage, parent, { + requireParent: options.requireParent === true, + }) + message = stamped.message + if (stamped.stamped) { + onProgress?.( + `stamped Parent chip /sessions/${(parent?.sessionId ?? '').trim()}` + ) + } else if (!(parent?.sessionId ?? '').trim()) { + onProgress?.( + 'WARNING: no parent session id - remit unattributed ' + + '(outside-session OK; in-session set HAPI_SESSION_ID or use MCP spawn_peer)' + ) + } + } catch (error) { + if (error instanceof ParentStampError) { + throw new SpawnPeerError('bad_args', error.message) + } + throw error + } + const requestedName = (options.name ?? '').trim() + if (requestedName.length > SESSION_NAME_MAX_LENGTH) { + throw new SpawnPeerError( + 'bad_args', + `name must be at most ${SESSION_NAME_MAX_LENGTH} characters (hub rename max)` + ) + } + + if (options.agent && !(CREATABLE_AGENT_FLAVORS as readonly string[]).includes(options.agent)) { + throw new SpawnPeerError('bad_args', `unsupported agent: ${options.agent}`) + } + + const waitActiveSecs = options.waitActiveSecs ?? DEFAULT_WAIT_ACTIVE_SECS + if (!Number.isFinite(waitActiveSecs) || waitActiveSecs <= 0) { + throw new SpawnPeerError('bad_args', 'waitActiveSecs must be a positive number') + } + + const sessionType = options.sessionType ?? 'simple' + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const http = options.http ?? axios + const sleep = options.sleep ?? defaultSleep + const now = options.now ?? Date.now + + const settings = await readSettings() + const localMachineId = (options.localMachineId ?? settings.machineId ?? '').trim() + const jwt = await exchangeJwt(apiUrl, accessToken, http) + const machineId = await resolveTargetMachineId({ + machineId: options.machineId, + machine: options.machine, + localMachineId, + apiUrl, + jwt, + http, + }) + // Match estate hapi-spawn-peer.sh (`MACHINE != LOCAL_MACHINE`): empty local + // settings.machineId still counts as not-equal, so explicit remote targets + // skip the local dir check. + const crossHost = machineId !== localMachineId + // Resolve directory only after local vs remote is known: + // - remote: preserve foreign absolute roots (mixed-OS --machine) + // - local: native path.resolve so Windows `\repo` binds to caller drive + // Relative paths always anchor here (MCP cwd / process.cwd), not runner cwd. + const directory = resolveSpawnPeerDirectory(rawDirectory, { + cwd: options.cwd, + crossHost, + }) + if (crossHost) { + onProgress?.( + `cross-machine spawn: local=${localMachineId || '(unset)'} target=${machineId} ` + + `(dir must exist on TARGET)` + ) + } else { + assertLocalSpawnDirectory(directory) + } + + const hubDefaults = options.hubPeerSpawnDefaults !== undefined + ? options.hubPeerSpawnDefaults + : await fetchHubPeerSpawnDefaults(apiUrl, jwt, http) + const resolved = resolvePeerSpawnConfig({ + agent: options.agent, + permissionMode: options.permissionMode, + model: options.model, + effort: options.effort + }, hubDefaults) + + // Validate explicit permission against the resolved agent (hub default when + // agent is omitted) — not a Claude preview before settings load. + if ( + options.permissionMode + && !getPermissionModesForFlavor(resolved.agent).includes(options.permissionMode) + ) { + throw new SpawnPeerError( + 'bad_args', + `permission mode ${options.permissionMode} is not supported by ${resolved.agent}` + ) + } + + const spawnBody: Record = { + directory, + sessionType, + agent: resolved.agent + } + // Pi/DSH have empty launch-permission catalogs — omit permissionMode so the + // machine route does not 400 invalid_permission_mode on inherited default. + if (getPermissionModesForFlavor(resolved.agent).length > 0) { + spawnBody.permissionMode = resolved.permissionMode + } + if (options.worktreeName) { + spawnBody.worktreeName = options.worktreeName + } + if (resolved.model) { + spawnBody.model = resolved.model + } + if (resolved.effort) { + if (resolved.agent === 'codex' || resolved.agent === 'opencode') { + spawnBody.modelReasoningEffort = resolved.effort + } else { + spawnBody.effort = resolved.effort + } + } + + onProgress?.(`spawning agent=${resolved.agent} permission=${resolved.permissionMode} type=${sessionType} dir=${directory}`) + const spawnResponse = await http.post( + `${apiUrl}/api/machines/${encodeURIComponent(machineId)}/spawn`, + spawnBody, + { + headers: authHeaders(jwt), + timeout: 60_000, + validateStatus: () => true + } + ) + const spawnData = spawnResponse.data as { type?: string; sessionId?: string; message?: string; error?: string } | undefined + const sessionId = typeof spawnData?.sessionId === 'string' ? spawnData.sessionId : '' + if ( + spawnResponse.status < 200 + || spawnResponse.status >= 300 + || spawnData?.type === 'error' + || !sessionId + ) { + const detail = spawnData?.message + || spawnData?.error + || (spawnData?.type === 'success' && !sessionId ? 'spawn returned no sessionId' : `HTTP ${spawnResponse.status}`) + throw new SpawnPeerError('spawn_failed', `spawn failed: ${detail}`) + } + onProgress?.(`spawned ${sessionId}`) + + let renamed = false + if (requestedName) { + try { + const renameResponse = await http.patch( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}`, + { name: requestedName }, + { + headers: authHeaders(jwt), + timeout: 10_000, + validateStatus: () => true + } + ) + if (renameResponse.status >= 200 && renameResponse.status < 300 && renameResponse.data?.ok === true) { + renamed = true + onProgress?.(`renamed → ${requestedName}`) + } else { + onProgress?.('rename failed (continuing to deliver remit)') + } + } catch { + onProgress?.('rename failed (continuing to deliver remit)') + } + } + + onProgress?.(`delivering remit (${message.length} chars) via ping-peer path...`) + let pingResult: { sessionId: string; name: string } | undefined + let deliveryError: unknown + try { + pingResult = await pingPeer({ + sessionId, + message, + waitActiveSecs, + waitForInitialActive: true, + apiUrl, + accessToken, + http, + sleep, + now, + onProgress + }) + } catch (error) { + deliveryError = error + } + + const deadline = now() + waitActiveSecs * 1000 + // Failure path never auto-archives (remit POST may still land). Poll only + // to recover a late-visible remit; unread GETs must not look like success. + // Classify from the *final* verify result: an earlier empty read must not + // stick as empty_session once later transcript GETs become unavailable. + let lastVerify: Exclude | null = null + while (now() <= deadline) { + const verify = await verifySessionRemit(apiUrl, jwt, sessionId, message, http) + if (verify === 'found') { + return { + sessionId, + name: renamed + ? requestedName + : pingResult?.name || sessionId.slice(0, 8) + } + } + lastVerify = verify + if (now() >= deadline) { + break + } + await sleep(POLL_VERIFY_MS) + } + + if (lastVerify !== 'absent') { + throw new SpawnPeerError( + 'verify_failed', + `could not verify remit for ${sessionId} (transcript unread); ` + + `left child running - inspect or archive ${sessionId} before retrying spawn-peer` + ) + } + + // Never auto-archive on the failure path. A timed-out remit POST (or a + // TOCTOU gap between an empty read and archive) can still land work on + // the child; killing it would destroy an in-flight peer. Leave the id + // for the caller to inspect / archive. + const leaveRunning = + `left child running - inspect or archive ${sessionId} before retrying spawn-peer` + if (deliveryError) { + if (deliveryError instanceof SpawnPeerError) { + throw new SpawnPeerError(deliveryError.code, `${deliveryError.message}; ${leaveRunning}`) + } + if (deliveryError instanceof PingPeerError) { + throw new SpawnPeerError(deliveryError.code, `${deliveryError.message}; ${leaveRunning}`) + } + throw new SpawnPeerError( + 'send_failed', + `${deliveryError instanceof Error ? deliveryError.message : String(deliveryError)}; ${leaveRunning}` + ) + } + throw new SpawnPeerError( + 'empty_session', + `session ${sessionId} still has no user message after remit delivery (empty shell); ${leaveRunning}` + ) +} + +export function exitCodeForSpawnPeerError(error: SpawnPeerError): number { + switch (error.code) { + case 'bad_args': + case 'disabled': + case 'auth_failed': + case 'not_found': + case 'ambiguous': + return 2 + case 'spawn_failed': + case 'resume_failed': + return 3 + case 'timeout': + case 'send_failed': + case 'apply_failed': + case 'empty_session': + case 'verify_failed': + return 4 + default: + return 1 + } +} diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 6b805d99..9aa560dd 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -26,6 +26,7 @@ export const TITLE_INSTRUCTION = trimIdent(` inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', + spawnTool: 'hapi_spawn_peer', })} ${SKILL_LOOKUP_INSTRUCTION} `); @@ -46,6 +47,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', + spawnTool: 'hapi_spawn_peer', })} ${SKILL_LOOKUP_INSTRUCTION} `); diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index fcc09f1a..a88b6a31 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -36,7 +36,7 @@ hapi runner start # Run background service for remote session spawning hapi ping-peer --list # Shell peer shortlist (prefer MCP list_peers in-session) ``` -MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Runners that must keep agents out of other sessions' contexts can hide all three with `HAPI_DISABLE_PEER_TOOLS=1` - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools-on-a-runner). +MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message), `spawn_peer` (create with a required remit). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Never treat machine-spawn HTTP 200 as a working peer. In-session `spawn_peer` auto-stamps a durable Parent `[title](/sessions/)` chip onto the remit (UUID is identity; titles are decoration). Runners that must keep agents out of other sessions' contexts can hide all four with `HAPI_DISABLE_PEER_TOOLS=1` - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools-on-a-runner). ### HAPI Hub diff --git a/docs/guide/installation.md b/docs/guide/installation.md index eb57a783..e5dfc096 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -341,7 +341,7 @@ When the hub runs on one host and the runner on another, agents inside runner-sp [Runner host] hapi runner start → spawns session CLIs │ ▼ - agent session → MCP list_peers / inspect_peer / ping_peer + agent session → MCP list_peers / inspect_peer / ping_peer / spawn_peer ``` On the runner host, configure the **same** hub URL and token the hub uses: @@ -357,7 +357,7 @@ Session CLI may export an **explicit** non-default `HAPI_API_URL` (from env or s ##### Disabling peer tools on a runner -Set `HAPI_DISABLE_PEER_TOOLS=1` (also `true`/`yes`) in the runner environment to stop wrapped agents from browsing or messaging other HAPI sessions — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. The switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` from inside a wrapped agent shell fails loudly too. Scope the env to the runner (systemd unit, launchd plist, or runner guard script); operators who leave it unset in their interactive terminal keep full `hapi ping-peer` CLI access. +Set `HAPI_DISABLE_PEER_TOOLS=1` (also `true`/`yes`) in the runner environment to stop wrapped agents from browsing, messaging, or spawning other HAPI sessions — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. The switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` / `spawn_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` / `hapi spawn-peer` from inside a wrapped agent shell fails loudly too. Scope the env to the runner (systemd unit, launchd plist, or runner guard script); operators who leave it unset in their interactive terminal keep full `hapi ping-peer` CLI access. Additional runner commands: diff --git a/hub/src/config/peerSpawnDefaults.ts b/hub/src/config/peerSpawnDefaults.ts new file mode 100644 index 00000000..87ac2a06 --- /dev/null +++ b/hub/src/config/peerSpawnDefaults.ts @@ -0,0 +1,86 @@ +import { + mergePeerSpawnDefaults, + type PeerSpawnDefaults, + type ResolvedPeerSpawnDefaults +} from '@hapi/protocol/peerSpawnDefaults' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol/modes' +import { + getSettingsFile, + readSettingsOrThrow, + updateSettings, + type Settings +} from './settings' + +export type { PeerSpawnDefaults, ResolvedPeerSpawnDefaults } + +/** + * Merge a models patch onto stored overrides. + * Empty / whitespace values clear that flavor’s override (do not keep the prior key). + */ +export function applyPeerSpawnModelsPatch( + existing: PeerSpawnDefaults['models'] | undefined, + patch: NonNullable +): PeerSpawnDefaults['models'] | undefined { + const allowed = new Set(CREATABLE_AGENT_FLAVORS) + const next: NonNullable = { ...(existing ?? {}) } + for (const [flavor, model] of Object.entries(patch)) { + if (!allowed.has(flavor)) { + continue + } + const trimmed = model.trim() + if (!trimmed) { + delete next[flavor] + } else { + next[flavor] = trimmed + } + } + return Object.keys(next).length > 0 ? next : undefined +} + +export function readPeerSpawnDefaultsFromSettings( + settings: Settings +): ResolvedPeerSpawnDefaults { + return mergePeerSpawnDefaults(settings.peerSpawnDefaults) +} + +export async function readPeerSpawnDefaults(dataDir: string): Promise { + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return readPeerSpawnDefaultsFromSettings(settings) +} + +export function applyPeerSpawnDefaultsPatch( + current: Settings, + patch: PeerSpawnDefaults +): PeerSpawnDefaults { + const existing = current.peerSpawnDefaults ?? {} + const next: PeerSpawnDefaults = { + ...existing, + ...(patch.agent !== undefined ? { agent: patch.agent } : {}), + ...(patch.permissionMode !== undefined ? { permissionMode: patch.permissionMode } : {}) + } + if (patch.models !== undefined) { + const nextModels = applyPeerSpawnModelsPatch(existing.models, patch.models) + if (nextModels !== undefined) { + next.models = nextModels + } else { + delete next.models + } + } + return next +} + +export async function writePeerSpawnDefaults( + dataDir: string, + patch: PeerSpawnDefaults +): Promise { + return updateSettings(getSettingsFile(dataDir), (current) => { + const settings: Settings = { + ...current, + peerSpawnDefaults: applyPeerSpawnDefaultsPatch(current, patch) + } + return { + settings, + result: readPeerSpawnDefaultsFromSettings(settings) + } + }) +} diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index cb92ca90..7234158e 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -41,6 +41,8 @@ export interface Settings { * into supported flavor system / developer instructions. Default off. */ sessionSummaryContract?: boolean + /** Hub-wide defaults for MCP/CLI spawn_peer (Layer 0 A2A). */ + peerSpawnDefaults?: import('@hapi/protocol/peerSpawnDefaults').PeerSpawnDefaults /** * When true, web chat shows a compact AGENT_NOTIFY_SUMMARY row. * Default off: render/copy strip the footer; store stays raw. diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts index 10ddfd6a..80dbe74b 100644 --- a/hub/src/web/routes/hubSettings.test.ts +++ b/hub/src/web/routes/hubSettings.test.ts @@ -3,12 +3,17 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Hono } from 'hono' +import { STOCK_PEER_SPAWN_DEFAULTS } from '@hapi/protocol/peerSpawnDefaults' import type { WebAppEnv } from '../middleware/auth' import { createHubSettingsRoutes } from './hubSettings' import { writeSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' import { writeSessionSummaryInChatEnabled } from '../../config/sessionSummaryInChat' const directories: string[] = [] +const stockPeerSpawnDefaults = { + ...STOCK_PEER_SPAWN_DEFAULTS, + permissionMode: 'bypassPermissions' as const +} afterEach(async () => { await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) @@ -27,14 +32,15 @@ describe('GET/PUT /api/hub-settings', () => { return { app, dataDir } } - it('returns default off for emit and chat display', async () => { + it('returns default off for emit, chat display, and stock peer spawn defaults', async () => { const { app } = await createApp() const response = await app.request('/api/hub-settings') expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') expect(await response.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: false + sessionSummaryInChat: false, + peerSpawnDefaults: stockPeerSpawnDefaults }) }) @@ -48,13 +54,15 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + peerSpawnDefaults: stockPeerSpawnDefaults }) const get = await app.request('/api/hub-settings') expect(await get.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + peerSpawnDefaults: stockPeerSpawnDefaults }) }) @@ -70,18 +78,76 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + peerSpawnDefaults: stockPeerSpawnDefaults }) }) - it('rejects empty body', async () => { + it('persists peer spawn defaults', async () => { const { app } = await createApp() - const response = await app.request('/api/hub-settings', { + const put = await app.request('/api/hub-settings', { method: 'PUT', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}) + body: JSON.stringify({ + peerSpawnDefaults: { + agent: 'cursor', + permissionMode: 'yolo', + models: { cursor: 'auto' } + } + }) + }) + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'cursor', + permissionMode: 'yolo', + models: { + claude: 'sonnet', + cursor: 'auto' + } + } + }) + }) + + it('clears a saved peer spawn model override with an empty string', async () => { + const { app } = await createApp() + await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'default', + models: { codex: 'gpt-5' } + } + }) + }) + + const clear = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'default', + models: { codex: '' } + } + }) + }) + expect(clear.status).toBe(200) + expect(await clear.json()).toEqual({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'default', + models: { + claude: 'sonnet' + } + } }) - expect(response.status).toBe(400) }) it('rejects invalid body', async () => { @@ -94,6 +160,28 @@ describe('GET/PUT /api/hub-settings', () => { expect(response.status).toBe(400) }) + it('rejects retired gemini as peer spawn default agent', async () => { + const { app } = await createApp() + const response = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + peerSpawnDefaults: { agent: 'gemini' } + }) + }) + expect(response.status).toBe(400) + }) + + it('rejects empty body', async () => { + const { app } = await createApp() + const response = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }) + expect(response.status).toBe(400) + }) + it('rejects non-default namespaces for PUT but allows GET', async () => { const { app, dataDir } = await createApp('default') await writeSessionSummaryInChatEnabled(dataDir, true) @@ -109,7 +197,8 @@ describe('GET/PUT /api/hub-settings', () => { expect(get.status).toBe(200) expect(await get.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: true + sessionSummaryInChat: true, + peerSpawnDefaults: stockPeerSpawnDefaults }) const put = await tenantApp.request('/api/hub-settings', { @@ -127,7 +216,8 @@ describe('GET/PUT /api/hub-settings', () => { const response = await app.request('/api/hub-settings') expect(await response.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + peerSpawnDefaults: stockPeerSpawnDefaults }) }) }) diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts index 85a6af02..f20862d0 100644 --- a/hub/src/web/routes/hubSettings.ts +++ b/hub/src/web/routes/hubSettings.ts @@ -1,11 +1,13 @@ import { Hono } from 'hono' import { UpdateHubSettingsRequestSchema, type HubSettingsResponse } from '@hapi/protocol' +import { mergePeerSpawnDefaults } from '@hapi/protocol/peerSpawnDefaults' import { getSettingsFile, readSettingsOrThrowCached, updateSettings, type Settings } from '../../config/settings' +import { applyPeerSpawnDefaultsPatch } from '../../config/peerSpawnDefaults' import type { WebAppEnv } from '../middleware/auth' const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' @@ -13,7 +15,8 @@ const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' function toHubSettings(settings: Settings): HubSettingsResponse { return { sessionSummaryContract: settings.sessionSummaryContract === true, - sessionSummaryInChat: settings.sessionSummaryInChat === true + sessionSummaryInChat: settings.sessionSummaryInChat === true, + peerSpawnDefaults: mergePeerSpawnDefaults(settings.peerSpawnDefaults) } } @@ -45,6 +48,12 @@ export function createHubSettingsRoutes(dataDir: string): Hono { if (parsed.data.sessionSummaryInChat !== undefined) { settings.sessionSummaryInChat = parsed.data.sessionSummaryInChat } + if (parsed.data.peerSpawnDefaults !== undefined) { + settings.peerSpawnDefaults = applyPeerSpawnDefaultsPatch( + current, + parsed.data.peerSpawnDefaults + ) + } return { settings, result: toHubSettings(settings) diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 7197c355..7b2d2b47 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, mock } from 'bun:test' import { Hono } from 'hono' import type { Machine, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' @@ -154,6 +154,275 @@ describe('machines routes', () => { expect(capturedPermissionMode).toBe('auto') }) + it('applies hub peerSpawnDefaults when agent and permissionMode are omitted', async () => { + const machine = createMachine() + let capturedAgent: string | undefined + let capturedPermissionMode: string | undefined + let capturedModel: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + agent?: string, + model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedAgent = agent + capturedModel = model + capturedPermissionMode = permissionMode + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'claude', + permissionMode: 'yolo', + models: { claude: 'sonnet[1m]' } + }) + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }) + }) + + expect(response.status).toBe(200) + expect(capturedAgent).toBe('claude') + expect(capturedPermissionMode).toBe('bypassPermissions') + expect(capturedModel).toBe('sonnet[1m]') + }) + + it('keeps explicit spawn overrides over hub peerSpawnDefaults', async () => { + const machine = createMachine() + let capturedAgent: string | undefined + let capturedPermissionMode: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + agent?: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedAgent = agent + capturedPermissionMode = permissionMode + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'cursor', + permissionMode: 'yolo' + }) + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'claude', + permissionMode: 'default' + }) + }) + + expect(response.status).toBe(200) + expect(capturedAgent).toBe('claude') + expect(capturedPermissionMode).toBe('default') + }) + + it('preserves explicit yolo boolean when permissionMode is omitted', async () => { + const machine = createMachine() + let capturedYolo: boolean | undefined + let capturedPermissionMode: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + _agent?: string, + _model?: string, + _modelReasoningEffort?: string, + yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedYolo = yolo + capturedPermissionMode = permissionMode + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'claude', + permissionMode: 'yolo', + models: { claude: 'sonnet' } + }) + })) + + for (const yolo of [false, true] as const) { + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'claude', + yolo + }) + }) + expect(response.status).toBe(200) + expect(capturedYolo).toBe(yolo) + expect(capturedPermissionMode).toBeUndefined() + } + }) + + + it('rejects incompatible explicit permissionMode for the resolved agent', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: () => { throw new Error('must not spawn') } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'claude', + permissionMode: 'yolo', + models: { claude: 'sonnet' } + }) + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'claude', + permissionMode: 'read-only' + }) + }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ code: 'invalid_permission_mode' }) + }) + + + it('does not inject hub model when agent is explicit and model omitted', async () => { + const machine = createMachine() + let capturedModel: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + _agent?: string, + model?: string + ) => { + capturedModel = model + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'opencode', + permissionMode: 'default', + models: { opencode: 'provider/hub-model' } + }) + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'opencode' + }) + }) + expect(response.status).toBe(200) + expect(capturedModel).toBeUndefined() + }) + + it('returns 500 when hub spawn defaults cannot be loaded', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: () => { throw new Error('must not spawn') } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => { + throw new Error('settings unreadable') + } + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }) + }) + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + code: 'hub_spawn_defaults_unavailable' + }) + }) + it('returns Codex models for an online machine', async () => { const machine = createMachine() const engine = { @@ -447,6 +716,31 @@ describe('machines routes', () => { expect(captured![15]).toBeUndefined() }) + it('rejects spawn bodies that include message/prompt/text instead of silently stripping', async () => { + const machine = createMachine() + const spawnSession = () => { throw new Error('must not spawn') } + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession, + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { c.set('namespace', 'default'); await next() }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + for (const key of ['message', 'prompt', 'text'] as const) { + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/x', [key]: 'do the work' }) + }) + expect(response.status).toBe(400) + const body = await response.json() as { error?: string; code?: string } + expect(body.code).toBe('spawn_remit_not_supported') + expect(body.error).toMatch(/spawn-peer|messages/i) + } + }) + it('accepts an explicit remote AGY machine spawn', async () => { const machine = createMachine() let captured: unknown[] | null = null @@ -496,6 +790,36 @@ describe('machines routes', () => { } }) + it('rejects non-remote startingMode when hub default agent is AGY and request omits agent', async () => { + const machine = createMachine() + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'session-1' })) + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession, + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { c.set('namespace', 'default'); await next() }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine, { + getPeerSpawnDefaults: () => ({ + agent: 'agy', + permissionMode: 'default', + models: {} + }) + })) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/x', startingMode: 'pty' }) + }) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'AGY only supports remote mode' + }) + expect(spawnSession).not.toHaveBeenCalled() + }) + it('returns 400 when /opencode-models is called without cwd', async () => { const machine = createMachine() const engine = { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index f5024613..d0f2e047 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -6,16 +6,60 @@ import { RenameMachineRequestSchema, SpawnSessionRequestSchema } from '@hapi/protocol' +import { + resolvePeerSpawnConfig, + STOCK_PEER_SPAWN_DEFAULTS, + type PeerSpawnDefaults, + type ResolvedPeerSpawnDefaults +} from '@hapi/protocol/peerSpawnDefaults' +import { getPermissionModesForFlavor } from '@hapi/protocol/modes' import { Hono } from 'hono' import { RPC_TARGET_MISSING_ERROR_CODE } from '@hapi/protocol/rpcMethods' +import { readPeerSpawnDefaults } from '../../config/peerSpawnDefaults' import type { SyncEngine } from '../../sync/syncEngine' import { RpcTargetMissingError } from '../../sync/rpcGateway' import type { WebAppEnv } from '../middleware/auth' import { requireMachine } from './guards' -export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono { - const app = new Hono() +const SPAWN_REMIT_FIELDS = ['message', 'prompt', 'text'] as const +function spawnRemitField(body: unknown): string | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return null + } + for (const key of SPAWN_REMIT_FIELDS) { + if (Object.prototype.hasOwnProperty.call(body, key)) { + return key + } + } + return null +} + +export type MachinesRoutesOptions = { + dataDir?: string + /** Test seam: override hub peer spawn defaults (resolved or stored partial). */ + getPeerSpawnDefaults?: () => PeerSpawnDefaults | ResolvedPeerSpawnDefaults | null | Promise +} + +async function loadPeerSpawnDefaultsForSpawn( + options: MachinesRoutesOptions | undefined +): Promise { + if (options?.getPeerSpawnDefaults) { + return await options.getPeerSpawnDefaults() + } + if (options?.dataDir) { + // Do not swallow read failures into stock yolo — that would override + // operator-configured restrictive defaults on a transient I/O error. + return await readPeerSpawnDefaults(options.dataDir) + } + return null +} + +export function createMachinesRoutes( + getSyncEngine: () => SyncEngine | null, + options?: MachinesRoutesOptions +): Hono { + const app = new Hono() app.get('/machines', (c) => { const engine = getSyncEngine() if (!engine) { @@ -85,32 +129,96 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) + const remitField = spawnRemitField(body) + if (remitField) { + return c.json({ + error: `POST /api/machines/:id/spawn does not accept '${remitField}'. Machine spawn creates an empty composer. Use hapi spawn-peer / MCP spawn_peer, or POST /api/sessions/:id/messages after spawn.`, + code: 'spawn_remit_not_supported' + }, 400) + } const parsed = SpawnSessionRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } - if ( - (parsed.data.agent === 'agy' || parsed.data.agent === 'dsh') - && parsed.data.startingMode - && parsed.data.startingMode !== 'remote' - ) { - return c.json({ error: `${parsed.data.agent.toUpperCase()} only supports remote mode` }, 400) - } const startingMode = parsed.data.startingMode const namespace = c.get('namespace') + // Apply hub peerSpawnDefaults when agent / permissionMode / model are omitted + // so scavenger/raw machine spawn matches Settings → General → Agents (and stock yolo). + let hubDefaults: PeerSpawnDefaults | ResolvedPeerSpawnDefaults | null + try { + hubDefaults = await loadPeerSpawnDefaultsForSpawn(options) + } catch (error) { + const message = error instanceof Error ? error.message : 'Cannot load hub spawn defaults' + return c.json({ error: message, code: 'hub_spawn_defaults_unavailable' as const }, 500) + } + + const effectiveAgent = parsed.data.agent + ?? hubDefaults?.agent + ?? STOCK_PEER_SPAWN_DEFAULTS.agent + // Validate startingMode against the resolved agent (hub default may be + // AGY/DSH even when the request omitted agent). + if ( + (effectiveAgent === 'agy' || effectiveAgent === 'dsh') + && startingMode + && startingMode !== 'remote' + ) { + return c.json({ error: `${effectiveAgent.toUpperCase()} only supports remote mode` }, 400) + } + if ( + parsed.data.permissionMode !== undefined + && !getPermissionModesForFlavor(effectiveAgent).includes(parsed.data.permissionMode) + ) { + return c.json({ + error: `permission mode ${parsed.data.permissionMode} is not supported by ${effectiveAgent}`, + code: 'invalid_permission_mode' as const + }, 400) + } + + const resolved = resolvePeerSpawnConfig({ + ...(parsed.data.agent !== undefined ? { agent: parsed.data.agent } : {}), + ...(parsed.data.permissionMode !== undefined ? { permissionMode: parsed.data.permissionMode } : {}), + ...(parsed.data.model !== undefined ? { model: parsed.data.model } : {}), + ...(parsed.data.effort !== undefined ? { effort: parsed.data.effort } : {}) + }, hubDefaults) + + // When the caller supplies an explicit yolo boolean without a native + // permissionMode, preserve the runner's boolean path — injecting a hub + // mode would override yolo:false (runner prioritizes permissionMode). + // Flavors with an empty launch catalog (pi/dsh) must omit permissionMode. + const permissionModeForSpawn = + parsed.data.permissionMode === undefined && parsed.data.yolo !== undefined + ? undefined + : getPermissionModesForFlavor(resolved.agent).length === 0 + ? undefined + : resolved.permissionMode + + // Apply hub model only when the caller omitted agent+model (full defaults + // path). An explicit agent with model omitted means native Default — + // do not inject the hub model for that flavor. + const modelForSpawn = parsed.data.model !== undefined + ? parsed.data.model + : parsed.data.agent === undefined + ? resolved.model + : undefined + const effortForSpawn = parsed.data.effort !== undefined + ? parsed.data.effort + : parsed.data.agent === undefined + ? resolved.effort + : undefined + const result = await engine.spawnSession( machineId, parsed.data.directory, - parsed.data.agent, - parsed.data.model, + resolved.agent, + modelForSpawn, parsed.data.modelReasoningEffort, parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, undefined, // resumeSessionId - parsed.data.effort, - parsed.data.permissionMode, + effortForSpawn, + permissionModeForSpawn, parsed.data.serviceTier, undefined, parsed.data.collaborationMode, diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 9ee5a290..8dfaf8c3 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -291,7 +291,7 @@ function createWebApp(options: { app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) - app.route('/api', createMachinesRoutes(options.getSyncEngine)) + app.route('/api', createMachinesRoutes(options.getSyncEngine, { dataDir: configuration.dataDir })) app.route('/api', createStorageRoutes(configuration.dbPath)) app.route('/api', createHubSettingsRoutes(configuration.dataDir)) app.route('/api', createUsageRoutes(options.store)) diff --git a/shared/package.json b/shared/package.json index 35dd5bd4..9f011fa6 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,10 +13,12 @@ "./buildInfo": "./src/buildInfo.ts", "./conversationHistory": "./src/conversationHistory.ts", "./modes": "./src/modes.ts", + "./peerSpawnDefaults": "./src/peerSpawnDefaults.ts", "./rpcMethods": "./src/rpcMethods.ts", "./runnerCapabilities": "./src/runnerCapabilities.ts", "./schemas": "./src/schemas.ts", "./sessionCitation": "./src/sessionCitation.ts", + "./peerParentStamp": "./src/peerParentStamp.ts", "./sessionExport": "./src/sessionExport.ts", "./sessionLifecycle": "./src/sessionLifecycle.ts", "./types": "./src/types.ts", diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 6b619bdb..9c7e92c8 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -9,6 +9,10 @@ import { SessionSchema } from './schemas' import { AgentFlavorSchema } from './modes' +import { + PeerSpawnDefaultsSchema, + ResolvedPeerSpawnDefaultsSchema +} from './peerSpawnDefaults' import type { DecryptedMessage, Machine, @@ -73,7 +77,8 @@ export type CreateSessionResponse = z.infer export const HubSettingsResponseSchema = z.object({ sessionSummaryContract: z.boolean(), /** Show compact AGENT_NOTIFY_SUMMARY in chat (default off / hide). */ - sessionSummaryInChat: z.boolean() + sessionSummaryInChat: z.boolean(), + peerSpawnDefaults: ResolvedPeerSpawnDefaultsSchema }) export type HubSettingsResponse = z.infer @@ -81,10 +86,14 @@ export type HubSettingsResponse = z.infer export const UpdateHubSettingsRequestSchema = z .object({ sessionSummaryContract: z.boolean().optional(), - sessionSummaryInChat: z.boolean().optional() + sessionSummaryInChat: z.boolean().optional(), + peerSpawnDefaults: PeerSpawnDefaultsSchema.optional() }) .refine( - (data) => data.sessionSummaryContract !== undefined || data.sessionSummaryInChat !== undefined, + (data) => + data.sessionSummaryContract !== undefined + || data.sessionSummaryInChat !== undefined + || data.peerSpawnDefaults !== undefined, { message: 'At least one hub setting field is required' } ) @@ -377,8 +386,11 @@ export const SessionServiceTierRequestSchema = z.object({ export type SessionServiceTierRequest = z.infer +/** Hub session display-name ceiling (`PATCH /api/sessions/:id`). */ +export const SESSION_NAME_MAX_LENGTH = 255 + export const RenameSessionRequestSchema = z.object({ - name: z.string().min(1).max(255).optional(), + name: z.string().min(1).max(SESSION_NAME_MAX_LENGTH).optional(), titleLocked: z.boolean().optional() }).refine(value => value.name !== undefined || value.titleLocked !== undefined, { message: 'name or titleLocked is required' diff --git a/shared/src/index.ts b/shared/src/index.ts index 637d1744..0ad4d236 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './peerSpawnDefaults' export * from './scratchlistAttachments' export * from './workGraph' export * from './apiTypes' @@ -19,6 +20,7 @@ export * from './sessionReadState' export * from './sessionSummary' export * from './sessionOrder' export * from './sessionCitation' +export * from './peerParentStamp' export * from './sessionLifecycle' export * from './sessionExport' export * from './piThinkingLevel' diff --git a/shared/src/peerParentStamp.test.ts b/shared/src/peerParentStamp.test.ts new file mode 100644 index 00000000..9fe59ff0 --- /dev/null +++ b/shared/src/peerParentStamp.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'bun:test' +import { + ParentStampError, + ensureParentStamp, + extractParentSessionIdFromRemit, + formatParentStampBlock, + formatSessionChip, + remitCitesSessionId, +} from './peerParentStamp' +import { extractSessionCitationIds, normalizeSessionIdPrefix } from './sessionCitation' + +const PARENT_ID = '4bd4d2b9-e114-4e03-af4c-03e9e4f7439e' +const AGENT_SESSION_ID = 'cursor-agent-session-abc' + +describe('formatSessionChip', () => { + it('builds a durable [title](/sessions/) chip', () => { + expect(formatSessionChip({ sessionId: PARENT_ID, name: 'Producer to Infra' })).toBe( + `[Producer to Infra](/sessions/${PARENT_ID})` + ) + }) + + it('falls back to a short id when name is missing (never invents a nickname)', () => { + expect(formatSessionChip({ sessionId: PARENT_ID })).toBe( + `[4bd4d2b9](/sessions/${PARENT_ID})` + ) + }) +}) + +describe('formatParentStampBlock', () => { + it('stamps Parent with the full UUID chip and optional agentSessionId', () => { + const block = formatParentStampBlock({ + sessionId: PARENT_ID, + name: 'cursor - tooling/meta bot', + agentSessionId: AGENT_SESSION_ID, + }) + expect(block).toContain('## Parent') + expect(block).toContain(`[cursor - tooling/meta bot](/sessions/${PARENT_ID})`) + expect(block).toContain(`agentSessionId: \`${AGENT_SESSION_ID}\``) + expect(block.toLowerCase()).toMatch(/uuid|decoration|rename/) + }) +}) + +describe('ensureParentStamp', () => { + it('prepends a Parent block when the remit lacks the parent UUID', () => { + const result = ensureParentStamp('## Your assignment\n- do the work', { + sessionId: PARENT_ID, + name: 'Orchestrator', + }) + expect(result.stamped).toBe(true) + expect(result.alreadyPresent).toBe(false) + expect(result.message.startsWith('## Parent')).toBe(true) + expect(result.message).toContain(`/sessions/${PARENT_ID}`) + expect(result.message).toContain('## Your assignment') + }) + + it('is a no-op when the remit already has a ## Parent markdown chip for this UUID', () => { + const body = `## Parent\n- Orchestrator chip: [Old Title](/sessions/${PARENT_ID})\n\nGo.` + const result = ensureParentStamp(body, { + sessionId: PARENT_ID, + name: 'Renamed', + }) + expect(result.stamped).toBe(false) + expect(result.alreadyPresent).toBe(true) + expect(result.message).toBe(body) + }) + + it('stays idempotent when the parent title contains brackets (escaped in the chip label)', () => { + const identity = { sessionId: PARENT_ID, name: 'Team [Infra]' } + const first = ensureParentStamp('Do the work.', identity) + expect(first.stamped).toBe(true) + expect(first.message).toContain(`[Team \\[Infra\\]](/sessions/${PARENT_ID})`) + const second = ensureParentStamp(first.message, { + sessionId: PARENT_ID, + name: 'Renamed [Again]', + }) + expect(second.stamped).toBe(false) + expect(second.alreadyPresent).toBe(true) + expect(second.message).toBe(first.message) + expect(second.message.match(/## Parent/g)?.length).toBe(1) + }) + + it('does not hang on a long nonmatching backslash run under ## Parent', () => { + const body = `## Parent\n- [${'\\'.repeat(40)}\n\nDo the work.` + const started = Date.now() + const result = ensureParentStamp(body, { + sessionId: PARENT_ID, + name: 'Parent', + }) + expect(Date.now() - started).toBeLessThan(200) + expect(result.stamped).toBe(true) + expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`) + }) + + it('still stamps when the remit only has a bare parent citation (no ## Parent block)', () => { + // Bare /sessions/ must not suppress the Parent chip - chat UI only + // promotes citations under ## Parent into the sender-style chip. + const body = `See /sessions/${PARENT_ID} for context.\n\nDo the work.` + const result = ensureParentStamp(body, { + sessionId: PARENT_ID, + name: 'Parent', + }) + expect(result.stamped).toBe(true) + expect(result.alreadyPresent).toBe(false) + expect(result.message.startsWith('## Parent')).toBe(true) + expect(result.message).toContain(body) + }) + + it('still stamps when ## Parent is empty and the UUID only appears under a later heading', () => { + const body = `## Parent\n\n## Context\nSee [other](/sessions/${PARENT_ID}) for prior work.\n\nDo the work.` + const result = ensureParentStamp(body, { + sessionId: PARENT_ID, + name: 'Parent', + }) + expect(result.stamped).toBe(true) + expect(result.alreadyPresent).toBe(false) + expect(result.message.startsWith('## Parent')).toBe(true) + expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`) + }) + + it('still stamps when ## Parent only has a bare /sessions path (no markdown chip)', () => { + const body = `## Parent\n- source: /sessions/${PARENT_ID}\n\nDo the work.` + const result = ensureParentStamp(body, { + sessionId: PARENT_ID, + name: 'Parent', + }) + expect(result.stamped).toBe(true) + expect(result.alreadyPresent).toBe(false) + expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`) + }) + + it('fail-closes when requireParent and parent id is missing', () => { + expect(() => + ensureParentStamp('brief', null, { requireParent: true }) + ).toThrow(ParentStampError) + expect(() => + ensureParentStamp('brief', { sessionId: ' ' }, { requireParent: true }) + ).toThrow(/parent session id/i) + }) + + it('allows missing parent when requireParent is false (outside-session CLI)', () => { + const result = ensureParentStamp('brief', undefined, { requireParent: false }) + expect(result.message).toBe('brief') + expect(result.stamped).toBe(false) + }) +}) + +describe('P2 falsification: spawn → rename → ping by handoff UUID only', () => { + it('keeps the handoff UUID usable after the parent display name changes', () => { + const originalName = 'Producer' + const renamedName = 'Producer to Infra' + const stamped = ensureParentStamp('Own steps: implement P0', { + sessionId: PARENT_ID, + name: originalName, + agentSessionId: AGENT_SESSION_ID, + }) + + // Parent is renamed mid-flight; remit text still has the old decorative title. + expect(stamped.message).toContain(originalName) + expect(stamped.message).not.toContain(renamedName) + + // Child must address parent by UUID from the handoff chip - not invent titles. + const handoffIds = extractSessionCitationIds(stamped.message) + expect(handoffIds).toEqual([PARENT_ID]) + expect(extractParentSessionIdFromRemit(stamped.message)).toBe(PARENT_ID) + expect(remitCitesSessionId(stamped.message, PARENT_ID)).toBe(true) + + const chip = stamped.message.match(/\[[^\]]*\]\(\/sessions\/[^)]+\)/)?.[0] ?? '' + expect(normalizeSessionIdPrefix(chip)).toBe(PARENT_ID) + + // Rename is a non-event for routing: UUID is identity; title is decoration. + const pingTarget = extractParentSessionIdFromRemit(stamped.message) + expect(pingTarget).toBe(PARENT_ID) + expect(pingTarget).not.toBe(renamedName) + expect(pingTarget).not.toBe(originalName) + }) +}) diff --git a/shared/src/peerParentStamp.ts b/shared/src/peerParentStamp.ts new file mode 100644 index 00000000..4451b82e --- /dev/null +++ b/shared/src/peerParentStamp.ts @@ -0,0 +1,172 @@ +/** + * Rename-proof parent identity for peer spawn/ping remits (heavygee/hapi#175). + * + * Display names are decoration. Hub session UUIDs (and agentSessionId across + * hub-row churn) are identity. Orchestrated remits must stamp a durable + * `[title](/sessions/)` Parent chip so children never invent nicknames + * like `upstream` when a parent is renamed mid-flight. + */ + +import { extractSessionCitationIds } from './sessionCitation' + +export type PeerParentIdentity = { + sessionId: string + name?: string | null + agentSessionId?: string | null +} + +export type EnsureParentStampOptions = { + /** + * When true (MCP / in-session spawn), missing parent id fails closed. + * Outside-session CLI may omit this and allow unattributed remits with a + * warning at the call site. + */ + requireParent?: boolean +} + +export type EnsureParentStampResult = { + message: string + stamped: boolean + alreadyPresent: boolean +} + +export class ParentStampError extends Error { + constructor(message: string) { + super(message) + this.name = 'ParentStampError' + } +} + +/** Escape markdown link label so brackets in titles do not break the chip. */ +function escapeChipTitle(title: string): string { + return title.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]') +} + +/** + * Build a composer-compatible session chip. Title is decorative; UUID is identity. + */ +export function formatSessionChip(identity: PeerParentIdentity): string { + const sessionId = identity.sessionId.trim() + const rawTitle = (identity.name ?? '').trim() || sessionId.slice(0, 8) + return `[${escapeChipTitle(rawTitle)}](/sessions/${sessionId})` +} + +/** + * Canonical Parent block prepended to orchestrated spawn remits. + */ +export function formatParentStampBlock(identity: PeerParentIdentity): string { + const sessionId = identity.sessionId.trim() + const lines = [ + '## Parent', + `- Orchestrator chip (mandatory): ${formatSessionChip({ ...identity, sessionId })}`, + ' Title is decoration and may change mid-flight - address parent by UUID only; never invent nicknames.', + ] + const agentSessionId = (identity.agentSessionId ?? '').trim() + if (agentSessionId) { + lines.push(`- agentSessionId: \`${agentSessionId}\``) + } + return lines.join('\n') +} + +export function remitCitesSessionId(message: string, sessionId: string): boolean { + const id = sessionId.trim() + if (!id) return false + return extractSessionCitationIds(message).includes(id) +} + +/** Slice the `## Parent` section body (through the next `##` heading), or null. */ +function extractParentSection(message: string): string | null { + if (!message) return null + const parentHeader = /^##\s*Parent\b/im.exec(message) + if (!parentHeader || parentHeader.index === undefined) return null + const fromParent = message.slice(parentHeader.index) + const afterHeader = fromParent.slice(parentHeader[0].length) + const nextHeading = /\n##\s/.exec(afterHeader) + return nextHeading + ? fromParent.slice(0, parentHeader[0].length + (nextHeading.index ?? 0)) + : fromParent +} + +/** + * True when the Parent section contains a markdown chip + * `[title](/sessions/)` (not a bare path). + */ +export function parentSectionHasSessionChip( + message: string, + sessionId: string +): boolean { + const id = sessionId.trim() + if (!id) return false + const section = extractParentSection(message) + if (!section) return false + // Chip form only - bare /sessions/ does not render as the Parent sender chip. + // Labels may contain escaped brackets (`\]` / `\[`) from escapeChipTitle. + // Exclude `\` from the single-char class so `\\.` is the only escape parse + // (avoids exponential backtracking on long nonmatching backslash runs). + const chipRe = + /\[(?:\\.|[^\\\]])*\]\((?:[^)\s]*\/)?sessions\/([^/?#)\s]+)\)/g + for (const match of section.matchAll(chipRe)) { + const raw = match[1] + if (!raw) continue + try { + if (decodeURIComponent(raw) === id) return true + } catch { + if (raw === id) return true + } + } + return false +} + +/** + * Prefer the first `/sessions/` under a `## Parent` heading when present; + * otherwise the first citation in the remit (handoff UUID). + */ +export function extractParentSessionIdFromRemit(message: string): string | null { + if (!message) return null + const section = extractParentSection(message) + if (section) { + const ids = extractSessionCitationIds(section) + if (ids[0]) return ids[0]! + } + return extractSessionCitationIds(message)[0] ?? null +} + +/** + * Ensure an orchestrated remit cites the parent under a `## Parent` chip block. + * Idempotent when a `## Parent` section already has a markdown chip for this + * UUID. Bare citations (or an empty Parent heading) do not suppress the stamp. + */ +export function ensureParentStamp( + message: string, + identity: PeerParentIdentity | null | undefined, + options: EnsureParentStampOptions = {} +): EnsureParentStampResult { + const requireParent = options.requireParent === true + const sessionId = (identity?.sessionId ?? '').trim() + + if (!sessionId) { + if (requireParent) { + throw new ParentStampError( + 'in-session spawn requires a parent session id ' + + '(HAPI_SESSION_ID / MCP client session). ' + + 'Refusing unattributed remit for an orchestrated child.' + ) + } + return { message, stamped: false, alreadyPresent: false } + } + + if (parentSectionHasSessionChip(message, sessionId)) { + return { message, stamped: false, alreadyPresent: true } + } + + const block = formatParentStampBlock({ + sessionId, + name: identity?.name, + agentSessionId: identity?.agentSessionId, + }) + const stampedMessage = message.trim().length > 0 + ? `${block}\n\n${message}` + : block + + return { message: stampedMessage, stamped: true, alreadyPresent: false } +} diff --git a/shared/src/peerSpawnDefaults.test.ts b/shared/src/peerSpawnDefaults.test.ts new file mode 100644 index 00000000..301ac14f --- /dev/null +++ b/shared/src/peerSpawnDefaults.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'bun:test' +import { + PeerSpawnDefaultsSchema, + STOCK_PEER_SPAWN_DEFAULTS, + mergePeerSpawnDefaults, + resolvePeerSpawnConfig, + resolvePermissionModeForFlavor +} from './peerSpawnDefaults' + +describe('resolvePermissionModeForFlavor', () => { + it('maps stock yolo to bypassPermissions for claude', () => { + expect(resolvePermissionModeForFlavor('yolo', 'claude')).toBe('bypassPermissions') + }) + + it('keeps yolo for codex', () => { + expect(resolvePermissionModeForFlavor('yolo', 'codex')).toBe('yolo') + }) + + it('keeps explicit bypassPermissions for claude', () => { + expect(resolvePermissionModeForFlavor('bypassPermissions', 'claude')).toBe('bypassPermissions') + }) + + it('remaps resolved Claude bypassPermissions to codex yolo', () => { + expect(resolvePermissionModeForFlavor('bypassPermissions', 'codex')).toBe('yolo') + }) + + it('remaps resolved Claude bypassPermissions to cursor yolo', () => { + expect(resolvePermissionModeForFlavor('bypassPermissions', 'cursor')).toBe('yolo') + }) + + it('remaps Kimi safe-yolo onto Codex yolo (launchable auto-approval)', () => { + expect(resolvePermissionModeForFlavor('safe-yolo', 'codex')).toBe('yolo') + }) + + it('falls back to AGY request-review instead of Claude default', () => { + expect(resolvePermissionModeForFlavor('default', 'agy')).toBe('request-review') + }) +}) + +describe('mergePeerSpawnDefaults', () => { + it('returns stock when hub settings are unset', () => { + expect(mergePeerSpawnDefaults(null)).toEqual({ + ...STOCK_PEER_SPAWN_DEFAULTS, + permissionMode: 'bypassPermissions' + }) + expect(mergePeerSpawnDefaults(undefined)).toEqual({ + ...STOCK_PEER_SPAWN_DEFAULTS, + permissionMode: 'bypassPermissions' + }) + }) + + it('merges partial hub overrides', () => { + expect(mergePeerSpawnDefaults({ + agent: 'cursor', + models: { cursor: 'auto' } + })).toEqual({ + agent: 'cursor', + permissionMode: 'yolo', + models: { + claude: 'sonnet', + cursor: 'auto' + } + }) + }) +}) + +describe('resolvePeerSpawnConfig', () => { + it('prefers explicit args over hub settings over stock', () => { + expect(resolvePeerSpawnConfig( + { agent: 'codex', permissionMode: 'read-only', model: 'gpt-5' }, + { agent: 'cursor', permissionMode: 'auto', models: { cursor: 'composer-2.5' } } + )).toEqual({ + agent: 'codex', + permissionMode: 'read-only', + model: 'gpt-5' + }) + }) + + it('falls back to hub model for resolved agent', () => { + expect(resolvePeerSpawnConfig( + { agent: 'claude' }, + { models: { claude: 'opus' } } + )).toEqual({ + agent: 'claude', + permissionMode: 'bypassPermissions', + model: 'opus' + }) + }) + + it('uses stock defaults when hub settings are unavailable', () => { + expect(resolvePeerSpawnConfig({})).toEqual({ + agent: 'claude', + permissionMode: 'bypassPermissions', + model: 'sonnet' + }) + }) + + it('re-maps stock yolo permission when agent override differs from hub default agent', () => { + expect(resolvePeerSpawnConfig( + { agent: 'cursor' }, + { agent: 'claude', permissionMode: 'yolo' } + )).toEqual({ + agent: 'cursor', + permissionMode: 'yolo' + }) + }) + + it('preserves auto-approval when hub returns resolved Claude bypassPermissions and agent overrides to codex', () => { + expect(resolvePeerSpawnConfig( + { agent: 'codex' }, + mergePeerSpawnDefaults(null) + )).toEqual({ + agent: 'codex', + permissionMode: 'yolo' + }) + }) + + it('forwards explicit effort', () => { + expect(resolvePeerSpawnConfig({ effort: 'high' })).toEqual({ + agent: 'claude', + permissionMode: 'bypassPermissions', + model: 'sonnet', + effort: 'high' + }) + }) +}) + +describe('PeerSpawnDefaultsSchema', () => { + it('rejects retired gemini as a spawn default agent', () => { + expect(PeerSpawnDefaultsSchema.safeParse({ agent: 'gemini' }).success).toBe(false) + }) + + it('accepts creatable agents', () => { + expect(PeerSpawnDefaultsSchema.safeParse({ agent: 'codex' }).success).toBe(true) + }) +}) diff --git a/shared/src/peerSpawnDefaults.ts b/shared/src/peerSpawnDefaults.ts new file mode 100644 index 00000000..b721489e --- /dev/null +++ b/shared/src/peerSpawnDefaults.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import { + AgentFlavorSchema, + CREATABLE_AGENT_FLAVORS, + getPermissionModesForFlavor, + type AgentFlavor, + type PermissionMode +} from './modes' +import { PermissionModeSchema } from './schemas' + +/** Per-flavor model id overrides for peer spawn (e.g. claude: sonnet, cursor: auto). + * Empty string is a patch sentinel meaning “clear this flavor’s override”. */ +export const PeerSpawnModelsSchema = z.record(z.string(), z.string()) + +export type PeerSpawnModels = z.infer + +/** New-session / spawn defaults only accept creatable flavors (not retired gemini). */ +const CreatableAgentFlavorSchema = AgentFlavorSchema.refine( + (agent): agent is AgentFlavor => (CREATABLE_AGENT_FLAVORS as readonly string[]).includes(agent), + { message: 'Agent flavor is not creatable' } +) + +/** Hub-persisted peer spawn defaults (partial; unset fields fall back to stock). */ +export const PeerSpawnDefaultsSchema = z.object({ + agent: CreatableAgentFlavorSchema.optional(), + permissionMode: PermissionModeSchema.optional(), + models: PeerSpawnModelsSchema.optional() +}) + +export type PeerSpawnDefaults = z.infer + +/** Fully resolved peer spawn defaults returned by GET /api/hub-settings. */ +export const ResolvedPeerSpawnDefaultsSchema = z.object({ + agent: CreatableAgentFlavorSchema, + permissionMode: PermissionModeSchema, + models: PeerSpawnModelsSchema +}) +export type ResolvedPeerSpawnDefaults = z.infer + +/** Stock product defaults when hub settings omit peerSpawnDefaults. */ +export const STOCK_PEER_SPAWN_DEFAULTS: ResolvedPeerSpawnDefaults = { + agent: 'claude', + permissionMode: 'yolo', + models: { + claude: 'sonnet' + } +} + +const YOLO_EQUIVALENTS: readonly PermissionMode[] = [ + 'yolo', + 'always-proceed', + 'bypassPermissions' +] + +/** Map a cross-flavor permission intent (e.g. yolo) to a *launchable* mode. */ +export function resolvePermissionModeForFlavor( + mode: PermissionMode, + flavor: AgentFlavor +): PermissionMode { + const launchModes = getPermissionModesForFlavor(flavor) + if (launchModes.includes(mode)) { + return mode + } + // Remap any auto-approval equivalent onto a launchable mode for the target + // flavor — hub GET returns already-resolved Claude bypassPermissions, and + // Kimi safe-yolo must not inherit onto Codex (launch catalog excludes it). + if (mode === 'safe-yolo' || (YOLO_EQUIVALENTS as readonly PermissionMode[]).includes(mode)) { + const equivalent = YOLO_EQUIVALENTS.find((candidate) => + launchModes.includes(candidate) + ) + if (equivalent) { + return equivalent + } + } + if (launchModes.length === 0) { + // Pi/DSH: caller must omit permissionMode on the wire. + return 'default' + } + if (flavor === 'agy' && launchModes.includes('request-review')) { + return 'request-review' + } + if (launchModes.includes('default')) { + return 'default' + } + return launchModes[0]! +} + +export function mergePeerSpawnDefaults( + stored?: PeerSpawnDefaults | null +): ResolvedPeerSpawnDefaults { + const storedModels: PeerSpawnModels = {} + for (const [flavor, model] of Object.entries(stored?.models ?? {})) { + const trimmed = model.trim() + if (trimmed) { + storedModels[flavor] = trimmed + } + } + const models: PeerSpawnModels = { + ...STOCK_PEER_SPAWN_DEFAULTS.models, + ...storedModels + } + const agent = stored?.agent ?? STOCK_PEER_SPAWN_DEFAULTS.agent + const rawPermissionMode = stored?.permissionMode ?? STOCK_PEER_SPAWN_DEFAULTS.permissionMode + return { + agent, + permissionMode: resolvePermissionModeForFlavor(rawPermissionMode, agent), + models + } +} + +export type PeerSpawnConfigOverrides = { + agent?: AgentFlavor + permissionMode?: PermissionMode + model?: string + effort?: string +} + +export type ResolvedPeerSpawnConfig = { + agent: AgentFlavor + permissionMode: PermissionMode + model?: string + effort?: string +} + +/** + * Resolve spawn-peer agent / permission / model / effort: + * explicit CLI/MCP args → hub settings → stock defaults. + */ +export function resolvePeerSpawnConfig( + overrides: PeerSpawnConfigOverrides, + hubDefaults?: PeerSpawnDefaults | null +): ResolvedPeerSpawnConfig { + const stored = hubDefaults ?? {} + const agent = overrides.agent ?? stored.agent ?? STOCK_PEER_SPAWN_DEFAULTS.agent + const rawPermissionMode = overrides.permissionMode + ?? stored.permissionMode + ?? STOCK_PEER_SPAWN_DEFAULTS.permissionMode + const permissionMode = resolvePermissionModeForFlavor(rawPermissionMode, agent) + const models: PeerSpawnModels = { + ...STOCK_PEER_SPAWN_DEFAULTS.models, + ...stored.models + } + const model = (overrides.model ?? models[agent])?.trim() || undefined + const effort = overrides.effort?.trim() || undefined + return { + agent, + permissionMode, + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + } +} diff --git a/shared/src/sessionCitation.test.ts b/shared/src/sessionCitation.test.ts index f0d668b9..3ca62bac 100644 --- a/shared/src/sessionCitation.test.ts +++ b/shared/src/sessionCitation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { SESSION_REFERENCE_STEER_SUFFIX, + SPAWN_PEER_TOOL_DESCRIPTION, PEER_DELIVERY_MODE_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, buildSessionCitationSteerInstruction, @@ -132,4 +133,53 @@ describe('buildSessionCitationSteerInstruction', () => { expect(text).toContain('mcp__hapi__list_peers') expect(text.toLowerCase()).toMatch(/not.*(grep|glob|filesystem|local file)/i) }) + + it('teaches spawn_peer for new peers with work and forbids stuffing remit into machine spawn', () => { + const text = buildSessionCitationSteerInstruction({ + inspectTool: 'mcp__hapi__inspect_peer', + pingTool: 'mcp__hapi__ping_peer', + listPeersTool: 'mcp__hapi__list_peers', + spawnTool: 'mcp__hapi__spawn_peer', + }) + expect(text).toContain('mcp__hapi__spawn_peer') + expect(text).toMatch(/spawn-peer|spawn_peer/) + expect(text.toLowerCase()).toMatch(/400|reject/) + expect(text.toLowerCase()).not.toMatch(/strip/) + }) +}) + +describe('SPAWN_PEER_TOOL_DESCRIPTION', () => { + it('tells agents the hub rejects a spawn-body remit instead of stripping it', () => { + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/reject/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch(/strip/) + }) + + it('teaches omit permissionMode = yolo default, not approval-gated default', () => { + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/yolo/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/omit/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch( + /pass permissionmode only when the operator asked/ + ) + }) + + it('forbids inventing peer titles and teaches Parent UUID stamp', () => { + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/never invent/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/parent/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/uuid/) + }) + + it('documents optional machine targeting beyond this host only', () => { + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/machine/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/hostname|uuid/) + expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch( + /spawn a new hapi session on this machine and/ + ) + }) +}) + +describe('PING_PEER_TOOL_DESCRIPTION identity rule', () => { + it('forbids inventing peer titles', async () => { + const { PING_PEER_TOOL_DESCRIPTION } = await import('./sessionCitation') + expect(PING_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/never invent/) + }) }) diff --git a/shared/src/sessionCitation.ts b/shared/src/sessionCitation.ts index 8af95c79..edfa150a 100644 --- a/shared/src/sessionCitation.ts +++ b/shared/src/sessionCitation.ts @@ -22,7 +22,32 @@ export const INSPECT_PEER_TOOL_DESCRIPTION = 'Fires when the user cites a peer via markdown [title](/sessions/), Copy-reference prose ' + 'See session "…" (/sessions/) for context, or a bare /sessions/. ' + 'Extract and pass it as sessionIdPrefix. /sessions/ is a hub path - do NOT Grep, Glob, or Read it as a local filesystem path. ' + - 'Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl.' + 'Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl. ' + + 'Never invent peer titles; always use the /sessions/ from the citation.' + +/** + * Hard rule for peer tools: UUID is identity; titles are decoration. + * Shared across spawn_peer / ping_peer / inspect_peer descriptions. + */ +export const PEER_IDENTITY_UUID_RULE = + 'Never invent peer titles (upstream, orchestrator, nicknames). ' + + 'Always copy [title](/sessions/) from the handoff or inspect_peer / search_peers; ' + + 'title is decoration - UUID is identity. Rename of a parent is a non-event.' + +/** MCP `spawn_peer` tool description. Remit is required; empty shell is failure. */ +export const SPAWN_PEER_TOOL_DESCRIPTION = + 'Spawn a new HAPI session and deliver a required first user message (the remit). ' + + 'Optional machine selects the target runner (UUID or hostname from GET /api/machines); omit to use this host. ' + + 'directory must exist on the target machine (local existence is not required for cross-host spawns). ' + + 'Creates the session, optionally names it, then uses the same delivery path as ping_peer. ' + + 'Returns the new sessionId + name. Fails if the remit does not land (a sessionId with 0 user messages is a failed spawn). ' + + 'Do not call POST /api/machines/.../spawn with a message field - the hub rejects it. Use spawn_peer to create and deliver the remit. ' + + 'Prefer this (or `hapi spawn-peer`) over JWT+curl. Same hub/namespace as this session. ' + + 'Default permissionMode is yolo via hub peerSpawnDefaults / stock - omit the field to keep that default so peers work without approval prompts. ' + + 'Pass permissionMode only to tighten (e.g. default/plan) or when the operator names a mode; do not clone the parent session\'s mode. ' + + 'When agent/model are omitted, resolves from hub peerSpawnDefaults (Settings → General → Agents), then stock (claude + sonnet). ' + + 'In-session calls auto-stamp a durable Parent [title](/sessions/) chip for this session onto the remit (fail-closed if parent id is missing). ' + + PEER_IDENTITY_UUID_RULE /** Shared delivery guidance for the peer MCP schemas, descriptions, and prompts. */ export const PEER_DELIVERY_MODE_DESCRIPTION = @@ -42,12 +67,14 @@ export const PING_PEER_TOOL_DESCRIPTION = 'When the user cites a peer via [title](/sessions/), Copy-reference prose See session "…" (/sessions/) for context, or a bare /sessions/, ' + 'extract and pass it as sessionIdPrefix. /sessions/ is a hub path - do NOT search the local filesystem for it. ' + 'Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat. ' + + PEER_IDENTITY_UUID_RULE + ' ' + `Select deliveryMode: ${PEER_DELIVERY_MODE_DESCRIPTION}` /** Zod `.describe` for sessionIdPrefix on inspect_peer / ping_peer. */ export const SESSION_ID_PREFIX_PARAM_DESCRIPTION = 'Target HAPI session id or unique id prefix (another session - not this chat). ' + - 'Prefer the full UUID from [title](/sessions/) or Copy-reference See session "…" (/sessions/) for context.' + 'Prefer the full UUID from [title](/sessions/) or Copy-reference See session "…" (/sessions/) for context. ' + + 'Do not pass invented titles or nicknames - UUID only.' /** * Hub session ids have no dots. Reject dotted tails so source paths like @@ -144,6 +171,8 @@ export type SessionCitationSteerTools = { pingTool: string /** Flavor-specific discovery tool when no citation is available. */ listPeersTool?: string + /** Flavor-specific spawn-with-remit tool, e.g. `mcp__hapi__spawn_peer`. */ + spawnTool?: string } /** @@ -179,5 +208,13 @@ export function buildSessionCitationSteerInstruction(tools: SessionCitationSteer `(same hub/namespace; works from runner-spawned sessions). ` + `Shell fallback: hapi ping-peer --list.` } + if (tools.spawnTool) { + text += + ` To spawn a new peer with work attached, call "${tools.spawnTool}" ` + + `with directory and a required message (the remit). ` + + `Do not POST /api/machines/.../spawn with a message/prompt/text field - ` + + `the hub rejects it (HTTP 400). Use spawn_peer instead. ` + + `Shell fallback: hapi spawn-peer --dir PATH --name TITLE --message-file -.` + } return text } diff --git a/web/src/components/NewSession/PermissionField.test.tsx b/web/src/components/NewSession/PermissionField.test.tsx index ff29b768..0fe13653 100644 --- a/web/src/components/NewSession/PermissionField.test.tsx +++ b/web/src/components/NewSession/PermissionField.test.tsx @@ -54,14 +54,19 @@ describe('PermissionField', () => { expect(onChange).toHaveBeenCalledWith('plan') }) - it('renders the YOLO toggle with its native mapping for toggle flavors', () => { - renderField({ agent: 'cursor' }) - expect(screen.getByRole('checkbox')).toBeTruthy() - expect(screen.getByText(/applies native Yolo mode/i)).toBeInTheDocument() + it('renders Cursor permission modes as a native select', () => { + const onChange = vi.fn() + renderField({ agent: 'cursor', nativeValue: 'default', onNativeChange: onChange }) + expect(screen.getByRole('combobox')).toBeTruthy() + expect(screen.getByRole('option', { name: 'Plan Mode' })).toBeTruthy() + expect(screen.getByRole('option', { name: 'Ask Mode' })).toBeTruthy() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) + expect(onChange).toHaveBeenCalledWith('plan') }) - it('maps agy YOLO to its native always-proceed mode', () => { + it('renders the YOLO toggle with its native mapping for toggle flavors', () => { renderField({ agent: 'agy' }) + expect(screen.getByRole('checkbox')).toBeTruthy() expect(screen.getByText(/applies native Always Proceed mode/i)).toBeInTheDocument() }) diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index dad51988..aa760f35 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactElement } from 'react' import type { ApiClient } from '@/api/client' import type { AgentAvailabilityEntry, ClaudeModelSummary, Machine, PiModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' import { saveNewSessionFormDraft } from './newSessionFormDraft' import { loadPreferredLaunchSettings, @@ -250,6 +253,12 @@ vi.mock('./PermissionField', () => ({ + + ) })) @@ -306,9 +315,10 @@ vi.mock('./ModelSelector', () => ({ ) })) vi.mock('./ActionButtons', () => ({ - ActionButtons: (props: { onCreate: () => void; onChooseFolder?: () => void; canCreate: boolean }) => ( + ActionButtons: (props: { onCreate: () => void; onCancel: () => void; onChooseFolder?: () => void; canCreate: boolean }) => ( <> + {props.onChooseFolder ? : null} ) @@ -317,7 +327,42 @@ vi.mock('./ActionButtons', () => ({ import { NewSession } from './index' const machine = { id: 'machine-1' } as Machine -const api = {} as ApiClient +const api = { + getHubSettings: vi.fn().mockResolvedValue({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + // Neutral defaults so launch-preference tests control New Session via UI / + // sticky localStorage (bypassPermissions here would seed YOLO and skew asserts). + peerSpawnDefaults: { + agent: 'claude', + permissionMode: 'default', + models: { claude: 'sonnet' } + } + }) +} as unknown as ApiClient + +function renderWithQuery(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + // Pre-seed hub settings so useQuery is warm on first paint — a late resolve + // re-runs the preferred-launch effect and can wipe an in-test model pick. + client.setQueryData(queryKeys.hubSettings, { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'claude', + permissionMode: 'default', + models: { claude: 'sonnet' } + } + }) + const wrap = (node: ReactElement) => ( + {node} + ) + const result = render(wrap(ui)) + return { + ...result, + rerender: (node: ReactElement) => result.rerender(wrap(node)) + } +} describe('NewSession launch preferences', () => { beforeEach(() => { @@ -371,7 +416,7 @@ describe('NewSession launch preferences', () => { { agent: 'codex', available: true } ) - render( + renderWithQuery( { { agent: 'agy', available: false, reason: 'not_found' } ) - render( + renderWithQuery( { }) it('does not claim Agents are missing when no machine is connected', () => { - render( + renderWithQuery( { it('claims Agents are missing only after inspecting a connected machine', () => { mocks.availableAgents.splice(0, mocks.availableAgents.length) - render( + renderWithQuery( { })) mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'unexpected' }) - render( + renderWithQuery( { permissionMode: 'safe-yolo' }) - render( + renderWithQuery( { savePreferredAgent('claude') savePreferredYoloMode(true) - render( + renderWithQuery( { savePreferredAgent('codex') savePreferredYoloMode(true) - render( + renderWithQuery( { { modelId: 'auto', name: 'Auto' } ] - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { { modelId: 'openrouter-union-alpha', provider: 'openrouter' } ] - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { modelReasoningEffort: 'default' }) - render( + renderWithQuery( { modelReasoningEffort }) - render( + renderWithQuery( { savePreferredAgent(agent) savePreferredLaunchSettings('machine-1', agent, settings) - render( + renderWithQuery( { it('saves changed launch settings only after creation succeeds', async () => { mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) - render( + renderWithQuery( { copilotAgentMode: 'interactive', yoloMode: false, nativePermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('agy-model')).toHaveTextContent('gemini-3.6-flash-low')) }) @@ -890,7 +935,7 @@ describe('NewSession launch preferences', () => { copilotAgentMode: 'interactive', yoloMode: false, nativePermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('agy-model')).toHaveTextContent('auto')) }) @@ -899,7 +944,7 @@ describe('NewSession launch preferences', () => { // change while the form is open. A model the user chose is theirs to // keep — unlike a restored one, which the two tests above drop. savePreferredAgent('agy') - const view = render( {}} />) + const view = renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('agy-model')) await waitFor(() => expect(screen.getByTestId('agy-model')).toHaveTextContent('gemini-3.6-flash-low')) @@ -913,7 +958,7 @@ describe('NewSession launch preferences', () => { it('falls back to Default when a preferred AGY model is no longer advertised', async () => { savePreferredAgent('agy') savePreferredLaunchSettings('machine-1', 'agy', { model: 'removed-model', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('agy-model')).toHaveTextContent('auto')) }) @@ -921,7 +966,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('agy') savePreferredLaunchSettings('machine-1', 'agy', { model: 'gemini-3.6-flash-low', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default' }) mocks.agyModelsLoading = true - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('create')).toBeDisabled()) }) @@ -929,7 +974,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('dsh') savePreferredYoloMode(true) mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'dsh-session' }) - render( {}} />) + renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('create')) await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('dsh-session')) @@ -947,7 +992,7 @@ describe('NewSession launch preferences', () => { { modelId: '["deepseek-official","deepseek-v4-pro"]', name: 'DeepSeek-V4-Pro' }, ] mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'dsh-session' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => { expect(screen.getByTestId('model-options')).toHaveTextContent('DeepSeek-V4-Pro') @@ -968,7 +1013,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('dsh') mocks.dshModels = [{ modelId: '["deepseek-official","deepseek-flash"]', name: 'DeepSeek-V41-Flash' }] mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'dsh-session' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => { expect(screen.getByTestId('model-options')).toHaveTextContent('DeepSeek-V41-Flash') @@ -987,7 +1032,7 @@ describe('NewSession launch preferences', () => { savePreferredLaunchSettings('machine-1', 'dsh', { model: '["deepseek-official","deepseek-flash"]', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default' }) mocks.dshModels = [{ modelId: '["deepseek-official","deepseek-flash"]', name: 'DeepSeek-V41-Flash' }] mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'dsh-session' }) - const view = render( {}} />) + const view = renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('model')).toHaveTextContent('deepseek-flash')) @@ -995,7 +1040,7 @@ describe('NewSession launch preferences', () => { // launch settings (not just the in-form draft) and survive the // catalog validation effect. view.unmount() - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('model')).toHaveTextContent('deepseek-flash')) fireEvent.click(screen.getByTestId('create')) @@ -1011,7 +1056,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('dsh') savePreferredLaunchSettings('machine-1', 'dsh', { model: '["deepseek-official","removed-model"]', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default' }) mocks.dshModels = [{ modelId: '["deepseek-official","deepseek-flash"]', name: 'DeepSeek-V41-Flash' }] - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('model')).toHaveTextContent('auto')) }) @@ -1019,7 +1064,7 @@ describe('NewSession launch preferences', () => { it('lets Claude create with a chosen permission mode instead of the global YOLO toggle', async () => { savePreferredAgent('claude') mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'claude-session' }) - render( {}} />) + renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('permission-mode-plan')) fireEvent.click(screen.getByTestId('create')) @@ -1035,7 +1080,7 @@ describe('NewSession launch preferences', () => { it('does not carry a permission mode picked under another flavor into the Claude spawn payload', async () => { savePreferredAgent('codex') mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'claude-session' }) - render( {}} />) + renderWithQuery( {}} />) // Starts as codex; picking the mocked native-select button sets the // shared nativePermissionMode state to 'yolo', a value 'claude' does @@ -1057,7 +1102,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('claude') savePreferredYoloMode(true) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => { expect(screen.getByTestId('permission-mode')).toHaveTextContent('bypassPermissions') @@ -1072,7 +1117,7 @@ describe('NewSession launch preferences', () => { savePreferredAgent('cursor') savePreferredYoloMode(true) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByDisplayValue('cursor')).toBeChecked()) fireEvent.click(screen.getByDisplayValue('claude')) @@ -1085,7 +1130,7 @@ describe('NewSession launch preferences', () => { it('keeps an explicit OpenCode Default selection instead of restoring a concrete model', async () => { savePreferredAgent('opencode') mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'opencode-session' }) - render( {}} />) + renderWithQuery( {}} />) // The catalog advertises a concrete default; the user picks Default. fireEvent.click(screen.getByTestId('opencode-model-default')) await waitFor(() => expect(screen.getByTestId('opencode-model')).toHaveTextContent('default')) @@ -1100,7 +1145,7 @@ describe('NewSession launch preferences', () => { it('uses the probed current model variants for an explicit OpenCode Default selection', async () => { savePreferredAgent('opencode') mocks.opencodeVariants = { 'provider/current': ['low', 'high'] } - render( {}} />) + renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('opencode-model-default')) await waitFor(() => expect(screen.getByTestId('opencode-variants')).toHaveTextContent('low,high')) @@ -1110,7 +1155,7 @@ describe('NewSession launch preferences', () => { it('waits for OpenCode variants before launching a non-default effort', async () => { savePreferredAgent('opencode') mocks.opencodeVariantsLoading = true - const view = render( {}} />) + const view = renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('reasoning')) expect(screen.getByTestId('create')).toBeDisabled() @@ -1124,7 +1169,7 @@ describe('NewSession launch preferences', () => { it('does not probe OpenCode variants until the working directory is verified', () => { savePreferredAgent('opencode') mocks.directoryExists = undefined - render( {}} />) + renderWithQuery( {}} />) expect(mocks.opencodeVariantsEnabled).toBe(false) }) @@ -1133,14 +1178,14 @@ describe('NewSession launch preferences', () => { savePreferredAgent('opencode') savePreferredLaunchSettings('machine-1', 'opencode', { model: 'provider/model', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'high' }) mocks.opencodeModels = [{ modelId: 'provider/model', name: 'Model' }] - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('opencode-model')).toHaveTextContent('provider/model')) }) it('persists the selected AGY model only after a successful launch', async () => { savePreferredAgent('agy') mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'agy-session' }) - render( {}} />) + renderWithQuery( {}} />) fireEvent.click(screen.getByTestId('agy-model')) fireEvent.click(screen.getByTestId('create')) await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('agy-session')) @@ -1156,7 +1201,7 @@ describe('NewSession launch preferences', () => { { modelId: 'gpt-oss-120b-medium', name: 'GPT-OSS 120B (Medium)' }, ] mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'agy-session' }) - render( {}} />) + renderWithQuery( {}} />) fireEvent.change(screen.getByTestId('agy-model-select'), { target: { value: 'gemini-4-argon' } }) expect(screen.getByRole('option', { name: 'Gemini 4 Argon' })).toBeTruthy() expect(screen.queryByRole('option', { name: 'Gemini 4 Argon (High)' })).toBeNull() @@ -1180,7 +1225,7 @@ describe('NewSession launch preferences', () => { { modelId: 'gpt-oss-120b-medium', name: 'GPT-OSS 120B (Medium)' }, ] mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'agy-session' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('agy-model-select')).toHaveValue('gemini-4-argon')) expect(screen.getByTestId('agy-effort-options')).toHaveValue(expectedEffort) fireEvent.change(screen.getByTestId('agy-model-select'), { target: { value: 'gpt-oss-120b-medium' } }) @@ -1199,7 +1244,7 @@ describe('NewSession launch preferences', () => { { modelId: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)' } ] mocks.spawnSession.mockResolvedValue({ type: 'error', message: 'spawn failed' }) - render( {}} />) + renderWithQuery( {}} />) await waitFor(() => expect(screen.getByTestId('agy-model')).toHaveTextContent('gemini-3.5-flash-low')) fireEvent.click(screen.getByTestId('agy-model')) fireEvent.click(screen.getByTestId('create')) @@ -1214,7 +1259,7 @@ describe('NewSession launch preferences', () => { })) mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) - render( + renderWithQuery( { reopenSession: vi.fn().mockResolvedValue({ ok: true, sessionId: 'hapi-imported-1', resumed: true }) } as unknown as ApiClient - render( + renderWithQuery( { reopenSession: vi.fn().mockResolvedValue({ ok: true, sessionId: 'hapi-machine-b', resumed: true }) } as unknown as ApiClient - render( + renderWithQuery( { }) } as unknown as ApiClient - render( + renderWithQuery( { it('does not save changed launch settings when creation fails', async () => { mocks.spawnSession.mockResolvedValue({ type: 'error', message: 'spawn failed' }) - render( + renderWithQuery( { worktreeName: '' }) - render( + renderWithQuery( { modelReasoningEffort: 'default', }) - render( + renderWithQuery( { modelReasoningEffort: 'default', }) - render( + renderWithQuery( { modelReasoningEffort: 'default', }) - render( + renderWithQuery( { modelReasoningEffort: 'default', }) - render( + renderWithQuery( { { provider: 'opencode-go', modelId: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, ] - render( + renderWithQuery( { model: 'opencode-go/deepseek-v4-pro', })) }) + + it('seeds Codex hub defaults on a fresh browser before sticky keys are written', async () => { + localStorage.clear() + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + expect(screen.getByTestId('create')).toBeDisabled() + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'read-only', + models: { codex: 'gpt-5' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-mode')).toHaveTextContent('read-only') + }) + expect(screen.getByTestId('create')).not.toBeDisabled() + }) + + it.each([ + { agent: 'codex', permissionMode: 'read-only', cached: true }, + { agent: 'codex', permissionMode: 'read-only', cached: false }, + { agent: 'claude', permissionMode: 'plan', cached: true }, + { agent: 'cursor', permissionMode: 'plan', cached: true } + ] as const)( + 'preserves hub $agent/$permissionMode after cancel and remount (cached: $cached)', + async ({ agent, permissionMode, cached }) => { + localStorage.clear() + const settings = { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { agent, permissionMode, models: {} } + } + const sharedClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + if (cached) sharedClient.setQueryData(queryKeys.hubSettings, settings) + const onCancel = vi.fn() + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + + for (const reopen of [false, true]) { + let resolveSettings!: (value: typeof settings) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const hubApi = { + getHubSettings: vi.fn(() => cached ? Promise.resolve(settings) : deferred) + } as unknown as ApiClient + const client = cached + ? sharedClient + : new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = render( + + + + ) + if (!cached) { + expect(screen.getByTestId('create')).toBeDisabled() + await act(async () => { + resolveSettings(settings) + await deferred + }) + } + await waitFor(() => { + expect(screen.getByDisplayValue(agent)).toBeChecked() + expect(screen.getByTestId('permission-mode')).toHaveTextContent(permissionMode) + expect(screen.getByTestId('create')).toBeEnabled() + }) + if (!reopen) { + fireEvent.click(screen.getByTestId('cancel')) + expect(onCancel).toHaveBeenCalledOnce() + expect(mocks.spawnSession).not.toHaveBeenCalled() + expect(loadPreferredLaunchSettings('machine-1', agent)).toBeNull() + view.unmount() + } else { + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent, + permissionMode + })) + } + } + expect(localStorage.getItem('hapi:newSession:yolo')).toBeNull() + } + ) + + it('persists explicit YOLO toggle edits across remounts', async () => { + savePreferredAgent('agy') + const form = {}} /> + const view = renderWithQuery(form) + await waitFor(() => expect(screen.getByTestId('yolo-toggle')).toHaveTextContent('yolo-off')) + expect(localStorage.getItem('hapi:newSession:yolo')).toBeNull() + + fireEvent.click(screen.getByTestId('yolo-toggle')) + expect(localStorage.getItem('hapi:newSession:yolo')).toBe('true') + view.unmount() + renderWithQuery(form) + await waitFor(() => expect(screen.getByTestId('yolo-toggle')).toHaveTextContent('yolo-on')) + }) + + it('keeps Create disabled while getHubSettings is pending', async () => { + localStorage.clear() + const deferred = new Promise(() => {}) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + expect(screen.getByTestId('create')).toBeDisabled() + fireEvent.click(screen.getByTestId('create')) + expect(mocks.spawnSession).not.toHaveBeenCalled() + }) + + it('keeps an explicit Default permission when hub settings resolve after the edit', async () => { + localStorage.clear() + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + // Explicit Default (same value as mount default) must still count as an edit. + fireEvent.click(screen.getByTestId('permission-mode-default')) + expect(screen.getByTestId('permission-mode')).toHaveTextContent('default') + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'claude', + permissionMode: 'bypassPermissions', + models: { claude: 'sonnet' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-mode')).toHaveTextContent('default') + }) + }) + + it('keeps Cursor Plan when hub settings resolve late with a different agent default', async () => { + localStorage.clear() + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + fireEvent.click(screen.getByDisplayValue('cursor')) + fireEvent.click(screen.getByTestId('permission-mode-plan')) + expect(screen.getByTestId('permission-mode')).toHaveTextContent('plan') + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'yolo', + models: { codex: 'gpt-5' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-mode')).toHaveTextContent('plan') + }) + + act(() => { + mocks.spawnSession.mockImplementation(async () => ({ type: 'success', sessionId: 'session-1' })) + }) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'cursor', + permissionMode: 'plan' + })) + }) + + it('seeds Grok Plan from delayed hub defaults after agent initialization', async () => { + localStorage.clear() + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'grok', + permissionMode: 'plan', + models: {} + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-mode')).toHaveTextContent('plan') + }) + + act(() => { + mocks.spawnSession.mockImplementation(async () => ({ type: 'success', sessionId: 'session-1' })) + }) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'grok', + permissionMode: 'plan' + })) + }) + + it('applies delayed hub permission with a sticky agent and no launch prefs', async () => { + localStorage.clear() + savePreferredAgent('codex') + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + expect(screen.getByTestId('permission-mode')).toHaveTextContent('default') + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'claude', + permissionMode: 'read-only', + models: { claude: 'sonnet' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-mode')).toHaveTextContent('read-only') + }) + expect(screen.getByDisplayValue('codex')).toBeChecked() + }) + + it.each(['untouched', 'default', 'concrete'] as const)( + 'seeds delayed DSH model defaults while preserving an %s selection', + async (selection) => { + savePreferredAgent('dsh') + const hubModel = '["deepseek-official","deepseek-flash"]' + const pickedModel = '["deepseek-official","deepseek-v4-pro"]' + mocks.dshModels = [ + { modelId: hubModel, name: 'DeepSeek-V41-Flash' }, + { modelId: pickedModel, name: 'DeepSeek-V4-Pro' } + ] + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'dsh-session' }) + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + expect(screen.getByDisplayValue('dsh')).toBeChecked() + expect(screen.getByTestId('model')).toHaveTextContent('auto') + if (selection !== 'untouched') { + mocks.nextModelValue = selection === 'default' ? 'auto' : pickedModel + fireEvent.click(screen.getByTestId('model')) + expect(screen.getByTestId('model')).toHaveTextContent(mocks.nextModelValue) + } + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'claude', + permissionMode: 'default', + models: { dsh: hubModel } + } + }) + await deferred + }) + + const expectedModel = selection === 'untouched' + ? hubModel + : selection === 'concrete' ? pickedModel : undefined + await waitFor(() => { + expect(screen.getByDisplayValue('dsh')).toBeChecked() + expect(screen.getByTestId('model')).toHaveTextContent(expectedModel ?? 'auto') + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('dsh-session')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'dsh', + model: expectedModel + })) + } + ) + + it('preserves a saved Default model over a cached hub model', async () => { + localStorage.clear() + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'auto', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default', + permissionMode: 'default' + }) + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + + renderWithQuery( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('auto') + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'claude' + })) + expect(mocks.spawnSession.mock.calls[0]![0].model).toBeUndefined() + }) + + it('seeds OpenCode model state from hub when no launch preference exists', async () => { + localStorage.clear() + mocks.opencodeModels = [ + { modelId: 'provider/hub-model', name: 'Hub' }, + { modelId: 'provider/current', name: 'Current' } + ] + mocks.opencodeCurrentModelId = 'provider/current' + const hubApi = { + getHubSettings: vi.fn().mockResolvedValue({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'opencode', + permissionMode: 'default', + models: { opencode: 'provider/hub-model' } + } + }) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(queryKeys.hubSettings, { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'opencode', + permissionMode: 'default', + models: { opencode: 'provider/hub-model' } + } + }) + render( + + {}} + /> + + ) + + await waitFor(() => { + expect(screen.getByTestId('opencode-model')).toHaveTextContent('provider/hub-model') + }) + }) + + it('migrates Cursor YOLO=false over hub yolo into native Default', async () => { + localStorage.clear() + savePreferredAgent('cursor') + savePreferredYoloMode(false) + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + const hubApi = { + getHubSettings: vi.fn().mockResolvedValue({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'cursor', + permissionMode: 'yolo', + models: {} + } + }) + } as unknown as ApiClient + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(queryKeys.hubSettings, { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'cursor', + permissionMode: 'yolo', + models: {} + } + }) + render( + + {}} + /> + + ) + + await waitFor(() => { + expect(screen.getByDisplayValue('cursor')).toBeChecked() + expect(screen.getByTestId('permission-mode')).toHaveTextContent('default') + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'cursor', + permissionMode: 'default' + })) + }) + + it('does not apply Cursor YOLO migration after switching agent before hub settings', async () => { + localStorage.clear() + savePreferredAgent('cursor') + savePreferredYoloMode(true) + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + fireEvent.click(screen.getByDisplayValue('codex')) + await waitFor(() => expect(screen.getByDisplayValue('codex')).toBeChecked()) + expect(screen.getByTestId('permission-mode')).toHaveTextContent('default') + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'read-only', + models: {} + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByDisplayValue('codex')).toBeChecked() + // Hub read-only — not Cursor sticky YOLO → yolo + expect(screen.getByTestId('permission-mode')).toHaveTextContent('read-only') + }) + }) + + it('keeps an explicit Claude model when hub settings resolve late with Codex', async () => { + localStorage.clear() + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + expect(screen.getByDisplayValue('claude')).toBeChecked() + fireEvent.click(screen.getByTestId('model')) + expect(screen.getByTestId('model')).toHaveTextContent(mocks.nextModelValue) + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'codex', + permissionMode: 'read-only', + models: { codex: 'gpt-5' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByDisplayValue('claude')).toBeChecked() + expect(screen.getByTestId('model')).toHaveTextContent(mocks.nextModelValue) + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'claude', + model: mocks.nextModelValue + })) + }) + + it('keeps an explicit OpenCode Default when hub settings resolve late', async () => { + localStorage.clear() + mocks.opencodeModels = [ + { modelId: 'provider/hub-model', name: 'Hub' }, + { modelId: 'provider/current', name: 'Current' } + ] + mocks.opencodeCurrentModelId = 'provider/current' + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + let resolveSettings!: (value: unknown) => void + const deferred = new Promise((resolve) => { + resolveSettings = resolve + }) + const slowApi = { + getHubSettings: vi.fn(() => deferred) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} + /> + + ) + + fireEvent.click(screen.getByDisplayValue('opencode')) + fireEvent.click(screen.getByTestId('opencode-model-default')) + expect(screen.getByTestId('opencode-model')).toHaveTextContent('default') + + await act(async () => { + resolveSettings({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'opencode', + permissionMode: 'default', + models: { opencode: 'provider/hub-model' } + } + }) + await deferred + }) + + await waitFor(() => { + expect(screen.getByTestId('opencode-model')).toHaveTextContent('default') + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'opencode', + model: undefined + })) + }) + + it('seeds AGY spawn model from hub when no launch preference exists', async () => { + localStorage.clear() + mocks.agyModels = [ + { modelId: 'hub-agy-model', name: 'Hub AGY' }, + { modelId: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)' } + ] + mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-1' }) + const hubApi = { + getHubSettings: vi.fn().mockResolvedValue({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'agy', + permissionMode: 'default', + models: { agy: 'hub-agy-model' } + } + }) + } as unknown as ApiClient + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(queryKeys.hubSettings, { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'agy', + permissionMode: 'default', + models: { agy: 'hub-agy-model' } + } + }) + render( + + {}} + /> + + ) + + await waitFor(() => { + expect(screen.getByTestId('agy-model')).toHaveTextContent('hub-agy-model') + }) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + fireEvent.click(screen.getByTestId('create')) + await waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledWith('session-1')) + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'agy', + model: 'hub-agy-model' + })) + }) }) diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 399e810e..7bc7aaf1 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,7 +1,8 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useQuery } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, OpencodeLocalSessionSummary, PiLocalSessionSummary } from '@/types/api' -import { CREATABLE_AGENT_FLAVORS, getAgyModelChoices, splitAgyModelSelection, getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS, resolveHapiYoloPermissionMode, getAgyModelChoices, splitAgyModelSelection, getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' import type { AgentAvailabilityEntry, CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' @@ -26,6 +27,7 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' import { useRecentPaths } from '@/hooks/useRecentPaths' import { useTranslation } from '@/lib/use-translation' +import { queryKeys } from '@/lib/query-keys' import { getCodexModelReasoningEfforts } from '@/lib/codexModelCapabilities' import { buildNewSessionCursorModelCatalog, @@ -71,14 +73,18 @@ import { loadPreferredAgent, loadPreferredLaunchSettings, loadPreferredYoloMode, + hasSavedPreferredYoloMode, resolvePreferredLaunchSettings, savePreferredAgent, savePreferredLaunchSettings, savePreferredYoloMode, + seedNewSessionFromPeerSpawnDefaults, + isYoloStylePermissionMode, } from './preferences' +import { resolvePermissionModeForFlavor } from '@hapi/protocol/peerSpawnDefaults' import { SessionTypeSelector } from './SessionTypeSelector' import { PermissionField } from './PermissionField' -import { usesNativePermissionSelect, usesSharedPermissionModeState } from '@/lib/codexFamilyPermissionAgents' +import { usesNativePermissionSelect, usesSharedPermissionModeState, LEGACY_YOLO_BRIDGE_AGENTS } from '@/lib/codexFamilyPermissionAgents' import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' import { PiSessionImportDialog } from '@/components/PiSessionImportDialog' import { OpencodeSessionImportDialog } from '@/components/OpencodeSessionImportDialog' @@ -106,21 +112,58 @@ export function NewSession(props: { const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) const { sessions, refetch: refetchSessions } = useSessions(props.api) const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() + const hubSettingsQuery = useQuery({ + queryKey: queryKeys.hubSettings, + queryFn: async () => props.api.getHubSettings(), + staleTime: 30_000, + retry: false + }) + const hubPeerSpawnDefaults = hubSettingsQuery.data?.peerSpawnDefaults + // Block Create until hub peerSpawnDefaults settle once. Fresh browsers would + // otherwise POST mount defaults (claude + default) before seeding and bypass a + // restrictive hub config. Errors unlock Create with local/sticky defaults. + const hubDefaultsPending = hubSettingsQuery.isPending + const seededFromHubRef = useRef(false) + // Snapshot sticky localStorage keys before the mount effect persists the + // agent. Only explicit toggle edits persist a Yolo preference; mounting or + // seeding hub defaults must not manufacture a legacy migration candidate. + const [initialStickyPreferences] = useState(() => { + try { + return { + hasStickyAgent: localStorage.getItem('hapi:newSession:agent') !== null, + hasStickyYolo: localStorage.getItem('hapi:newSession:yolo') !== null + } + } catch { + return { hasStickyAgent: false, hasStickyYolo: false } + } + }) + // Hub permission mode for preferred-launch restore — read via ref so a + // late getHubSettings resolve does not re-run the effect and wipe edits. + const hubPermissionModeRef = useRef(hubPeerSpawnDefaults?.permissionMode) + hubPermissionModeRef.current = hubPeerSpawnDefaults?.permissionMode + const hubModelsRef = useRef(hubPeerSpawnDefaults?.models) + hubModelsRef.current = hubPeerSpawnDefaults?.models const [machineId, setMachineId] = useState(props.initialMachineId ?? null) const [directory, setDirectory] = useState(props.initialDirectory ?? '') const [suppressSuggestions, setSuppressSuggestions] = useState(false) const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) const [agent, setAgent] = useState(loadPreferredAgent) - // Snapshot taken once at mount, before any savePreferredAgent() call this - // component makes can overwrite the stored agent. savePreferredAgent() - // runs on every agent change (below), so reading loadPreferredAgent() - // again later would always equal the current agent and make the - // legacyYoloAgent === agent gate at the restore effect below vacuous. - const [legacyYoloAgent] = useState( - () => (loadPreferredYoloMode() ? loadPreferredAgent() : null) - ) + // Snapshot a saved HAPI YOLO toggle for bridge flavors (incl. Cursor after + // native-select move). null = no sticky key; true/false = migrate into native mode. + const [legacyYoloBridge] = useState(() => { + if (!hasSavedPreferredYoloMode()) { + return null + } + return { + agent: loadPreferredAgent(), + enabled: loadPreferredYoloMode() + } + }) const [model, setModel] = useState('auto') + const editedPermissionRef = useRef(false) + const editedAgentRef = useRef(false) + const editedModelRef = useRef(false) const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const pendingCursorBaseRef = useRef(null) const [effort, setEffort] = useState('auto') @@ -220,10 +263,6 @@ export function NewSession(props: { } }, [agent]) - useEffect(() => { - savePreferredYoloMode(yoloMode) - }, [yoloMode]) - useEffect(() => { if (props.initialDirectory !== undefined) { setDirectory(props.initialDirectory) @@ -288,6 +327,82 @@ export function NewSession(props: { machineId ]) + // Seed from hub peerSpawnDefaults once (Settings → General). + // Sticky New Session localStorage agent wins when present (last-used UI); + // peer spawn / machine spawn always resolve hub server-side. + // Browse-draft restore wins; explicit form edits after seed are not overwritten. + // A sticky agent must not suppress hub defaults for unset permission/model. + useEffect(() => { + if (seededFromHubRef.current || restoredFromBrowseRef.current || !hubPeerSpawnDefaults) { + return + } + seededFromHubRef.current = true + const seeded = seedNewSessionFromPeerSpawnDefaults(hubPeerSpawnDefaults) + const { hasStickyAgent, hasStickyYolo } = initialStickyPreferences + // If the operator already edited permission/Yolo/model, do not swap the + // agent — agent-change effects would wipe the restrictive choice. + const applyHubAgent = !hasStickyAgent + && !editedAgentRef.current + && !editedPermissionRef.current + && !editedModelRef.current + if (applyHubAgent) { + setAgent(seeded.agent) + } + const targetAgent: AgentType = applyHubAgent ? seeded.agent : agent + const remappedPermission = resolvePermissionModeForFlavor( + hubPeerSpawnDefaults.permissionMode, + targetAgent + ) + const savedLaunch = machineId + ? loadPreferredLaunchSettings(machineId, targetAgent) + : null + if (!editedPermissionRef.current && !savedLaunch?.permissionMode) { + if ( + legacyYoloBridge?.agent === targetAgent + && usesSharedPermissionModeState(targetAgent) + && LEGACY_YOLO_BRIDGE_AGENTS.includes(targetAgent) + ) { + const bridged = legacyYoloBridge.enabled + ? resolveHapiYoloPermissionMode(targetAgent) + : 'default' + if (bridged) { + setNativePermissionMode(bridged) + } + } else if (usesSharedPermissionModeState(targetAgent)) { + setNativePermissionMode(remappedPermission) + } else if (targetAgent === 'grok') { + if ( + remappedPermission === 'default' + || remappedPermission === 'auto' + || remappedPermission === 'plan' + || remappedPermission === 'bypassPermissions' + ) { + setGrokPermissionMode(remappedPermission) + } else if (isYoloStylePermissionMode(remappedPermission)) { + setGrokPermissionMode('bypassPermissions') + } + } + } + const hubModelForTarget = hubPeerSpawnDefaults.models[targetAgent]?.trim() + if (!editedModelRef.current && !savedLaunch?.model && hubModelForTarget) { + if (targetAgent === 'opencode') { + setOpencodeSelectedModel(hubModelForTarget) + } else if (targetAgent === 'agy') { + setAgySelectedModel(hubModelForTarget) + } else if (targetAgent === 'dsh') { + setDshSelectedModel(hubModelForTarget) + } else { + setModel(hubModelForTarget) + if (targetAgent === 'cursor') { + setCursorSelectedBase(hubModelForTarget) + } + } + } + if (!hasStickyYolo && !editedPermissionRef.current) { + setYoloMode(isYoloStylePermissionMode(remappedPermission)) + } + }, [agent, hubPeerSpawnDefaults, initialStickyPreferences, legacyYoloBridge, machineId]) + useEffect(() => { if (props.machines.length === 0) return if (machineId && props.machines.find((m) => m.id === machineId)) return @@ -980,32 +1095,78 @@ export function NewSession(props: { return } + const savedLaunch = loadPreferredLaunchSettings(machineId, agent) const preferred = resolvePreferredLaunchSettings( agent, - loadPreferredLaunchSettings(machineId, agent), - legacyYoloAgent === agent + savedLaunch, + legacyYoloBridge?.agent === agent ? legacyYoloBridge.enabled : null, + hubPermissionModeRef.current ) - setModel(agent === 'opencode' ? 'auto' : preferred.model) - setCursorSelectedBase(preferred.cursorSelectedBase) + const hubModel = hubModelsRef.current?.[agent]?.trim() + // Explicit saved `auto` is native Default — do not replace with hub model. + // Absent saved model may still inherit the hub flavor model. + const nextModel = agent === 'opencode' + ? 'auto' + : savedLaunch?.model !== undefined + ? preferred.model + : (hubModel || preferred.model) + setModel(nextModel) + setCursorSelectedBase( + preferred.cursorSelectedBase !== 'auto' + ? preferred.cursorSelectedBase + : savedLaunch?.cursorSelectedBase !== undefined + ? preferred.cursorSelectedBase + : (agent === 'cursor' && hubModel ? hubModel : preferred.cursorSelectedBase) + ) setEffort(preferred.effort) setModelReasoningEffort(preferred.modelReasoningEffort) if (usesSharedPermissionMode) { setNativePermissionMode(preferred.permissionMode ?? 'default') } - setOpencodeSelectedModel( - agent === 'opencode' && preferred.model !== 'auto' ? preferred.model : null - ) + if (agent === 'grok' && !editedPermissionRef.current) { + const hubMode = hubPermissionModeRef.current + if ( + hubMode === 'default' + || hubMode === 'auto' + || hubMode === 'plan' + || hubMode === 'bypassPermissions' + ) { + setGrokPermissionMode(hubMode) + } else if (hubMode && isYoloStylePermissionMode(hubMode)) { + setGrokPermissionMode('bypassPermissions') + } + } + // Flavor-specific selectors own the spawn model for OpenCode / AGY. + // Prefer a concrete saved model, else hub, else native Default (null). + if (agent === 'opencode') { + if (savedLaunch?.model !== undefined && savedLaunch.model !== 'auto') { + setOpencodeSelectedModel(savedLaunch.model) + } else if (savedLaunch?.model === 'auto') { + setOpencodeSelectedModel(null) + } else { + setOpencodeSelectedModel(hubModel || null) + } + } else { + setOpencodeSelectedModel(null) + } agyModelPickedByUserRef.current = false - setAgySelectedModel( - agent === 'agy' && preferred.model !== 'auto' ? preferred.model : null - ) - // Restored DSH tuples are validated against the machine catalog by the - // effect above (drops selections the catalog no longer advertises). + if (agent === 'agy') { + if (savedLaunch?.model !== undefined && savedLaunch.model !== 'auto') { + setAgySelectedModel(savedLaunch.model) + } else if (savedLaunch?.model === 'auto') { + setAgySelectedModel(null) + } else { + setAgySelectedModel(hubModel || null) + } + } else { + setAgySelectedModel(null) + } + // Validate restored DSH models against the selected machine's catalog. setDshSelectedModel( - agent === 'dsh' && preferred.model !== 'auto' ? preferred.model : null + agent === 'dsh' && nextModel !== 'auto' ? nextModel : null ) - }, [agent, legacyYoloAgent, machineId, usesSharedPermissionMode]) + }, [agent, legacyYoloBridge, machineId, usesSharedPermissionMode]) useEffect(() => { if ( @@ -1574,6 +1735,7 @@ export function NewSession(props: { const handleAgentChange = useCallback((newAgent: AgentType) => { preserveRestoredDraftRef.current = false + editedAgentRef.current = true setAgent(newAgent) }, []) @@ -1600,6 +1762,7 @@ export function NewSession(props: { }, [getRecentPaths]) const handleCursorBaseChange = useCallback((baseKey: string) => { + editedModelRef.current = true if (baseKey === 'auto') { pendingCursorBaseRef.current = null setCursorSelectedBase('auto') @@ -1749,6 +1912,7 @@ export function NewSession(props: { async function handleCreate() { if (!machineId || !trimmedDirectory || createInFlightRef.current) return + if (hubDefaultsPending) return createInFlightRef.current = true setIsCreating(true) @@ -2023,6 +2187,7 @@ export function NewSession(props: { && selectedAgentAvailable && !isLaunchPreferenceValidationPending && !fastModeSelectionPending + && !hubDefaultsPending ) return ( @@ -2145,6 +2310,7 @@ export function NewSession(props: { ? `${t('newSession.model.loadFailed')}: ${dshModelsState.error}` : null} onModelChange={(value) => { + editedModelRef.current = true setDshSelectedModel(value === 'auto' ? null : value) }} /> @@ -2158,6 +2324,7 @@ export function NewSession(props: { availableModels={agyModelChoices} selectedModel={agySelectedModel} onModelChange={(modelId) => { + editedModelRef.current = true agyModelPickedByUserRef.current = modelId !== null setAgySelectedModel(modelId) const levels = agyModelChoices.find((choice) => choice.modelId === modelId)?.supportedEfforts ?? [] @@ -2174,7 +2341,10 @@ export function NewSession(props: { availableModels={opencodeModelsState.availableModels} currentModelId={opencodeModelsState.currentModelId} selectedModel={opencodeSelectedModel} - onModelChange={setOpencodeSelectedModel} + onModelChange={(modelId) => { + editedModelRef.current = true + setOpencodeSelectedModel(modelId) + }} onRetry={opencodeModelsState.refetch} /> ) : ( @@ -2194,6 +2364,7 @@ export function NewSession(props: { handleCursorBaseChange(value) return } + editedModelRef.current = true setModel(value) setCursorSelectedBase( value === 'auto' ? 'auto' : resolveCursorBaseFromWire(value, cursorPicker.catalog) @@ -2258,7 +2429,10 @@ export function NewSession(props: { : agent === 'pi' && piModelsState.error ? `${t('newSession.model.loadFailed')}: ${piModelsState.error}` : null} - onModelChange={setModel} + onModelChange={(value) => { + editedModelRef.current = true + setModel(value) + }} /> ) )} @@ -2285,13 +2459,18 @@ export function NewSession(props: { autoPermissionModeSupported={agent === 'grok' ? grokModelsState.autoPermissionModeSupported : null} isDisabled={isFormDisabled} onNativeChange={(mode) => { + editedPermissionRef.current = true if (agent === 'grok') { setGrokPermissionMode(mode as GrokPermissionMode) } else { setNativePermissionMode(mode) } }} - onYoloToggle={setYoloMode} + onYoloToggle={(value) => { + editedPermissionRef.current = true + setYoloMode(value) + savePreferredYoloMode(value) + }} /> { @@ -16,7 +17,7 @@ describe('NewSession preferences', () => { it('loads defaults when storage is empty', () => { expect(loadPreferredAgent()).toBe('claude') - expect(loadPreferredYoloMode()).toBe(false) + expect(loadPreferredYoloMode()).toBe(true) }) it('loads saved values from storage', () => { @@ -231,6 +232,76 @@ describe('NewSession preferences', () => { }) }) + it('seeds New Session agent and permission from hub peerSpawnDefaults', () => { + expect(seedNewSessionFromPeerSpawnDefaults({ + agent: 'cursor', + permissionMode: 'yolo', + models: { claude: 'sonnet', cursor: 'auto' } + })).toEqual({ + agent: 'cursor', + yoloMode: true, + permissionMode: 'yolo', + model: 'auto' + }) + + expect(seedNewSessionFromPeerSpawnDefaults({ + agent: 'claude', + permissionMode: 'bypassPermissions', + models: { claude: 'opus' } + })).toEqual({ + agent: 'claude', + yoloMode: true, + permissionMode: 'bypassPermissions', + model: 'opus' + }) + + expect(seedNewSessionFromPeerSpawnDefaults({ + agent: 'codex', + permissionMode: 'read-only', + models: {} + })).toEqual({ + agent: 'codex', + yoloMode: false, + permissionMode: 'read-only', + model: undefined + }) + }) + + it('uses hub permission mode when launch preferences have none', () => { + expect(resolvePreferredLaunchSettings('codex', null, null, 'yolo').permissionMode).toBe('yolo') + expect(resolvePreferredLaunchSettings('claude', null, null, 'bypassPermissions')).toEqual({ + model: 'auto', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default', + permissionMode: 'bypassPermissions' + }) + }) + + it('maps hub yolo through resolvePermissionModeForFlavor before seeding', () => { + expect(resolvePreferredLaunchSettings('claude', null, null, 'yolo').permissionMode) + .toBe('bypassPermissions') + expect(resolvePreferredLaunchSettings('cursor', null, null, 'yolo').permissionMode) + .toBe('yolo') + }) + + it('migrates a saved Cursor YOLO=false toggle over hub yolo', () => { + expect(resolvePreferredLaunchSettings('cursor', null, false, 'yolo').permissionMode) + .toBe('default') + expect(resolvePreferredLaunchSettings('cursor', null, true).permissionMode) + .toBe('yolo') + }) + + it('falls back to Default for an unsupported saved mode instead of hub yolo', () => { + expect(resolvePreferredLaunchSettings('codex', { + model: 'auto', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default', + permissionMode: 'safe-yolo' + }, null, 'yolo').permissionMode).toBe('default') + }) + it.each([ ['kimi', 'safe-yolo'], ['opencode', 'plan'] diff --git a/web/src/components/NewSession/preferences.ts b/web/src/components/NewSession/preferences.ts index 17aa55ad..04f14c95 100644 --- a/web/src/components/NewSession/preferences.ts +++ b/web/src/components/NewSession/preferences.ts @@ -2,8 +2,13 @@ import { CREATABLE_AGENT_FLAVORS, getPermissionModesForFlavor, resolveHapiYoloPermissionMode, + type AgentFlavor, type PermissionMode } from '@hapi/protocol' +import { + resolvePermissionModeForFlavor, + type ResolvedPeerSpawnDefaults +} from '@hapi/protocol/peerSpawnDefaults' import { CLAUDE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, @@ -30,6 +35,44 @@ export type PreferredLaunchSettings = { // (no longer creatable) falls back to 'claude'. const VALID_AGENTS = CREATABLE_AGENT_FLAVORS +const YOLO_STYLE_PERMISSION_MODES: ReadonlySet = new Set([ + 'yolo', + 'safe-yolo', + 'bypassPermissions', + 'always-proceed' +]) + +export function isYoloStylePermissionMode(mode: PermissionMode): boolean { + return YOLO_STYLE_PERMISSION_MODES.has(mode) +} + +/** + * Map hub Settings → General peerSpawnDefaults into New Session form seeds + * so UI create and agent spawn share one authoritative default. + */ +export function seedNewSessionFromPeerSpawnDefaults( + defaults: ResolvedPeerSpawnDefaults +): { + agent: AgentType + yoloMode: boolean + permissionMode: PermissionMode + model: string | undefined +} { + const agent = VALID_AGENTS.includes(defaults.agent as AgentType) + ? defaults.agent as AgentType + : 'claude' + const permissionMode = getPermissionModesForFlavor(agent).includes(defaults.permissionMode) + ? defaults.permissionMode + : (isYoloStylePermissionMode(defaults.permissionMode) ? defaults.permissionMode : 'default') + const model = defaults.models[agent]?.trim() || undefined + return { + agent, + yoloMode: isYoloStylePermissionMode(permissionMode), + permissionMode, + model + } +} + export function loadPreferredAgent(): AgentType { try { const stored = localStorage.getItem(AGENT_STORAGE_KEY) @@ -52,7 +95,21 @@ export function savePreferredAgent(agent: AgentType): void { export function loadPreferredYoloMode(): boolean { try { - return localStorage.getItem(YOLO_STORAGE_KEY) === 'true' + const stored = localStorage.getItem(YOLO_STORAGE_KEY) + // No key → stock yolo (matches hub peerSpawnDefaults / STOCK_PEER_SPAWN_DEFAULTS). + if (stored === null) { + return true + } + return stored === 'true' + } catch { + return true + } +} + +/** True only when the operator previously persisted a Yolo preference. */ +export function hasSavedPreferredYoloMode(): boolean { + try { + return localStorage.getItem(YOLO_STORAGE_KEY) !== null } catch { return false } @@ -130,7 +187,8 @@ function resolvePreferredOptionValue( export function resolvePreferredLaunchSettings( agent: AgentType, preferred: PreferredLaunchSettings | null, - legacyYolo = false + legacyYolo: boolean | null = false, + hubPermissionMode?: PermissionMode ): PreferredLaunchSettings { const preferredModel = preferred?.model ?? 'auto' const staticModelValues = MODEL_OPTIONS[agent].map((option) => option.value) @@ -162,16 +220,34 @@ export function resolvePreferredLaunchSettings( const usesSharedPermissionMode = usesSharedPermissionModeState(agent) const availablePermissionModes = getPermissionModesForFlavor(agent) const preferredPermissionMode = preferred?.permissionMode - // A removed explicit mode falls back to Default, never to a stale YOLO toggle. - const legacyYoloBridgeMode = preferredPermissionMode === undefined && legacyYolo && LEGACY_YOLO_BRIDGE_AGENTS.includes(agent) - ? resolveHapiYoloPermissionMode(agent) + // Migrate a saved HAPI YOLO toggle into the native select (true → yolo-equivalent, + // false → Default). Absent key leaves hub/stock defaults in place. + const legacyYoloBridgeMode = preferredPermissionMode === undefined + && legacyYolo !== null + && LEGACY_YOLO_BRIDGE_AGENTS.includes(agent) + ? (legacyYolo + ? resolveHapiYoloPermissionMode(agent) + : 'default') : null + const hubMode = hubPermissionMode + ? (() => { + const mapped = resolvePermissionModeForFlavor( + hubPermissionMode, + agent as AgentFlavor + ) + return availablePermissionModes.includes(mapped) ? mapped : undefined + })() + : undefined const permissionMode = usesSharedPermissionMode ? preferredPermissionMode && availablePermissionModes.includes(preferredPermissionMode) ? preferredPermissionMode - : legacyYoloBridgeMode && availablePermissionModes.includes(legacyYoloBridgeMode) - ? legacyYoloBridgeMode - : 'default' + : preferredPermissionMode !== undefined + // Explicit saved mode that left the launch catalog → Default, + // never hub yolo (would upgrade sandboxed → unrestricted). + ? 'default' + : legacyYoloBridgeMode && availablePermissionModes.includes(legacyYoloBridgeMode) + ? legacyYoloBridgeMode + : hubMode ?? 'default' : undefined return { diff --git a/web/src/lib/codexFamilyPermissionAgents.ts b/web/src/lib/codexFamilyPermissionAgents.ts index d0160690..288c0c85 100644 --- a/web/src/lib/codexFamilyPermissionAgents.ts +++ b/web/src/lib/codexFamilyPermissionAgents.ts @@ -27,7 +27,10 @@ export function usesCodexFamilyPermissionModes( * through the same native select. */ export function usesNativePermissionSelect(flavor: string | null | undefined): boolean { - return flavor === 'claude' || flavor === 'grok' || usesCodexFamilyPermissionModes(flavor) + return flavor === 'claude' + || flavor === 'grok' + || flavor === 'cursor' + || usesCodexFamilyPermissionModes(flavor) } /** @@ -47,4 +50,4 @@ export function usesSharedPermissionModeState(flavor: string | null | undefined) * once into the native mode it mapped to; flavors that moved earlier already * settled on 'default' and are intentionally left alone. */ -export const LEGACY_YOLO_BRIDGE_AGENTS: readonly AgentFlavor[] = ['codex', 'claude'] +export const LEGACY_YOLO_BRIDGE_AGENTS: readonly AgentFlavor[] = ['codex', 'claude', 'cursor'] diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 04da328e..46adebb3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -879,6 +879,14 @@ export default { 'settings.usage.bucketDetails': '{requests} requests · {input} in · {output} out', 'settings.usage.sessions': '{count} sessions with usage', 'settings.general.description': 'Language, companion pairing, and general application preferences.', + 'settings.general.agents.title': 'Agents', + 'settings.general.agents.description': 'Hub-wide defaults for New Session create and peer spawn (MCP spawn_peer / hapi spawn-peer).', + 'settings.general.peerSpawn.agent': 'Default agent', + 'settings.general.peerSpawn.agent.desc': 'Default flavor for New Session and peer spawn when the caller omits agent.', + 'settings.general.peerSpawn.permissionMode': 'Default permission mode', + 'settings.general.peerSpawn.permissionMode.desc': 'Default permission mode for New Session and peer spawns (yolo maps to each flavor\'s auto-approve equivalent).', + 'settings.general.peerSpawn.model': 'Default model', + 'settings.general.peerSpawn.model.desc': 'Default model id for the selected agent (per-flavor; blur to save). Used by New Session and peer spawn when model is omitted.', 'settings.general.sessionSummary.title': 'Session status summaries', 'settings.general.sessionSummary.description': 'Choose whether supported agents emit status summaries and whether they appear in chat.', 'settings.general.sessionSummaryContract': 'Emit status summaries', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index de8d2b8b..beea0771 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -877,6 +877,14 @@ export default { 'settings.usage.bucketDetails': '{requests} 次请求 · 输入 {input} · 输出 {output}', 'settings.usage.sessions': '{count} 个会话有用量记录', 'settings.general.description': '语言、伴侣应用配对和通用应用偏好。', + 'settings.general.agents.title': '智能体', + 'settings.general.agents.description': '新建会话与对等 spawn(MCP spawn_peer / hapi spawn-peer)共用的中心级默认值。', + 'settings.general.peerSpawn.agent': '默认智能体', + 'settings.general.peerSpawn.agent.desc': '新建会话与对等 spawn 未指定 agent 时的默认风味。', + 'settings.general.peerSpawn.permissionMode': '默认权限模式', + 'settings.general.peerSpawn.permissionMode.desc': '新建会话与对等 spawn 的默认权限模式(yolo 会映射到各风味的自动批准等价项)。', + 'settings.general.peerSpawn.model': '默认模型', + 'settings.general.peerSpawn.model.desc': '所选智能体的默认模型 id(按风味;失焦后保存)。新建会话与对等 spawn 未指定 model 时使用。', 'settings.general.sessionSummary.title': '会话状态摘要', 'settings.general.sessionSummary.description': '选择是否让受支持的智能体输出状态摘要,以及是否在聊天中显示。', 'settings.general.sessionSummaryContract': '输出状态摘要', diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index da7c5f6a..115f282c 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -1,8 +1,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + CREATABLE_AGENT_FLAVORS, + getPermissionModesForFlavor, + getPermissionModeLabel, + type AgentFlavor, + type PermissionMode +} from '@hapi/protocol' +import type { UpdateHubSettingsRequest } from '@hapi/protocol/apiTypes' +import type { ResolvedPeerSpawnDefaults } from '@hapi/protocol/peerSpawnDefaults' import { useTranslation, type Locale } from '@/lib/use-translation' import { useAppContext } from '@/lib/app-context' import { CompanionPairing } from '@/components/settings/CompanionPairing' -import { SettingsChoiceGroup, SettingsPageContent, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' +import { SettingsChoiceGroup, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' import { queryKeys } from '@/lib/query-keys' const locales: ReadonlyArray<{ value: Locale; label: string }> = [ @@ -42,7 +51,7 @@ export default function SettingsGeneralPage() { }) const hubSettingsMutation = useMutation({ - mutationFn: async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean }) => { + mutationFn: async (patch: UpdateHubSettingsRequest) => { if (!api) throw new Error('API unavailable') return await api.updateHubSettings(patch) }, @@ -51,6 +60,26 @@ export default function SettingsGeneralPage() { }, }) + const peerDefaults = hubSettingsQuery.data?.peerSpawnDefaults + const agentOptions = CREATABLE_AGENT_FLAVORS.map((value) => ({ value, label: value })) + const permissionOptions = getPermissionModesForFlavor(peerDefaults?.agent).map((mode) => ({ + value: mode, + label: getPermissionModeLabel(mode) + })) + + function updatePeerSpawnDefaults(next: ResolvedPeerSpawnDefaults) { + if (hubSettingsMutation.isPending) return + hubSettingsMutation.mutate({ + peerSpawnDefaults: { + agent: next.agent, + permissionMode: next.permissionMode, + models: next.models + } + }) + } + + const currentModel = peerDefaults?.models[peerDefaults.agent] ?? '' + return ( @@ -85,6 +114,65 @@ export default function SettingsGeneralPage() { ) : null} ) : null} + {isOwner ? ( + + {peerDefaults ? ( + <> + { + updatePeerSpawnDefaults({ + ...peerDefaults, + agent: agent as AgentFlavor + }) + }} + /> + { + updatePeerSpawnDefaults({ + ...peerDefaults, + permissionMode: permissionMode as PermissionMode + }) + }} + /> + + { + const model = event.target.value.trim() + if (model === currentModel) return + // Empty string is the clear sentinel for this flavor override. + updatePeerSpawnDefaults({ + ...peerDefaults, + models: { + ...peerDefaults.models, + [peerDefaults.agent]: model + } + }) + }} + className="w-full max-w-xs rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm text-[var(--app-fg)]" + /> + + + ) : null} + + ) : null}
diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index acf5524f..ae321886 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -24,8 +24,20 @@ const { context, navigate, setAppearance, setColorTheme, setFontScale, setTermin setAppBadgeEnabled: vi.fn(), })) -const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) -const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) +const hubSettingsFixture = { + sessionSummaryContract: false, + sessionSummaryInChat: false, + peerSpawnDefaults: { + agent: 'claude' as const, + permissionMode: 'bypassPermissions' as const, + models: { claude: 'sonnet' } + } +} +const getHubSettings = vi.fn().mockResolvedValue(hubSettingsFixture) +const updateHubSettings = vi.fn().mockResolvedValue({ + ...hubSettingsFixture, + sessionSummaryContract: true +}) vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => ({ colorTheme: 'default', setColorTheme }), @@ -218,8 +230,11 @@ describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() - getHubSettings.mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) - updateHubSettings.mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) + getHubSettings.mockResolvedValue(hubSettingsFixture) + updateHubSettings.mockResolvedValue({ + ...hubSettingsFixture, + sessionSummaryContract: true + }) context.token = `x.${btoa(JSON.stringify({ ns: 'default' }))}.x` })