diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ee7491..38325fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: - run: npm ci - run: npm run build - name: Run Windows-specific unit tests - run: npx vitest run test/agent/wezterm.test.ts test/daemon/hook-send.test.ts test/daemon/env-injection.test.ts test/cli/send.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts + run: npx vitest run test/agent/wezterm.test.ts test/daemon/hook-send.test.ts test/daemon/env-injection.test.ts test/cli/send.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts test/util/windows-launch-artifacts.test.ts test/util/windows-launch-artifacts.cmd-parse.test.ts test/util/windows-stale-watchdog-cleanup.test.ts env: IMCODES_MUX: wezterm @@ -127,7 +127,7 @@ jobs: - run: npm ci - run: npm run build - name: Run Windows ConPTY / startup regression tests - run: npx vitest run test/agent/conpty.test.ts test/agent/drivers/drivers.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts + run: npx vitest run test/agent/conpty.test.ts test/agent/drivers/drivers.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts test/util/windows-launch-artifacts.test.ts test/util/windows-launch-artifacts.cmd-parse.test.ts test/util/windows-stale-watchdog-cleanup.test.ts # ── Web frontend tests ──────────────────────────────────────────────────── web-tests-unit: diff --git a/shared/p2p-advanced.ts b/shared/p2p-advanced.ts new file mode 100644 index 00000000..1f8543c5 --- /dev/null +++ b/shared/p2p-advanced.ts @@ -0,0 +1,381 @@ +import { isTransportSessionAgentType } from './agent-types.js'; + +const LEGACY_MODE_KEYS = new Set(['audit', 'review', 'plan', 'brainstorm', 'discuss']); +const COMBO_SEPARATOR = '>'; + +export type P2pAdvancedPresetKey = 'openspec'; +export type P2pRoundPreset = + | 'discussion' + | 'openspec_propose' + | 'proposal_audit' + | 'implementation' + | 'implementation_audit' + | 'custom'; +export type P2pRoundExecutionMode = 'single_main' | 'multi_dispatch'; +export type P2pRoundPermissionScope = 'analysis_only' | 'artifact_generation' | 'implementation'; +export type P2pRoundVerdictPolicy = 'none' | 'smart_gate' | 'forced_rework'; +export type P2pContextReducerMode = 'reuse_existing_session' | 'clone_sdk_session'; +export type P2pVerdictMarker = 'PASS' | 'REWORK'; +export type P2pDispatchStyle = 'initiator_only' | 'worker_hops'; +export type P2pSynthesisStyle = 'none' | 'initiator_summary'; + +export interface P2pContextReducerConfig { + mode: P2pContextReducerMode; + sessionName?: string; + templateSession?: string; +} + +export interface P2pAdvancedJumpRule { + targetRoundId: string; + marker?: P2pVerdictMarker; + minTriggers: number; + maxTriggers: number; +} + +export interface P2pAdvancedRound { + id: string; + title: string; + preset: P2pRoundPreset; + executionMode: P2pRoundExecutionMode; + permissionScope: P2pRoundPermissionScope; + timeoutMinutes?: number; + artifactOutputs?: string[]; + promptAppend?: string; + verdictPolicy?: P2pRoundVerdictPolicy; + jumpRule?: P2pAdvancedJumpRule; +} + +export interface P2pParticipantSnapshotEntry { + sessionName: string; + agentType: string; + parentSession?: string | null; +} + +export interface P2pHelperDiagnostic { + code: + | 'P2P_HELPER_PRIMARY_FAILED' + | 'P2P_HELPER_FALLBACK_FAILED' + | 'P2P_HELPER_CLEANUP_FAILED' + | 'P2P_COMPRESSION_SKIPPED_NO_FALLBACK' + | 'P2P_VERDICT_MISSING'; + attempt: number; + sourceSession?: string | null; + templateSession?: string | null; + fallbackSession?: string | null; + timestamp: number; + message?: string; +} + +export interface P2pResolvedRound { + id: string; + title: string; + modeKey: string; + preset: P2pRoundPreset; + executionMode: P2pRoundExecutionMode; + permissionScope: P2pRoundPermissionScope; + timeoutMinutes: number; + timeoutMs: number; + promptAppend: string; + verdictPolicy: P2pRoundVerdictPolicy; + jumpRule?: P2pAdvancedJumpRule; + dispatchStyle: P2pDispatchStyle; + synthesisStyle: P2pSynthesisStyle; + requiresVerdict: boolean; + presetPrompt: string; + summaryPrompt?: string; + authoritativeVerdictWriter: 'initiator_summary' | 'initiator_only' | null; + allowRouting: boolean; + artifactOutputs: string[]; + artifactConvention: 'none' | 'explicit' | 'openspec_convention'; +} + +export interface ResolveP2pRoundPlanOptions { + modeOverride?: string; + roundsOverride?: number; + hopTimeoutMinutes?: number; + advancedPresetKey?: string | null; + advancedRounds?: P2pAdvancedRound[] | null; + advancedRunTimeoutMinutes?: number | null; + contextReducer?: P2pContextReducerConfig | null; + participants?: P2pParticipantSnapshotEntry[] | null; +} + +export interface P2pResolvedPlan { + advanced: boolean; + rounds: P2pResolvedRound[]; + overallRunTimeoutMinutes?: number; + contextReducer?: P2pContextReducerConfig; + helperEligibleSnapshot?: P2pParticipantSnapshotEntry[]; +} + +const DEFAULT_HOP_TIMEOUT_MINUTES = 8; +const DEFAULT_ADVANCED_RUN_TIMEOUT_MINUTES = 30; + +function parseModePipeline(mode: string): string[] { + if (mode.includes(COMBO_SEPARATOR)) { + return mode.split(COMBO_SEPARATOR).map((entry) => entry.trim()).filter(Boolean); + } + return [mode]; +} + +function isValidLegacyMode(mode: string): boolean { + return LEGACY_MODE_KEYS.has(mode); +} + +function validateLegacyMode(mode: string): void { + const pipeline = parseModePipeline(mode); + if (pipeline.length === 0 || pipeline.some((entry) => !isValidLegacyMode(entry))) { + throw new Error(`Invalid P2P mode pipeline: ${mode}`); + } +} + +function cloneRound(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function createOpenSpecPreset(): P2pAdvancedRound[] { + return [ + { + id: 'discussion', + title: 'Discussion', + preset: 'discussion', + executionMode: 'multi_dispatch', + permissionScope: 'analysis_only', + timeoutMinutes: 5, + verdictPolicy: 'none', + }, + { + id: 'openspec_propose', + title: 'OpenSpec Propose', + preset: 'openspec_propose', + executionMode: 'single_main', + permissionScope: 'artifact_generation', + timeoutMinutes: 8, + verdictPolicy: 'none', + }, + { + id: 'proposal_audit', + title: 'Proposal Audit', + preset: 'proposal_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + timeoutMinutes: 6, + verdictPolicy: 'none', + }, + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + timeoutMinutes: 8, + verdictPolicy: 'none', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + timeoutMinutes: 6, + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ]; +} + +export const BUILT_IN_ADVANCED_PRESETS: Record = { + openspec: createOpenSpecPreset(), +}; + +const PRESET_PROMPTS: Record = { + discussion: 'Clarify the request, collect missing constraints, and synthesize the strongest next-step understanding from the evidence in the discussion file and referenced code.', + openspec_propose: 'Produce an OpenSpec-ready proposal/design/tasks result from the discussion and code context. Write concrete artifacts, acceptance criteria, and implementation scope rather than broad notes.', + proposal_audit: 'Audit the proposal artifacts for missing scope, missing acceptance criteria, contradictions, and weak assumptions. Strengthen the proposal without changing the requested objective.', + implementation: 'Execute the implementation work required by the current round. Prefer concrete code and tests over commentary, while staying within the stated scope and artifact targets.', + implementation_audit: 'Audit the implementation result against the requested scope, artifact outputs, and acceptance criteria. End with an authoritative verdict marker.', + custom: 'Follow the configured round contract exactly. Stay within the declared permission scope and use the configured outputs and prompt append as the operative instruction.', +}; + +const SUMMARY_PROMPTS: Partial> = { + discussion: 'Synthesize the key points, areas of agreement, and open questions from this round. Then assign concrete follow-up focus for the next round.', + implementation: 'Synthesize the implementation outputs from the worker evidence. Produce one authoritative implementation summary that references the latest completed attempt.', + implementation_audit: 'Write one authoritative audit synthesis and end with exactly one verdict marker line: `` or ``.', +}; + +function buildLegacyResolvedRound(mode: string, roundIndex: number, totalRounds: number, hopTimeoutMinutes?: number): P2pResolvedRound { + const pipeline = parseModePipeline(mode); + const modeKey = pipeline[Math.min(roundIndex - 1, pipeline.length - 1)] ?? mode; + return { + id: `legacy_${roundIndex}`, + title: `Round ${roundIndex}`, + modeKey, + preset: 'custom', + executionMode: 'multi_dispatch', + permissionScope: 'analysis_only', + timeoutMinutes: hopTimeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES, + timeoutMs: (hopTimeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES) * 60_000, + promptAppend: '', + verdictPolicy: 'none', + dispatchStyle: 'worker_hops', + synthesisStyle: 'initiator_summary', + requiresVerdict: false, + presetPrompt: '', + summaryPrompt: totalRounds === roundIndex ? undefined : 'Synthesize the key points, areas of agreement, and open questions from this round. Then assign concrete follow-up focus for the next round.', + authoritativeVerdictWriter: null, + allowRouting: false, + artifactOutputs: [], + artifactConvention: 'none', + }; +} + +function defaultArtifactConvention(round: P2pAdvancedRound): 'none' | 'explicit' | 'openspec_convention' { + if (round.preset === 'openspec_propose' && (!round.artifactOutputs || round.artifactOutputs.length === 0)) { + return 'openspec_convention'; + } + if (round.permissionScope === 'artifact_generation') return 'explicit'; + return 'none'; +} + +function normalizeAdvancedRound(round: P2pAdvancedRound): P2pResolvedRound { + const verdictPolicy = round.verdictPolicy ?? 'none'; + const artifactConvention = defaultArtifactConvention(round); + const artifactOutputs = artifactConvention === 'openspec_convention' + ? ['openspec/changes'] + : [...(round.artifactOutputs ?? [])]; + const synthesisStyle: P2pSynthesisStyle = round.executionMode === 'multi_dispatch' ? 'initiator_summary' : 'none'; + const requiresVerdict = verdictPolicy !== 'none'; + const authoritativeVerdictWriter = requiresVerdict + ? (round.executionMode === 'multi_dispatch' ? 'initiator_summary' : 'initiator_only') + : null; + const allowRouting = round.preset !== 'proposal_audit' && requiresVerdict && !!round.jumpRule; + return { + id: round.id, + title: round.title, + modeKey: round.preset === 'custom' ? 'custom' : round.preset, + preset: round.preset, + executionMode: round.executionMode, + permissionScope: round.permissionScope, + timeoutMinutes: round.timeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES, + timeoutMs: (round.timeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES) * 60_000, + promptAppend: round.promptAppend?.trim() ?? '', + verdictPolicy, + jumpRule: round.jumpRule ? cloneRound(round.jumpRule) : undefined, + dispatchStyle: round.executionMode === 'single_main' ? 'initiator_only' : 'worker_hops', + synthesisStyle, + requiresVerdict, + presetPrompt: PRESET_PROMPTS[round.preset], + summaryPrompt: synthesisStyle === 'initiator_summary' ? SUMMARY_PROMPTS[round.preset] : undefined, + authoritativeVerdictWriter, + allowRouting, + artifactOutputs, + artifactConvention, + }; +} + +function validateAdvancedRoundIds(rounds: P2pAdvancedRound[]): void { + const seen = new Set(); + for (const round of rounds) { + if (!round.id.trim()) throw new Error('Advanced P2P round ids must be non-empty'); + if (seen.has(round.id)) throw new Error(`Duplicate advanced P2P round id: ${round.id}`); + seen.add(round.id); + } +} + +function validateContextReducer( + reducer: P2pContextReducerConfig | null | undefined, + participants: P2pParticipantSnapshotEntry[] | null | undefined, +): P2pContextReducerConfig | undefined { + if (!reducer) return undefined; + const snapshot = participants ?? []; + const lookup = new Map(snapshot.map((entry) => [entry.sessionName, entry])); + if (reducer.mode === 'reuse_existing_session') { + if (!reducer.sessionName) throw new Error('contextReducer.sessionName is required for reuse_existing_session'); + const target = lookup.get(reducer.sessionName); + if (!target || !isTransportSessionAgentType(target.agentType)) { + throw new Error(`Reducer session is not an eligible SDK-backed participant: ${reducer.sessionName}`); + } + } else { + if (!reducer.templateSession) throw new Error('contextReducer.templateSession is required for clone_sdk_session'); + const template = lookup.get(reducer.templateSession); + if (!template || !isTransportSessionAgentType(template.agentType)) { + throw new Error(`Reducer template is not an eligible SDK-backed participant: ${reducer.templateSession}`); + } + } + return cloneRound(reducer); +} + +function validateAdvancedRounds(rounds: P2pAdvancedRound[]): void { + validateAdvancedRoundIds(rounds); + const ids = new Set(rounds.map((round) => round.id)); + for (const round of rounds) { + const verdictPolicy = round.verdictPolicy ?? 'none'; + const artifactConvention = defaultArtifactConvention(round); + if (round.permissionScope === 'artifact_generation' && artifactConvention === 'explicit' && (!round.artifactOutputs || round.artifactOutputs.length === 0)) { + throw new Error(`Artifact-generation round "${round.id}" must declare artifact outputs`); + } + if (verdictPolicy === 'forced_rework') { + if (!round.jumpRule) throw new Error(`forced_rework round "${round.id}" requires a jumpRule`); + if (round.jumpRule.minTriggers < 0) throw new Error(`forced_rework round "${round.id}" has invalid minTriggers`); + if (round.jumpRule.maxTriggers < round.jumpRule.minTriggers) throw new Error(`forced_rework round "${round.id}" has invalid maxTriggers`); + } + if (round.jumpRule) { + if (!ids.has(round.jumpRule.targetRoundId)) throw new Error(`Round "${round.id}" jumps to unknown target "${round.jumpRule.targetRoundId}"`); + const currentIndex = rounds.findIndex((entry) => entry.id === round.id); + const targetIndex = rounds.findIndex((entry) => entry.id === round.jumpRule?.targetRoundId); + if (targetIndex >= currentIndex) throw new Error(`Round "${round.id}" must jump backward to an earlier round`); + if (round.preset === 'proposal_audit') throw new Error('proposal_audit cannot drive routing in v1'); + } + } +} + +export function resolveP2pRoundPlan(options: ResolveP2pRoundPlanOptions): P2pResolvedPlan { + const { + modeOverride, + roundsOverride, + hopTimeoutMinutes, + advancedPresetKey, + advancedRounds, + advancedRunTimeoutMinutes, + contextReducer, + participants, + } = options; + + const advancedRequested = !!advancedPresetKey || !!advancedRounds?.length; + if (!advancedRequested) { + const mode = modeOverride ?? 'discuss'; + validateLegacyMode(mode); + const comboRounds = parseModePipeline(mode).length; + const totalRounds = Math.max(1, roundsOverride ?? comboRounds); + return { + advanced: false, + rounds: Array.from({ length: totalRounds }, (_, index) => buildLegacyResolvedRound(mode, index + 1, totalRounds, hopTimeoutMinutes)), + }; + } + + if (advancedPresetKey && advancedPresetKey !== 'openspec') { + throw new Error(`Unknown advanced P2P preset: ${advancedPresetKey}`); + } + + const presetRounds = advancedPresetKey === 'openspec' + ? cloneRound(BUILT_IN_ADVANCED_PRESETS.openspec) + : []; + const rawRounds = advancedRounds?.length ? cloneRound(advancedRounds) : presetRounds; + if (rawRounds.length === 0) throw new Error('Advanced P2P requires at least one round'); + validateAdvancedRounds(rawRounds); + const validatedReducer = validateContextReducer(contextReducer, participants); + const helperEligibleSnapshot = (participants ?? []).filter((entry) => isTransportSessionAgentType(entry.agentType)); + + return { + advanced: true, + rounds: rawRounds.map((round) => normalizeAdvancedRound(round)), + overallRunTimeoutMinutes: advancedRunTimeoutMinutes ?? DEFAULT_ADVANCED_RUN_TIMEOUT_MINUTES, + contextReducer: validatedReducer, + helperEligibleSnapshot, + }; +} diff --git a/shared/p2p-modes.ts b/shared/p2p-modes.ts index ec9e2448..5285bae5 100644 --- a/shared/p2p-modes.ts +++ b/shared/p2p-modes.ts @@ -1,4 +1,5 @@ /** P2P Quick Discussion mode configuration. */ +import type { P2pAdvancedPresetKey, P2pAdvancedRound, P2pContextReducerConfig } from './p2p-advanced.js'; /** The "config" meta-mode — each session uses its own saved default mode. */ export const P2P_CONFIG_MODE = 'config' as const; @@ -18,6 +19,14 @@ export interface P2pSavedConfig { extraPrompt?: string; /** Per-hop timeout in minutes. Default: 8. */ hopTimeoutMinutes?: number; + /** Built-in advanced workflow preset key. */ + advancedPresetKey?: P2pAdvancedPresetKey; + /** Advanced round overrides / full custom workflow definition. */ + advancedRounds?: P2pAdvancedRound[]; + /** Whole-run timeout for advanced workflows in minutes. */ + advancedRunTimeoutMinutes?: number; + /** Optional context compression/helper config for advanced workflows. */ + contextReducer?: P2pContextReducerConfig; } diff --git a/shared/p2p-status.ts b/shared/p2p-status.ts index 0e1c5740..534b2c49 100644 --- a/shared/p2p-status.ts +++ b/shared/p2p-status.ts @@ -5,6 +5,7 @@ * server/web consumers, and expose richer parallel-hop progress through * additive fields. */ +import type { P2pHelperDiagnostic } from './p2p-advanced.js'; export const P2P_RUN_STATUS_VALUES = [ 'queued', @@ -175,6 +176,29 @@ export interface P2pRunUpdatePayload { hop_counts?: P2pHopCounts; completed_round_hops_count?: number; terminal_reason?: 'completed' | 'timed_out' | 'failed' | 'cancelled' | null; + advanced_p2p_enabled?: boolean; + current_round_id?: string | null; + current_execution_step?: number | null; + current_round_attempt?: number | null; + round_attempt_counts?: Record; + round_jump_counts?: Record; + routing_history?: Array<{ + fromRoundId?: string | null; + toRoundId?: string | null; + trigger?: string | null; + atStep: number; + atAttempt?: number | null; + timestamp: number; + }>; + helper_diagnostics?: P2pHelperDiagnostic[]; + advanced_nodes?: Array<{ + id: string; + title: string; + preset?: string; + status: P2pProgressNodeStatus; + attempt?: number; + step?: number; + }>; [key: string]: unknown; } diff --git a/src/agent/providers/claude-code-sdk.ts b/src/agent/providers/claude-code-sdk.ts index 8fb1a2f7..a85a98b6 100644 --- a/src/agent/providers/claude-code-sdk.ts +++ b/src/agent/providers/claude-code-sdk.ts @@ -10,6 +10,7 @@ import type { ProviderError, SessionConfig, SessionInfoUpdate, + ProviderStatusUpdate, ToolCallEvent, } from '../transport-provider.js'; import { @@ -30,7 +31,9 @@ interface ClaudeSdkSessionState { cwd: string; env?: Record; model?: string; + settings?: string | Record; description?: string; + systemPrompt?: string; permissionMode: PermissionMode; effort?: TransportEffortLevel; started: boolean; @@ -45,6 +48,7 @@ interface ClaudeSdkSessionState { pendingError?: ProviderError; toolCalls: Map; emittedToolStates: Map; + lastStatusSignature: string | null; } type ClaudeToolBlock = { @@ -91,6 +95,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { private errorCallbacks: Array<(sessionId: string, error: ProviderError) => void> = []; private toolCallCallbacks: Array<(sessionId: string, tool: ToolCallEvent) => void> = []; private sessionInfoCallbacks: Array<(sessionId: string, info: SessionInfoUpdate) => void> = []; + private statusCallbacks: Array<(sessionId: string, status: ProviderStatusUpdate) => void> = []; async connect(config: ProviderConfig): Promise { const binaryPath = this.resolveBinaryPath(config); @@ -122,7 +127,9 @@ export class ClaudeCodeSdkProvider implements TransportProvider { cwd: normalizeTransportCwd(config.cwd) ?? existing?.cwd ?? normalizeTransportCwd(process.cwd())!, env: config.env ?? existing?.env, model: typeof config.agentId === 'string' ? config.agentId : existing?.model, + settings: config.settings ?? existing?.settings, description: config.description ?? existing?.description, + systemPrompt: config.systemPrompt ?? existing?.systemPrompt, permissionMode: this.resolvePermissionMode(), effort: config.effort ?? existing?.effort, started: !!(config.resumeId && config.skipCreate), @@ -136,6 +143,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { pendingComplete: undefined, toolCalls: new Map(), emittedToolStates: new Map(), + lastStatusSignature: null, }); this.emitSessionInfo(routeId, { resumeId, ...(config.effort ? { effort: config.effort } : {}) }); return routeId; @@ -185,6 +193,14 @@ export class ClaudeCodeSdkProvider implements TransportProvider { }; } + onStatus(cb: (sessionId: string, status: ProviderStatusUpdate) => void): () => void { + this.statusCallbacks.push(cb); + return () => { + const idx = this.statusCallbacks.indexOf(cb); + if (idx >= 0) this.statusCallbacks.splice(idx, 1); + }; + } + setSessionAgentId(sessionId: string, agentId: string): void { const state = this.sessions.get(sessionId); if (!state) return; @@ -241,8 +257,10 @@ export class ClaudeCodeSdkProvider implements TransportProvider { state.pendingError = undefined; state.toolCalls.clear(); state.emittedToolStates.clear(); + state.lastStatusSignature = null; const resolvedBinary = this.resolveBinaryPath(this.config); + const baseSystemPrompt = extraSystemPrompt ?? state.description; const options: Record = { cwd: state.cwd, ...(state.env ? { env: { ...process.env, ...state.env } } : {}), @@ -251,8 +269,11 @@ export class ClaudeCodeSdkProvider implements TransportProvider { includePartialMessages: true, ...(state.started ? { resume: state.resumeId } : { sessionId: state.resumeId }), ...(state.model ? { model: state.model } : {}), + ...(state.settings ? { settings: state.settings } : {}), ...(state.effort ? { effort: state.effort } : {}), - ...(extraSystemPrompt ? { appendSystemPrompt: extraSystemPrompt } : {}), + ...(baseSystemPrompt ?? state.systemPrompt ? { + appendSystemPrompt: [baseSystemPrompt, state.systemPrompt].filter(Boolean).join('\n\n'), + } : {}), }; // On Windows where claude resolved to a .cmd shim, override the SDK's // internal spawn so we can prepend `node script.js` and avoid spawn @@ -344,6 +365,29 @@ export class ClaudeCodeSdkProvider implements TransportProvider { return; } + if (msg.type === 'system' && msg.subtype === 'compact_boundary') { + this.emitStatus(sessionId, state, { + status: 'compacting', + label: 'Compacting conversation...', + }); + return; + } + + if (msg.type === 'system' && msg.subtype === 'status') { + if (msg.status === 'compacting') { + this.emitStatus(sessionId, state, { + status: 'compacting', + label: 'Compacting conversation...', + }); + } else { + this.emitStatus(sessionId, state, { + status: msg.status, + label: null, + }); + } + return; + } + if (msg.type === 'stream_event') { const event = msg.event; if (event.type === 'message_start' && event.message?.id) { @@ -520,6 +564,16 @@ export class ClaudeCodeSdkProvider implements TransportProvider { for (const cb of this.sessionInfoCallbacks) cb(sessionId, info); } + private emitStatus(sessionId: string, state: ClaudeSdkSessionState, status: ProviderStatusUpdate): void { + const signature = JSON.stringify({ + status: status.status, + label: status.label ?? null, + }); + if (state.lastStatusSignature === signature) return; + state.lastStatusSignature = signature; + for (const cb of this.statusCallbacks) cb(sessionId, status); + } + private emitError(sessionId: string, error: ProviderError): void { if (this.sessions.get(sessionId)?.completed && error.code === PROVIDER_ERROR_CODES.CANCELLED) return; for (const cb of this.errorCallbacks) cb(sessionId, error); diff --git a/src/agent/providers/codex-sdk.ts b/src/agent/providers/codex-sdk.ts index 7e2f9614..4a936474 100644 --- a/src/agent/providers/codex-sdk.ts +++ b/src/agent/providers/codex-sdk.ts @@ -9,6 +9,7 @@ import type { ProviderError, SessionConfig, SessionInfoUpdate, + ProviderStatusUpdate, ToolCallEvent, } from '../transport-provider.js'; import { @@ -53,6 +54,7 @@ interface CodexSdkSessionState { cached_input_tokens: number; output_tokens: number; }; + lastStatusSignature: string | null; } function toolFromItem(item: Record, lifecycle: 'started' | 'completed'): ToolCallEvent | null { @@ -173,6 +175,7 @@ export class CodexSdkProvider implements TransportProvider { private errorCallbacks: Array<(sessionId: string, error: ProviderError) => void> = []; private toolCallCallbacks: Array<(sessionId: string, tool: ToolCallEvent) => void> = []; private sessionInfoCallbacks: Array<(sessionId: string, info: SessionInfoUpdate) => void> = []; + private statusCallbacks: Array<(sessionId: string, status: ProviderStatusUpdate) => void> = []; private child: ChildProcessWithoutNullStreams | null = null; private rl: ReadlineInterface | null = null; private nextRequestId = 1; @@ -221,6 +224,7 @@ export class CodexSdkProvider implements TransportProvider { pendingComplete: undefined, cancelled: false, lastUsage: undefined, + lastStatusSignature: null, }); if (config.resumeId || config.effort) this.emitSessionInfo(routeId, { ...(config.resumeId ? { resumeId: config.resumeId } : {}), ...(config.effort ? { effort: config.effort } : {}) }); return routeId; @@ -272,6 +276,14 @@ export class CodexSdkProvider implements TransportProvider { }; } + onStatus(cb: (sessionId: string, status: ProviderStatusUpdate) => void): () => void { + this.statusCallbacks.push(cb); + return () => { + const idx = this.statusCallbacks.indexOf(cb); + if (idx >= 0) this.statusCallbacks.splice(idx, 1); + }; + } + setSessionAgentId(sessionId: string, agentId: string): void { const state = this.sessions.get(sessionId); if (!state) return; @@ -302,6 +314,7 @@ export class CodexSdkProvider implements TransportProvider { state.pendingComplete = undefined; state.cancelled = false; state.lastUsage = undefined; + state.lastStatusSignature = null; await this.startTurn(sessionId, state, message); } @@ -470,6 +483,7 @@ export class CodexSdkProvider implements TransportProvider { const sessionId = this.threadToSession.get(params.threadId); const state = sessionId ? this.sessions.get(sessionId) : null; if (!sessionId || !state) return; + this.clearStatus(sessionId, state); state.currentMessageId = params.itemId; state.currentText += String(params.delta ?? ''); const delta: MessageDelta = { @@ -490,6 +504,16 @@ export class CodexSdkProvider implements TransportProvider { const item = params.item as Record | undefined; if (!item) return; + if (item.type === 'reasoning') { + this.emitStatus(sessionId, state, { + status: 'thinking', + label: 'Thinking...', + }); + return; + } + + this.clearStatus(sessionId, state); + const tool = toolFromItem(item, method === 'item/started' ? 'started' : 'completed'); if (tool) { for (const cb of this.toolCallCallbacks) cb(sessionId, tool); @@ -522,16 +546,19 @@ export class CodexSdkProvider implements TransportProvider { const status = turn.status; if (status === 'failed') { + this.clearStatus(sessionId, state); state.runningTurnId = undefined; this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, turn.error?.message ?? 'Codex turn failed', false, turn.error)); return; } if (status === 'interrupted') { + this.clearStatus(sessionId, state); state.runningTurnId = undefined; this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.CANCELLED, 'Codex turn cancelled', true)); return; } + this.clearStatus(sessionId, state); state.pendingComplete = { id: state.currentMessageId ?? `${sessionId}:agent-message`, sessionId, @@ -582,6 +609,20 @@ export class CodexSdkProvider implements TransportProvider { for (const cb of this.sessionInfoCallbacks) cb(sessionId, info); } + private emitStatus(sessionId: string, state: CodexSdkSessionState, status: ProviderStatusUpdate): void { + const signature = JSON.stringify({ + status: status.status, + label: status.label ?? null, + }); + if (state.lastStatusSignature === signature) return; + state.lastStatusSignature = signature; + for (const cb of this.statusCallbacks) cb(sessionId, status); + } + + private clearStatus(sessionId: string, state: CodexSdkSessionState): void { + this.emitStatus(sessionId, state, { status: null, label: null }); + } + private emitError(sessionId: string, error: ProviderError): void { for (const cb of this.errorCallbacks) cb(sessionId, error); } diff --git a/src/agent/providers/qwen.ts b/src/agent/providers/qwen.ts index 01282761..837065a4 100644 --- a/src/agent/providers/qwen.ts +++ b/src/agent/providers/qwen.ts @@ -10,6 +10,7 @@ import type { ProviderCapabilities, ProviderConfig, ProviderError, + ProviderStatusUpdate, SessionConfig, ToolCallEvent, } from '../transport-provider.js'; @@ -17,6 +18,7 @@ import { CONNECTION_MODES, SESSION_OWNERSHIP, PROVIDER_ERROR_CODES, + type SessionInfoUpdate, } from '../transport-provider.js'; import type { AgentMessage, MessageDelta } from '../../../shared/agent-message.js'; import { DEFAULT_TRANSPORT_EFFORT, QWEN_EFFORT_LEVELS, type TransportEffortLevel } from '../../../shared/effort-levels.js'; @@ -33,6 +35,7 @@ interface QwenSessionState { description?: string; model?: string; effort: TransportEffortLevel; + settings?: string | Record; settingsDir?: string; settingsPath?: string; /** Internal Qwen CLI conversation ID — decoupled from provider session ID so cancel can start fresh. */ @@ -46,6 +49,7 @@ interface QwenSessionState { toolUseByIndex: Map; toolUseById: Map; emittedToolSignatures: Map; + lastStatusSignature: string | null; } function toQwenReasoning(effort: TransportEffortLevel): false | { effort: 'low' | 'medium' | 'high' } { @@ -185,6 +189,8 @@ export class QwenProvider implements TransportProvider { private completeCallbacks: Array<(sessionId: string, message: AgentMessage) => void> = []; private errorCallbacks: Array<(sessionId: string, error: ProviderError) => void> = []; private toolCallCallbacks: Array<(sessionId: string, tool: ToolCallEvent) => void> = []; + private statusCallbacks: Array<(sessionId: string, status: ProviderStatusUpdate) => void> = []; + private sessionInfoCallbacks: Array<(sessionId: string, info: SessionInfoUpdate) => void> = []; async connect(config: ProviderConfig): Promise { const resolved = resolveExecutableForSpawn(QWEN_BIN); @@ -214,6 +220,7 @@ export class QwenProvider implements TransportProvider { description: config.description ?? existing?.description, model: typeof config.agentId === 'string' ? config.agentId : existing?.model, effort: config.effort ?? existing?.effort ?? DEFAULT_TRANSPORT_EFFORT, + settings: config.settings ?? existing?.settings, settingsDir: existing?.settingsDir, settingsPath: existing?.settingsPath, qwenConversationId: existing?.qwenConversationId ?? sessionId, @@ -226,6 +233,7 @@ export class QwenProvider implements TransportProvider { toolUseByIndex: existing?.toolUseByIndex ?? new Map(), toolUseById: existing?.toolUseById ?? new Map(), emittedToolSignatures: existing?.emittedToolSignatures ?? new Map(), + lastStatusSignature: existing?.lastStatusSignature ?? null, }); return sessionId; } @@ -267,6 +275,22 @@ export class QwenProvider implements TransportProvider { this.toolCallCallbacks.push(cb); } + onStatus(cb: (sessionId: string, status: ProviderStatusUpdate) => void): () => void { + this.statusCallbacks.push(cb); + return () => { + const idx = this.statusCallbacks.indexOf(cb); + if (idx >= 0) this.statusCallbacks.splice(idx, 1); + }; + } + + onSessionInfo(cb: (sessionId: string, info: SessionInfoUpdate) => void): () => void { + this.sessionInfoCallbacks.push(cb); + return () => { + const idx = this.sessionInfoCallbacks.indexOf(cb); + if (idx >= 0) this.sessionInfoCallbacks.splice(idx, 1); + }; + } + setSessionAgentId(sessionId: string, agentId: string): void { const state = this.sessions.get(sessionId); if (!state) return; @@ -281,7 +305,13 @@ export class QwenProvider implements TransportProvider { await this.ensureSettingsPath(state); } - async send(sessionId: string, message: string, _attachments?: unknown[], extraSystemPrompt?: string): Promise { + async send( + sessionId: string, + message: string, + _attachments?: unknown[], + extraSystemPrompt?: string, + allowResumeFallback = true, + ): Promise { if (!this.config) { throw this.makeError(PROVIDER_ERROR_CODES.CONNECTION_LOST, 'Qwen provider not connected', false); } @@ -292,6 +322,7 @@ export class QwenProvider implements TransportProvider { description: undefined, model: undefined, effort: DEFAULT_TRANSPORT_EFFORT, + settings: undefined, settingsDir: undefined, settingsPath: undefined, qwenConversationId: sessionId, @@ -304,6 +335,7 @@ export class QwenProvider implements TransportProvider { toolUseByIndex: new Map(), toolUseById: new Map(), emittedToolSignatures: new Map(), + lastStatusSignature: null, }; if (state.child && !state.child.killed) { throw this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, 'Qwen session is already busy', true); @@ -317,6 +349,7 @@ export class QwenProvider implements TransportProvider { state.toolUseByIndex.clear(); state.toolUseById.clear(); state.emittedToolSignatures.clear(); + state.lastStatusSignature = null; const args = [ '-p', message, @@ -424,11 +457,20 @@ export class QwenProvider implements TransportProvider { const event = payload.event; if (!event) return; if (event.type === 'message_start') { + this.clearStatus(sessionId, state); state.currentMessageId = event.message?.id ?? randomUUID(); state.currentText = ''; return; } + if (event.type === 'content_block_start' && event.content_block?.type === 'thinking') { + this.emitStatus(sessionId, state, { + status: 'thinking', + label: 'Thinking...', + }); + return; + } if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') { + this.clearStatus(sessionId, state); const toolId = event.content_block.id ?? randomUUID(); const toolName = event.content_block.name ?? 'tool'; const toolInput = event.content_block.input; @@ -463,6 +505,7 @@ export class QwenProvider implements TransportProvider { return; } if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && typeof event.delta.text === 'string') { + this.clearStatus(sessionId, state); state.currentMessageId ??= randomUUID(); state.currentText += event.delta.text; this.deltaCallbacks.forEach((cb) => cb(sessionId, { @@ -473,6 +516,13 @@ export class QwenProvider implements TransportProvider { })); return; } + if (event.type === 'content_block_delta' && event.delta?.type === 'thinking_delta' && typeof event.delta.thinking === 'string') { + this.emitStatus(sessionId, state, { + status: 'thinking', + label: 'Thinking...', + }); + return; + } if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta' && typeof event.index === 'number') { const tool = state.toolUseByIndex.get(event.index); if (!tool) return; @@ -500,6 +550,14 @@ export class QwenProvider implements TransportProvider { } if (payload.type === 'assistant') { + if ((payload.message?.content ?? []).some((block) => block?.type === 'thinking')) { + this.emitStatus(sessionId, state, { + status: 'thinking', + label: 'Thinking...', + }); + } else { + this.clearStatus(sessionId, state); + } for (const block of payload.message?.content ?? []) { if (block?.type === 'tool_use' && block.id) { if (hasMeaningfulToolValue(block.input)) { @@ -530,6 +588,7 @@ export class QwenProvider implements TransportProvider { } if (payload.type === 'user') { + this.clearStatus(sessionId, state); for (const block of payload.message?.content ?? []) { if (block?.type !== 'tool_result' || !block.tool_use_id) continue; const output = stringifyToolResultContent(block.content); @@ -551,6 +610,7 @@ export class QwenProvider implements TransportProvider { } if (payload.type === 'result') { + this.clearStatus(sessionId, state); if (payload.is_error) { emitError(payload.error?.message || stderrBuf || 'Qwen execution failed', payload); return; @@ -591,6 +651,18 @@ export class QwenProvider implements TransportProvider { } } if (!completed && !sawError && code !== 0) { + if (allowResumeFallback && state.started && /No saved session found with ID/i.test(stderrBuf)) { + state.started = false; + state.qwenConversationId = randomUUID(); + this.emitSessionInfo(sessionId, { resumeId: state.qwenConversationId }); + void this.send(sessionId, message, _attachments, extraSystemPrompt, false).catch((err) => { + const providerError = typeof err === 'object' && err && 'code' in err + ? err as ProviderError + : this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, String(err), false, err); + emitError(providerError.message, providerError.details ?? providerError); + }); + return; + } emitError(stderrBuf.trim() || `Qwen exited with code ${code ?? 'null'}${signal ? ` (${signal})` : ''}`); } }); @@ -635,18 +707,50 @@ export class QwenProvider implements TransportProvider { return { code, message, recoverable, details }; } + private emitStatus(sessionId: string, state: QwenSessionState, status: ProviderStatusUpdate): void { + const signature = JSON.stringify({ + status: status.status, + label: status.label ?? null, + }); + if (state.lastStatusSignature === signature) return; + state.lastStatusSignature = signature; + for (const cb of this.statusCallbacks) cb(sessionId, status); + } + + private emitSessionInfo(sessionId: string, info: SessionInfoUpdate): void { + for (const cb of this.sessionInfoCallbacks) cb(sessionId, info); + } + + private clearStatus(sessionId: string, state: QwenSessionState): void { + this.emitStatus(sessionId, state, { status: null, label: null }); + } + private async ensureSettingsPath(state: QwenSessionState): Promise { if (!state.settingsDir) { state.settingsDir = await mkdtemp(path.join(os.tmpdir(), 'imcodes-qwen-thinking-')); state.settingsPath = path.join(state.settingsDir, 'settings.json'); } - const next = { - model: { - generationConfig: { - reasoning: toQwenReasoning(state.effort), - }, + const base = typeof state.settings === 'string' + ? {} + : (state.settings && typeof state.settings === 'object' ? state.settings : {}); + const nextModel = { + ...(base.model && typeof base.model === 'object' ? base.model as Record : {}), + generationConfig: { + ...( + base.model + && typeof base.model === 'object' + && (base.model as Record).generationConfig + && typeof (base.model as Record).generationConfig === 'object' + ? (base.model as Record).generationConfig as Record + : {} + ), + reasoning: toQwenReasoning(state.effort), }, }; + const next = { + ...base, + model: nextModel, + }; let current = ''; if (state.settingsPath) { try { diff --git a/src/agent/session-manager.ts b/src/agent/session-manager.ts index b4335443..a9116d69 100644 --- a/src/agent/session-manager.ts +++ b/src/agent/session-manager.ts @@ -862,7 +862,9 @@ export async function relaunchSessionWithSettings( requestedModel: targetRequestedModel ?? undefined, effort: targetEffort ?? undefined, transportConfig: targetTransportConfig ?? undefined, - ccPreset: targetAgentType === 'claude-code' ? (targetCcPreset ?? undefined) : undefined, + ccPreset: (targetAgentType === 'claude-code' || targetAgentType === 'claude-code-sdk' || targetAgentType === 'qwen') + ? (targetCcPreset ?? undefined) + : undefined, ...(preserveTransportBinding ? { bindExistingKey: record.providerSessionId, skipCreate: true, @@ -916,6 +918,12 @@ function wireTransportSessionInfo(runtime: TransportSessionRuntime, sessionName: next.codexSessionId = info.resumeId; changed = true; } + if (agentType === 'qwen' && next.providerSessionId !== info.resumeId) { + if (next.providerSessionId) unregisterProviderRoute(next.providerSessionId); + next.providerSessionId = info.resumeId; + registerProviderRoute(info.resumeId, sessionName); + changed = true; + } } if (typeof info.model === 'string' && info.model) { @@ -1012,7 +1020,7 @@ export async function restoreTransportSessions(providerId: string): Promise | undefined; + let systemPrompt: string | undefined; + let transportSettings: string | Record | undefined; + let effectiveRequestedModel = effectiveQwenModel; if (s.providerId === 'claude-code-sdk' && s.ccPreset) { - const { resolvePresetEnv } = await import('../daemon/cc-presets.js'); + const { resolvePresetEnv, getPresetTransportOverrides } = await import('../daemon/cc-presets.js'); extraEnv = await resolvePresetEnv(s.ccPreset, s.ccSessionId ?? undefined); + const presetOverrides = await getPresetTransportOverrides(s.ccPreset); + if (!effectiveRequestedModel && presetOverrides.model) effectiveRequestedModel = presetOverrides.model; + systemPrompt = presetOverrides.systemPrompt; + } else if (s.providerId === 'qwen' && s.ccPreset) { + const { getQwenPresetTransportConfig } = await import('../daemon/cc-presets.js'); + const presetConfig = await getQwenPresetTransportConfig(s.ccPreset); + extraEnv = { ...(extraEnv ?? {}), ...presetConfig.env }; + if (!effectiveRequestedModel && presetConfig.model) effectiveRequestedModel = presetConfig.model; + transportSettings = presetConfig.settings; + if (presetConfig.model) { + const nextModels = new Set([...(availableQwenModels ?? []), presetConfig.model]); + availableQwenModels = [...nextModels]; + } } await runtime.initialize({ sessionKey: effectiveSessionKey, @@ -1045,12 +1069,15 @@ export async function restoreTransportSessions(providerId: string): Promise 0 ? { qwenAvailableModels: availableQwenModels } : {}), ...getQwenDisplayMetadata({ - model: effectiveQwenModel, + model: effectiveRequestedModel, authType: qwenRuntime?.authType ?? s.qwenAuthType, authLimit: qwenRuntime?.authLimit ?? s.qwenAuthLimit, quotaUsageLabel: (qwenRuntime?.authType ?? s.qwenAuthType) === 'qwen-oauth' ? getQwenOAuthQuotaUsageLabel() : undefined, @@ -1118,6 +1145,8 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { let qwenAuthLimit: SessionRecord['qwenAuthLimit'] | undefined; let availableQwenModels: string[] | undefined; let sdkDisplay: Pick | undefined; + let transportSystemPrompt: string | undefined; + let transportSettings: string | Record | undefined; const storedRequestedModel = !opts.fresh ? existing?.requestedModel : undefined; let requestedTransportModel = opts.requestedModel ?? storedRequestedModel ?? (agentType === 'qwen' ? (opts.qwenModel ?? existing?.qwenModel) : undefined); const effectiveTransportConfig = opts.transportConfig ?? existing?.transportConfig ?? {}; @@ -1128,6 +1157,17 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { qwenAuthType = qwenRuntime?.authType; qwenAuthLimit = qwenRuntime?.authLimit; availableQwenModels = qwenRuntime?.availableModels ?? []; + if (opts.ccPreset) { + const { getQwenPresetTransportConfig } = await import('../daemon/cc-presets.js'); + const presetConfig = await getQwenPresetTransportConfig(opts.ccPreset); + transportEnv = { ...(transportEnv ?? {}), ...presetConfig.env }; + if (!requestedTransportModel && presetConfig.model) requestedTransportModel = presetConfig.model; + if (presetConfig.settings) transportSettings = presetConfig.settings; + if (presetConfig.model) { + const nextModels = new Set([...(availableQwenModels ?? []), presetConfig.model]); + availableQwenModels = [...nextModels]; + } + } if (!requestedTransportModel || (availableQwenModels.length > 0 && !availableQwenModels.includes(requestedTransportModel))) { requestedTransportModel = availableQwenModels[0] ?? requestedTransportModel; } @@ -1150,8 +1190,17 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { effectiveSkipCreate = true; } if (opts.ccPreset) { - const { resolvePresetEnv } = await import('../daemon/cc-presets.js'); + const { resolvePresetEnv, getPresetTransportOverrides } = await import('../daemon/cc-presets.js'); transportEnv = { ...(transportEnv ?? {}), ...(await resolvePresetEnv(opts.ccPreset, transportResumeId)) }; + const presetOverrides = await getPresetTransportOverrides(opts.ccPreset); + if (!requestedTransportModel && presetOverrides.model) requestedTransportModel = presetOverrides.model; + transportSystemPrompt = presetOverrides.systemPrompt; + } + if (requestedTransportModel) { + transportSettings = { + model: requestedTransportModel, + availableModels: [requestedTransportModel], + }; } sdkDisplay = await getClaudeSdkRuntimeConfig().catch(() => ({})); } else if (agentType === 'codex-sdk') { @@ -1167,6 +1216,8 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { cwd: projectDir, label: label || name, description, + ...(transportSystemPrompt ? { systemPrompt: transportSystemPrompt } : {}), + ...(transportSettings ? { settings: transportSettings } : {}), agentId: requestedTransportModel, bindExistingKey: effectiveBindExistingKey, skipCreate: effectiveSkipCreate, @@ -1214,6 +1265,7 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { ...(sdkDisplay ?? {}), ...(opts.effort ? { effort: opts.effort } : {}), description, + ...(opts.ccPreset ? { ccPreset: opts.ccPreset } : {}), label, parentSession, userCreated: opts.userCreated, diff --git a/src/agent/transport-provider.ts b/src/agent/transport-provider.ts index 641aeb5e..ef8851bb 100644 --- a/src/agent/transport-provider.ts +++ b/src/agent/transport-provider.ts @@ -114,6 +114,10 @@ export interface SessionConfig { label?: string; /** Persona/system prompt injection — used for session description/role. */ description?: string; + /** Runtime/system prompt injection that should not be surfaced as user-facing description. */ + systemPrompt?: string; + /** Provider-specific SDK/CLI settings object or settings file path. */ + settings?: string | Record; /** Parent session key for sub-sessions. */ parentSessionKey?: string; /** If binding to an already-existing remote session, use this key directly. */ @@ -178,6 +182,14 @@ export interface SessionInfoUpdate { effort?: TransportEffortLevel; } +/** Provider-reported transient execution status (e.g. compacting). */ +export interface ProviderStatusUpdate { + /** Machine-readable transient status. Null clears any previously active status. */ + status: string | null; + /** Human-readable label shown in the footer/status line. Null clears the label. */ + label?: string | null; +} + // ── TransportProvider interface ───────────────────────────────────────────── /** @@ -284,6 +296,12 @@ export interface TransportProvider { */ onSessionInfo?(cb: (sessionId: string, info: SessionInfoUpdate) => void): () => void; + /** + * Register a callback for transient provider status changes (e.g. compacting). + * Used by SDK-backed providers that can surface phases before a final response arrives. + */ + onStatus?(cb: (sessionId: string, status: ProviderStatusUpdate) => void): () => void; + /** * Register a callback for approval requests from the agent. * Only call when capabilities.approval is true. diff --git a/src/agent/transport-session-runtime.ts b/src/agent/transport-session-runtime.ts index 404a6e8f..2f6c0180 100644 --- a/src/agent/transport-session-runtime.ts +++ b/src/agent/transport-session-runtime.ts @@ -31,6 +31,7 @@ export class TransportSessionRuntime implements SessionRuntime { private _providerSessionId: string | null = null; private _sending = false; private _description: string | undefined; + private _systemPrompt: string | undefined; private _agentId: string | undefined; private _effort: TransportEffortLevel | undefined; private _unsubscribes: Array<() => void> = []; @@ -103,6 +104,7 @@ export class TransportSessionRuntime implements SessionRuntime { /** Set providerSessionId directly (restore from store without initialize). */ setProviderSessionId(id: string): void { this._providerSessionId = id; } setDescription(desc: string): void { this._description = desc; } + setSystemPrompt(prompt: string): void { this._systemPrompt = prompt; } setAgentId(agentId: string): void { this._agentId = agentId; if (this._providerSessionId) { @@ -126,6 +128,7 @@ export class TransportSessionRuntime implements SessionRuntime { async initialize(config: SessionConfig): Promise { this._providerSessionId = await this.provider.createSession(config); this._description = config.description; + this._systemPrompt = config.systemPrompt; this._agentId = config.agentId; this._effort = config.effort; } diff --git a/src/bind/bind-flow.ts b/src/bind/bind-flow.ts index 0e294a84..77734f56 100644 --- a/src/bind/bind-flow.ts +++ b/src/bind/bind-flow.ts @@ -200,6 +200,15 @@ async function ensureServiceInstalled(): Promise { } else if (process.platform === 'win32') { await installWindowsStartup(); console.log('\nDaemon installed as a startup shortcut — starts automatically on login.'); + // Immediately start the daemon (don't wait for next login). Use the + // single reusable ensureDaemonRunning() that handles all edge cases: + // orphan daemons holding the named pipe, crash-loop watchdogs, etc. + const { ensureDaemonRunning } = await import('../util/windows-daemon.js'); + if (ensureDaemonRunning(process.pid)) { + console.log('Daemon started.'); + } else { + console.log('Note: Daemon will start on next login.'); + } } else { console.log('\nRun "imcodes start" to start the daemon.'); } diff --git a/src/daemon/cc-presets.ts b/src/daemon/cc-presets.ts index 3ccf67cb..0e66ee63 100644 --- a/src/daemon/cc-presets.ts +++ b/src/daemon/cc-presets.ts @@ -71,6 +71,11 @@ export async function resolvePresetEnv(presetName: string, ccSessionId?: string) const preset = await getPreset(presetName); if (!preset) return {}; const env = { ...preset.env }; + // Backward compatibility: older saved presets used ANTHROPIC_AUTH_TOKEN, + // while current Claude CLI/SDK auth reads ANTHROPIC_API_KEY in bare env mode. + if (env['ANTHROPIC_AUTH_TOKEN'] && !env['ANTHROPIC_API_KEY']) { + env['ANTHROPIC_API_KEY'] = env['ANTHROPIC_AUTH_TOKEN']; + } // Auto-fill model aliases from ANTHROPIC_MODEL if (env['ANTHROPIC_MODEL']) { for (const alias of MODEL_ALIASES) { @@ -87,6 +92,87 @@ export async function resolvePresetEnv(presetName: string, ccSessionId?: string) return env; } +export async function getPresetTransportOverrides(presetName: string): Promise<{ + model?: string; + systemPrompt?: string; +}> { + const preset = await getPreset(presetName); + if (!preset) return {}; + const env = await resolvePresetEnv(presetName); + const configuredModel = env['ANTHROPIC_MODEL']?.trim() || undefined; + const configuredBaseUrl = env['ANTHROPIC_BASE_URL']?.trim() || undefined; + const runtimeFacts = [ + `Authoritative runtime fact: this session is using the Claude Code preset "${preset.name}".`, + configuredBaseUrl ? `Authoritative provider endpoint: ${configuredBaseUrl}.` : undefined, + configuredModel ? `Authoritative runtime model: ${configuredModel}.` : undefined, + configuredModel ? `If the user asks which model you are using, answer exactly with "${configuredModel}".` : 'If the user asks which model or provider you are using, answer with the authoritative runtime facts above.', + configuredBaseUrl ? `If the user asks which provider or endpoint you are using, mention "${configuredBaseUrl}".` : undefined, + 'These runtime facts override any generic Claude Code tool schema, enum, or default.', + 'Do not answer with Sonnet, Opus, Haiku, or any inferred Claude default unless that exact value matches the authoritative runtime model above.', + ].filter(Boolean).join(' '); + return { + ...(configuredModel ? { model: configuredModel } : {}), + ...(runtimeFacts ? { systemPrompt: runtimeFacts } : {}), + }; +} + +export async function getQwenPresetTransportConfig(presetName: string): Promise<{ + env: Record; + settings?: Record; + model?: string; +}> { + const preset = await getPreset(presetName); + if (!preset) return { env: {} }; + + const resolvedEnv = await resolvePresetEnv(presetName); + const model = resolvedEnv['ANTHROPIC_MODEL']?.trim() || undefined; + const baseUrl = resolvedEnv['ANTHROPIC_BASE_URL']?.trim() || undefined; + const apiKey = resolvedEnv['ANTHROPIC_API_KEY']?.trim() + || resolvedEnv['ANTHROPIC_AUTH_TOKEN']?.trim() + || undefined; + + const env: Record = {}; + if (baseUrl) env['ANTHROPIC_BASE_URL'] = baseUrl; + if (apiKey) env['ANTHROPIC_API_KEY'] = apiKey; + if (model) env['ANTHROPIC_MODEL'] = model; + + const settings: Record | undefined = (baseUrl && apiKey && model) + ? { + security: { + auth: { + selectedType: 'anthropic', + }, + }, + model: { + name: model, + }, + modelProviders: { + anthropic: [ + { + id: model, + name: preset.name, + envKey: 'ANTHROPIC_API_KEY', + baseUrl, + ...(preset.contextWindow + ? { + generationConfig: { + contextWindowSize: preset.contextWindow, + }, + } + : {}), + }, + ], + }, + } + : undefined; + + return { + env, + ...(settings ? { settings } : {}), + ...(model ? { model } : {}), + }; +} + /** Default init message for non-Anthropic providers (no native web search). */ const DEFAULT_INIT_MESSAGE = 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index f5aa1391..5572c220 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -25,11 +25,13 @@ import logger from '../util/logger.js'; import { homedir } from 'os'; import { readdir as fsReaddir, realpath as fsRealpath, readFile as fsReadFileRaw, stat as fsStat, writeFile as fsWriteFile } from 'node:fs/promises'; import * as nodePath from 'node:path'; -import { exec as execCb } from 'node:child_process'; +import { exec as execCb, execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; const execAsync = promisify(execCb); +const execFileAsync = promisify(execFileCb); import { startP2pRun, cancelP2pRun, getP2pRun, listP2pRuns, serializeP2pRun, type P2pTarget } from './p2p-orchestrator.js'; import { getComboRoundCount, parseModePipeline, P2P_CONFIG_MODE, type P2pSessionConfig } from '../../shared/p2p-modes.js'; +import type { P2pAdvancedRound, P2pContextReducerConfig } from '../../shared/p2p-advanced.js'; import { CRON_MSG } from '../../shared/cron-types.js'; import { executeCronJob } from './cron-executor.js'; import { TRANSPORT_MSG } from '../../shared/transport-events.js'; @@ -1042,6 +1044,10 @@ async function handleSend(cmd: Record, serverLink: ServerLink): let p2pExtraPrompt = (cmd as any).p2pExtraPrompt as string | undefined; const p2pLocale = (cmd as any).p2pLocale as string | undefined; const p2pHopTimeoutMs = (cmd as any).p2pHopTimeoutMs as number | undefined; + const p2pAdvancedPresetKey = (cmd as any).p2pAdvancedPresetKey as string | undefined; + const p2pAdvancedRounds = (cmd as any).p2pAdvancedRounds as P2pAdvancedRound[] | undefined; + const p2pAdvancedRunTimeoutMinutes = (cmd as any).p2pAdvancedRunTimeoutMinutes as number | undefined; + const p2pContextReducer = (cmd as any).p2pContextReducer as P2pContextReducerConfig | undefined; const p2pModeField = (cmd as any).p2pMode as string | undefined; const p2pAtTargets = (cmd as any).p2pAtTargets as Array<{ session: string; mode: string }> | undefined; const explicitTargets = directTargetSession @@ -1181,7 +1187,21 @@ async function handleSend(cmd: Record, serverLink: ServerLink): const langInstr = `Use the user's selected i18n language (${langName}) for the discussion.`; p2pExtraPrompt = p2pExtraPrompt ? `${p2pExtraPrompt}\n${langInstr}` : langInstr; } - const run = await startP2pRun(sessionName, tokens.agents, tokens.cleanText, fileContents, serverLink, p2pRounds, p2pExtraPrompt, resolvedMode || undefined, p2pHopTimeoutMs); + const run = await startP2pRun({ + initiatorSession: sessionName, + targets: tokens.agents, + userText: tokens.cleanText, + fileContents, + serverLink, + rounds: p2pRounds, + extraPrompt: p2pExtraPrompt, + modeOverride: resolvedMode || undefined, + hopTimeoutMs: p2pHopTimeoutMs, + advancedPresetKey: p2pAdvancedPresetKey, + advancedRounds: p2pAdvancedRounds, + advancedRunTimeoutMs: p2pAdvancedRunTimeoutMinutes != null ? p2pAdvancedRunTimeoutMinutes * 60_000 : undefined, + contextReducer: p2pContextReducer, + }); const status = isLegacy ? 'accepted_legacy' : 'accepted'; timelineEmitter.emit(sessionName, 'command.ack', { commandId: effectiveId, status }); try { @@ -1838,6 +1858,7 @@ async function handleSubSessionStart(cmd: Record, serverLink: S const shellBin = cmd.shellBin as string | null | undefined; const ccSessionId = cmd.ccSessionId as string | null | undefined; const parentSession = cmd.parentSession as string | null | undefined; + const ccPreset = cmd.ccPreset as string | null | undefined; const requestedEffort: unknown = cmd.thinking ?? cmd.effort; const effort = isTransportEffortLevel(requestedEffort) ? requestedEffort @@ -1866,6 +1887,7 @@ async function handleSubSessionStart(cmd: Record, serverLink: S requestedModel: (cmd.requestedModel as string | undefined) ?? (cmd.model as string | undefined), transportConfig: (cmd.transportConfig as Record | undefined) ?? undefined, bindExistingKey, + ...(ccPreset ? { ccPreset } : {}), ...(type === 'claude-code-sdk' ? { ccSessionId: randomUUID(), fresh: true } : {}), ...(type === 'codex-sdk' ? { fresh: true } : {}), ...(effort ? { effort } : {}), @@ -1881,8 +1903,6 @@ async function handleSubSessionStart(cmd: Record, serverLink: S } return; } - - const ccPreset = cmd.ccPreset as string | null | undefined; const subCcInitPrompt = cmd.ccInitPrompt as string | null | undefined; const description = cmd.description as string | null | undefined; @@ -2053,7 +2073,15 @@ async function handleP2pListDiscussions(_cmd: Record, serverLin const dir = imcSubDir(projectDir, 'discussions'); try { const entries = await fsReaddir(dir); - const files = entries.filter(e => e.endsWith('.md')); + const files = entries.filter((entry) => { + if (!entry.endsWith('.md')) return false; + // Keep only canonical discussion documents in the history list. + // Intermediate hop artifacts and reducer snapshots are implementation + // details and should not crowd out the main discussion file. + if (/\.round\d+\.hop\d+\.md$/i.test(entry)) return false; + if (/\.reducer\.\d+\.md$/i.test(entry)) return false; + return true; + }); for (const f of files) { try { const fullPath = nodePath.join(dir, f); @@ -2602,6 +2630,89 @@ async function handleFsListInner(resolved: string, rawPath: string, requestId: s const FS_READ_SIZE_LIMIT = 512 * 1024; // 512 KB +interface FsReadSnapshot { + path: string; + fileSignature: string; + status: 'ok' | 'error'; + content?: string; + encoding?: 'base64'; + mimeType?: string; + error?: string; + previewReason?: 'too_large' | 'binary' | 'unknown_type'; +} + +const fsReadCache = new Map(); +const fsReadInflight = new Map>(); +const fsReadGenerations = new Map(); +const FS_READ_CACHE_TTL_MS = 5_000; +const REPO_CONTEXT_CACHE_TTL_MS = 5_000; + +async function loadFsReadSnapshot(realPath: string, fileSignature: string): Promise { + const ext = nodePath.extname(realPath).toLowerCase().slice(1); + const IMAGE_MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml' }; + const OFFICE_MIME: Record = { + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }; + const mimeType = IMAGE_MIME[ext] ?? OFFICE_MIME[ext]; + + if (mimeType) { + const buf = await fsReadFileRaw(realPath); + return { + path: realPath, + fileSignature, + status: 'ok', + content: buf.toString('base64'), + encoding: 'base64', + mimeType, + }; + } + + const content = await fsReadFileRaw(realPath, 'utf-8'); + const sample = content.slice(0, 8192); + if (sample.includes('\0')) { + return { + path: realPath, + fileSignature, + status: 'error', + error: 'binary_file', + previewReason: 'binary', + }; + } + + return { + path: realPath, + fileSignature, + status: 'ok', + content, + }; +} + +async function getFsReadSnapshot(realPath: string, fileSignature: string): Promise { + const cached = fsReadCache.get(realPath); + if (cached && cached.expiresAt > Date.now() && cached.value.fileSignature === fileSignature) { + return cached.value; + } + const generation = getResourceGeneration(fsReadGenerations, realPath); + const inflightKey = `${realPath}::${fileSignature}::${generation}`; + const inflight = fsReadInflight.get(inflightKey); + if (inflight) return await inflight; + const promise = loadFsReadSnapshot(realPath, fileSignature) + .then(async (value) => { + const currentSignature = await safeStatSignature(realPath); + if (getResourceGeneration(fsReadGenerations, realPath) === generation && currentSignature === value.fileSignature) { + fsReadCache.set(realPath, { value, expiresAt: Date.now() + FS_READ_CACHE_TTL_MS }); + } + return value; + }) + .finally(() => { + fsReadInflight.delete(inflightKey); + }); + fsReadInflight.set(inflightKey, promise); + return await promise; +} + async function handleFsRead(cmd: Record, serverLink: ServerLink): Promise { const rawPath = cmd.path as string | undefined; const requestId = cmd.requestId as string | undefined; @@ -2619,6 +2730,7 @@ async function handleFsRead(cmd: Record, serverLink: ServerLink } const stats = await fsStat(real); + const fileSignature = `${stats.mtimeMs}:${stats.size}`; // Image files: send as base64 with a higher size limit (5 MB) const ext = nodePath.extname(real).toLowerCase().slice(1); @@ -2642,29 +2754,477 @@ async function handleFsRead(cmd: Record, serverLink: ServerLink } const mtime = stats.mtimeMs; - if (mimeType) { - const buf = await fsReadFileRaw(real); - const content = buf.toString('base64'); - try { serverLink.send({ type: 'fs.read_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', content, encoding: 'base64', mimeType, downloadId: handle.id, mtime }); } catch { /* ignore */ } - } else { - const content = await fsReadFileRaw(real, 'utf-8'); - // Check for binary content - const sample = content.slice(0, 8192); - if (sample.includes('\0')) { - try { serverLink.send({ type: 'fs.read_response', requestId, path: rawPath, resolvedPath: real, status: 'error', error: 'binary_file', previewReason: 'binary', downloadId: handle.id }); } catch { /* ignore */ } - return; - } - try { serverLink.send({ type: 'fs.read_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', content, downloadId: handle.id, mtime }); } catch { /* ignore */ } + const snapshot = await getFsReadSnapshot(real, fileSignature); + if (snapshot.status === 'error') { + try { serverLink.send({ type: 'fs.read_response', requestId, path: rawPath, resolvedPath: real, status: 'error', error: snapshot.error, previewReason: snapshot.previewReason, downloadId: handle.id }); } catch { /* ignore */ } + return; } + try { + serverLink.send({ + type: 'fs.read_response', + requestId, + path: rawPath, + resolvedPath: real, + status: 'ok', + content: snapshot.content, + ...(snapshot.encoding ? { encoding: snapshot.encoding } : {}), + ...(snapshot.mimeType ? { mimeType: snapshot.mimeType } : {}), + downloadId: handle.id, + mtime, + }); + } catch { /* ignore */ } } catch (err) { try { serverLink.send({ type: 'fs.read_response', requestId, path: rawPath, status: 'error', error: err instanceof Error ? err.message : String(err) }); } catch { /* ignore */ } } } +const GIT_STATUS_CACHE_TTL_MS = 5_000; +const GIT_DIFF_CACHE_TTL_MS = 5_000; + +type GitStatusFile = { path: string; code: string; additions?: number; deletions?: number }; + +interface RepoContext { + repoRoot: string; + gitDir: string; + repoSignature: string; +} + +interface RepoContextBase { + repoRoot: string; + gitDir: string; +} + +interface RepoSignatureState { + repoSignature: string; + indexSig: string; + headSig: string; + refPath: string | null; + refSig: string; +} + +interface GitStatusSnapshot { + repoRoot: string; + repoSignature: string; + files: GitStatusFile[]; +} + +interface GitNumstatSnapshot { + repoRoot: string; + repoSignature: string; + stats: Map; +} + +interface GitDiffSnapshot { + logicalPath: string; + repoRoot: string; + repoSignature: string; + fileSignature: string; + diff: string; +} + +const repoContextCache = new Map(); +const repoSignatureCache = new Map(); +const gitStatusCache = new Map(); +const gitStatusInflight = new Map>(); +const gitNumstatCache = new Map(); +const gitNumstatInflight = new Map>(); +const gitDiffCache = new Map(); +const gitDiffInflight = new Map>(); +const gitRepoGenerations = new Map(); +const gitDiffGenerations = new Map(); + +function normalizeFsPath(value: string): string { + return nodePath.resolve(value); +} + +function getResourceGeneration(map: Map, key: string): number { + return map.get(key) ?? 0; +} + +function bumpResourceGeneration(map: Map, key: string): void { + map.set(key, getResourceGeneration(map, key) + 1); +} + +function isPathInside(root: string, candidate: string): boolean { + const normalizedRoot = normalizeFsPath(root); + const normalizedCandidate = normalizeFsPath(candidate); + return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + nodePath.sep); +} + +async function safeStatSignature(targetPath: string): Promise { + try { + const stats = await fsStat(targetPath); + return `${stats.mtimeMs}:${stats.size}`; + } catch { + return 'missing'; + } +} + +async function resolveGitDir(dotGitPath: string, repoRoot: string): Promise { + try { + const stats = await fsStat(dotGitPath); + if (stats.isDirectory()) return dotGitPath; + if (!stats.isFile()) return null; + const raw = await fsReadFileRaw(dotGitPath, 'utf8'); + const match = raw.match(/^gitdir:\s*(.+)\s*$/mi); + if (!match?.[1]) return null; + return nodePath.resolve(repoRoot, match[1].trim()); + } catch { + return null; + } +} + +async function findRepoContextBase(startPath: string): Promise { + let current = normalizeFsPath(startPath); + const traversed: string[] = []; + const now = Date.now(); + while (true) { + const cached = repoContextCache.get(current); + if (cached && cached.expiresAt > now) { + for (const traversedPath of traversed) { + repoContextCache.set(traversedPath, { expiresAt: now + REPO_CONTEXT_CACHE_TTL_MS, value: cached.value }); + } + return cached.value; + } + traversed.push(current); + const dotGit = nodePath.join(current, '.git'); + const gitDir = await resolveGitDir(dotGit, current); + if (gitDir) { + const value = { repoRoot: current, gitDir }; + for (const traversedPath of traversed) { + repoContextCache.set(traversedPath, { expiresAt: Date.now() + REPO_CONTEXT_CACHE_TTL_MS, value }); + } + return value; + } + const parent = nodePath.dirname(current); + if (parent === current) break; + current = parent; + } + for (const traversedPath of traversed) { + repoContextCache.set(traversedPath, { expiresAt: Date.now() + REPO_CONTEXT_CACHE_TTL_MS, value: null }); + } + return null; +} + +async function buildRepoSignatureState(gitDir: string, indexSig?: string, headSig?: string): Promise { + const resolvedIndexSig = indexSig ?? await safeStatSignature(nodePath.join(gitDir, 'index')); + const headPath = nodePath.join(gitDir, 'HEAD'); + const resolvedHeadSig = headSig ?? await safeStatSignature(headPath); + let refPath: string | null = null; + let refSig = 'none'; + try { + const headRaw = await fsReadFileRaw(headPath, 'utf8'); + const match = headRaw.match(/^ref:\s*(.+)\s*$/m); + if (match?.[1]) { + refPath = match[1].trim(); + refSig = await safeStatSignature(nodePath.join(gitDir, refPath)); + } + } catch { + refSig = 'missing'; + } + return { + repoSignature: `${resolvedIndexSig}|${resolvedHeadSig}|${refSig}`, + indexSig: resolvedIndexSig, + headSig: resolvedHeadSig, + refPath, + refSig, + }; +} + +async function getRepoSignature(repoRoot: string, gitDir: string): Promise { + const indexSig = await safeStatSignature(nodePath.join(gitDir, 'index')); + const headPath = nodePath.join(gitDir, 'HEAD'); + const headSig = await safeStatSignature(headPath); + const cached = repoSignatureCache.get(repoRoot); + if (cached && cached.indexSig === indexSig && cached.headSig === headSig) { + if (!cached.refPath || await safeStatSignature(nodePath.join(gitDir, cached.refPath)) === cached.refSig) { + return cached.repoSignature; + } + } + const next = await buildRepoSignatureState(gitDir, indexSig, headSig); + repoSignatureCache.set(repoRoot, next); + return next.repoSignature; +} + +async function resolveRepoContext(startPath: string): Promise { + const repo = await findRepoContextBase(startPath); + if (!repo) return null; + return { + repoRoot: repo.repoRoot, + gitDir: repo.gitDir, + repoSignature: await getRepoSignature(repo.repoRoot, repo.gitDir), + }; +} + +function decodeGitPath(rawPath: string): string { + return rawPath.replace(/\\([\\\"abfnrtv])/g, (_match, escaped: string) => { + switch (escaped) { + case 'a': return '\u0007'; + case 'b': return '\b'; + case 'f': return '\f'; + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'v': return '\v'; + case '\\': return '\\'; + case '"': return '"'; + default: return escaped; + } + }).replace(/\\([0-7]{1,3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8))); +} + +function parseZRecords(stdout: string): string[] { + return stdout.split('\0').filter((entry) => entry.length > 0); +} + +function normalizeRepoRelativePath(repoRoot: string, relativePath: string): string { + return nodePath.join(repoRoot, decodeGitPath(relativePath)); +} + +async function loadRepoGitStatusSnapshot(repoRoot: string, repoSignature: string): Promise { + const { stdout } = await execAsync('git status --porcelain=v1 -z -u', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); + const files: GitStatusFile[] = []; + const records = parseZRecords(stdout); + for (let idx = 0; idx < records.length; idx++) { + const record = records[idx]; + const code = record.slice(0, 2).trim(); + const firstPath = record.slice(3); + let logicalPath = firstPath; + if (code.startsWith('R') || code.startsWith('C')) { + const renamedTo = records[idx + 1]; + if (renamedTo) { + logicalPath = renamedTo; + idx += 1; + } + } + files.push({ path: normalizeRepoRelativePath(repoRoot, logicalPath), code }); + } + return { repoRoot, repoSignature, files }; +} + +async function getRepoGitStatusSnapshot(startPath: string): Promise { + const context = await resolveRepoContext(startPath); + if (!context) return null; + const cached = gitStatusCache.get(context.repoRoot); + if (cached && cached.expiresAt > Date.now() && cached.value.repoSignature === context.repoSignature) { + return cached.value; + } + const generation = getResourceGeneration(gitRepoGenerations, context.repoRoot); + const inflightKey = `${context.repoRoot}::${context.repoSignature}::${generation}`; + const inflight = gitStatusInflight.get(inflightKey); + if (inflight) return await inflight; + const promise = loadRepoGitStatusSnapshot(context.repoRoot, context.repoSignature) + .then(async (value) => { + const currentSignature = await getRepoSignature(context.repoRoot, context.gitDir); + if (getResourceGeneration(gitRepoGenerations, context.repoRoot) === generation && currentSignature === value.repoSignature) { + gitStatusCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS }); + } + return value; + }) + .finally(() => { + gitStatusInflight.delete(inflightKey); + }); + gitStatusInflight.set(inflightKey, promise); + return await promise; +} + +async function loadRepoGitNumstatSnapshot(repoRoot: string, repoSignature: string): Promise { + let stdout = ''; + try { + ({ stdout } = await execAsync('git diff --numstat -z HEAD', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })); + } catch { + try { + ({ stdout } = await execAsync('git diff --numstat -z', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })); + } catch { + stdout = ''; + } + } + const stats = new Map(); + const records = parseZRecords(stdout); + for (let idx = 0; idx < records.length; idx++) { + const header = records[idx]; + const firstTab = header.indexOf('\t'); + const secondTab = firstTab >= 0 ? header.indexOf('\t', firstTab + 1) : -1; + if (firstTab < 0 || secondTab < 0) continue; + const additionsRaw = header.slice(0, firstTab); + const deletionsRaw = header.slice(firstTab + 1, secondTab); + const pathRaw = header.slice(secondTab + 1); + const additions = additionsRaw === '-' ? undefined : parseInt(additionsRaw, 10); + const deletions = deletionsRaw === '-' ? undefined : parseInt(deletionsRaw, 10); + let logicalPath = pathRaw; + if (pathRaw === '') { + const renamedTo = records[idx + 2]; + if (!renamedTo) continue; + logicalPath = renamedTo; + idx += 2; + } + stats.set(normalizeRepoRelativePath(repoRoot, logicalPath), { additions, deletions }); + } + return { repoRoot, repoSignature, stats }; +} + +async function getRepoGitNumstatSnapshot(startPath: string): Promise { + const context = await resolveRepoContext(startPath); + if (!context) return null; + const cached = gitNumstatCache.get(context.repoRoot); + if (cached && cached.expiresAt > Date.now() && cached.value.repoSignature === context.repoSignature) { + return cached.value; + } + const generation = getResourceGeneration(gitRepoGenerations, context.repoRoot); + const inflightKey = `${context.repoRoot}::${context.repoSignature}::${generation}`; + const inflight = gitNumstatInflight.get(inflightKey); + if (inflight) return await inflight; + const promise = loadRepoGitNumstatSnapshot(context.repoRoot, context.repoSignature) + .then(async (value) => { + const currentSignature = await getRepoSignature(context.repoRoot, context.gitDir); + if (getResourceGeneration(gitRepoGenerations, context.repoRoot) === generation && currentSignature === value.repoSignature) { + gitNumstatCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS }); + } + return value; + }) + .finally(() => { + gitNumstatInflight.delete(inflightKey); + }); + gitNumstatInflight.set(inflightKey, promise); + return await promise; +} + +async function loadFileGitDiffSnapshot(logicalPath: string, repoRoot: string, repoSignature: string, fileSignature: string): Promise { + let diff = ''; + const repoRelativePath = nodePath.relative(repoRoot, logicalPath).split(nodePath.sep).join('/'); + try { + const { stdout } = await execFileAsync('git', ['diff', 'HEAD', '--', repoRelativePath], { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); + diff = stdout; + } catch { /* ignore */ } + if (!diff) { + try { + const { stdout } = await execFileAsync('git', ['diff', '--', repoRelativePath], { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); + diff = stdout; + } catch { /* ignore */ } + } + return { logicalPath, repoRoot, repoSignature, fileSignature, diff }; +} + +async function getFileGitDiffSnapshot(logicalPath: string): Promise { + const context = await resolveRepoContext(nodePath.dirname(logicalPath)); + if (!context) return null; + const fileSignature = await safeStatSignature(logicalPath); + const cached = gitDiffCache.get(logicalPath); + if ( + cached + && cached.expiresAt > Date.now() + && cached.value.repoSignature === context.repoSignature + && cached.value.fileSignature === fileSignature + ) { + return cached.value; + } + const generation = getResourceGeneration(gitDiffGenerations, logicalPath); + const inflightKey = `${logicalPath}::${context.repoSignature}::${fileSignature}::${generation}`; + const inflight = gitDiffInflight.get(inflightKey); + if (inflight) return await inflight; + const promise = loadFileGitDiffSnapshot(logicalPath, context.repoRoot, context.repoSignature, fileSignature) + .then(async (value) => { + const currentContext = await resolveRepoContext(nodePath.dirname(logicalPath)); + const currentFileSignature = await safeStatSignature(logicalPath); + if ( + getResourceGeneration(gitDiffGenerations, logicalPath) === generation + && currentContext + && currentContext.repoSignature === value.repoSignature + && currentFileSignature === value.fileSignature + ) { + gitDiffCache.set(logicalPath, { value, expiresAt: Date.now() + GIT_DIFF_CACHE_TTL_MS }); + } + return value; + }) + .finally(() => { + gitDiffInflight.delete(inflightKey); + }); + gitDiffInflight.set(inflightKey, promise); + return await promise; +} + +function collectAffectedRepoRoots(targetPath: string): Set { + const affected = new Set(); + for (const key of gitStatusCache.keys()) { + if (isPathInside(key, targetPath)) affected.add(key); + } + for (const key of gitNumstatCache.keys()) { + if (isPathInside(key, targetPath)) affected.add(key); + } + for (const key of gitStatusInflight.keys()) { + const repoRoot = key.split('::')[0] ?? ''; + if (repoRoot && isPathInside(repoRoot, targetPath)) affected.add(repoRoot); + } + for (const key of gitNumstatInflight.keys()) { + const repoRoot = key.split('::')[0] ?? ''; + if (repoRoot && isPathInside(repoRoot, targetPath)) affected.add(repoRoot); + } + for (const entry of repoContextCache.values()) { + const repoRoot = entry.value?.repoRoot; + if (repoRoot && isPathInside(repoRoot, targetPath)) affected.add(repoRoot); + } + return affected; +} + +function invalidateGitCachesForPath(targetPath: string): void { + const normalized = normalizeFsPath(targetPath); + bumpResourceGeneration(fsReadGenerations, normalized); + bumpResourceGeneration(gitDiffGenerations, normalized); + for (const repoRoot of collectAffectedRepoRoots(normalized)) { + bumpResourceGeneration(gitRepoGenerations, repoRoot); + } + fsReadCache.delete(normalized); + gitDiffCache.delete(normalized); + for (const key of fsReadInflight.keys()) { + if (key.startsWith(`${normalized}::`)) fsReadInflight.delete(key); + } + for (const key of gitDiffInflight.keys()) { + if (key.startsWith(`${normalized}::`)) gitDiffInflight.delete(key); + } + for (const key of gitStatusCache.keys()) { + if (isPathInside(key, normalized)) gitStatusCache.delete(key); + if (isPathInside(key, normalized)) repoSignatureCache.delete(key); + } + for (const key of repoContextCache.keys()) { + if (isPathInside(key, normalized)) repoContextCache.delete(key); + } + for (const key of gitNumstatCache.keys()) { + if (isPathInside(key, normalized)) gitNumstatCache.delete(key); + if (isPathInside(key, normalized)) repoSignatureCache.delete(key); + } + for (const key of gitStatusInflight.keys()) { + if (isPathInside(key.split('::')[0] ?? '', normalized)) gitStatusInflight.delete(key); + } + for (const key of gitNumstatInflight.keys()) { + if (isPathInside(key.split('::')[0] ?? '', normalized)) gitNumstatInflight.delete(key); + } +} + +export function __resetFsGitCachesForTests(): void { + fsReadCache.clear(); + fsReadInflight.clear(); + fsReadGenerations.clear(); + repoContextCache.clear(); + repoSignatureCache.clear(); + gitStatusCache.clear(); + gitStatusInflight.clear(); + gitNumstatCache.clear(); + gitNumstatInflight.clear(); + gitDiffCache.clear(); + gitDiffInflight.clear(); + gitRepoGenerations.clear(); + gitDiffGenerations.clear(); +} + +function filterRepoFilesForPath(files: GitStatusFile[], requestedPath: string): GitStatusFile[] { + return files.filter((file) => isPathInside(requestedPath, file.path)); +} + /** fs.git_status — return git modified file list for a directory */ async function handleFsGitStatus(cmd: Record, serverLink: ServerLink): Promise { const rawPath = cmd.path as string | undefined; const requestId = cmd.requestId as string | undefined; + const includeStats = cmd.includeStats === true; if (!rawPath || !requestId) return; const expanded = rawPath.startsWith('~') ? rawPath.replace(/^~/, homedir()) : rawPath; @@ -2677,32 +3237,14 @@ async function handleFsGitStatus(cmd: Record, serverLink: Serve try { serverLink.send({ type: 'fs.git_status_response', requestId, path: rawPath, status: 'error', error: 'forbidden_path' }); } catch { /* ignore */ } return; } - - const { stdout } = await execAsync('git status --porcelain -u', { cwd: real, timeout: 5000 }); - const files: Array<{ path: string; code: string; additions?: number; deletions?: number }> = []; - for (const line of stdout.split('\n')) { - if (!line.trim()) continue; - const code = line.slice(0, 2).trim(); - const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1'); // unquote if needed - files.push({ path: nodePath.join(real, filePath), code }); - } - // Enrich with +/- line stats from git diff --numstat (best-effort) - try { - const { stdout: numstat } = await execAsync('git diff --numstat HEAD 2>/dev/null || git diff --numstat', { cwd: real, timeout: 5000 }); - const statsMap = new Map(); - for (const line of numstat.split('\n')) { - const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); - if (m) { - const add = m[1] === '-' ? 0 : parseInt(m[1], 10); - const del = m[2] === '-' ? 0 : parseInt(m[2], 10); - statsMap.set(nodePath.join(real, m[3].trim()), { add, del }); - } - } - for (const f of files) { - const s = statsMap.get(f.path); - if (s) { f.additions = s.add; f.deletions = s.del; } - } - } catch { /* ignore — stats are best-effort */ } + const [snapshot, numstat] = await Promise.all([ + getRepoGitStatusSnapshot(real), + includeStats ? getRepoGitNumstatSnapshot(real) : Promise.resolve(null), + ]); + const files = snapshot ? filterRepoFilesForPath(snapshot.files, real).map((file) => { + const stats = numstat?.stats.get(file.path); + return stats ? { ...file, ...stats } : file; + }) : []; try { serverLink.send({ type: 'fs.git_status_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', files }); } catch { /* ignore */ } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -2722,28 +3264,26 @@ async function handleFsGitDiff(cmd: Record, serverLink: ServerL const resolved = nodePath.resolve(expanded); try { - const real = await fsRealpath(resolved); - const allowed = isPathAllowed(real); + let allowedProbe = resolved; + while (true) { + try { + allowedProbe = await fsRealpath(allowedProbe); + break; + } catch { + const parent = nodePath.dirname(allowedProbe); + if (parent === allowedProbe) throw new Error(`ENOENT: no such file or directory, realpath '${resolved}'`); + allowedProbe = parent; + } + } + const allowed = isPathAllowed(allowedProbe); if (!allowed) { try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, status: 'error', error: 'forbidden_path' }); } catch { /* ignore */ } return; } - - const dir = nodePath.dirname(real); - // Try staged+unstaged diff vs HEAD; fall back to index diff; then untracked diff - let diff = ''; - try { - const { stdout } = await execAsync(`git diff HEAD -- ${JSON.stringify(real)}`, { cwd: dir, timeout: 5000 }); - diff = stdout; - } catch { /* ignore */ } - if (!diff) { - try { - const { stdout } = await execAsync(`git diff -- ${JSON.stringify(real)}`, { cwd: dir, timeout: 5000 }); - diff = stdout; - } catch { /* ignore */ } - } + const snapshot = await getFileGitDiffSnapshot(resolved); + const diff = snapshot?.diff ?? ''; // Untracked files: no diff (nothing meaningful to compare against) - try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', diff }); } catch { /* ignore */ } + try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, resolvedPath: resolved, status: 'ok', diff }); } catch { /* ignore */ } } catch (err) { try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, status: 'error', error: err instanceof Error ? err.message : String(err) }); } catch { /* ignore */ } } @@ -2844,6 +3384,7 @@ async function handleFsWrite(cmd: Record, serverLink: ServerLin // Write the file await fsWriteFile(real, content, 'utf-8'); const newStats = await fsStat(real); + invalidateGitCachesForPath(real); try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ } } catch (err) { try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, status: 'error', error: err instanceof Error ? err.message : String(err) }); } catch { /* ignore */ } @@ -2862,6 +3403,7 @@ async function handleFsWrite(cmd: Record, serverLink: ServerLin await fsWriteFile(resolved, content, 'utf-8'); const newStats = await fsStat(resolved); const real = await fsRealpath(resolved); + invalidateGitCachesForPath(real); try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ } } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/daemon/cron-executor.ts b/src/daemon/cron-executor.ts index 887771dd..ae305634 100644 --- a/src/daemon/cron-executor.ts +++ b/src/daemon/cron-executor.ts @@ -162,7 +162,14 @@ export async function executeCronJob(msg: CronDispatchMessage, serverLink: Serve } logger.info({ jobId, jobName, initiator: name, targets: targets.length, mode }, 'Cron: starting P2P discussion'); - const run = await startP2pRun(name, targets, topic, [], serverLink, rounds ?? 1); + const run = await startP2pRun({ + initiatorSession: name, + targets, + userText: topic, + fileContents: [], + serverLink, + rounds: rounds ?? 1, + }); // Link cron execution to P2P discussion so frontend can navigate try { serverLink.send({ type: 'cron.p2p_linked', jobId, discussionId: run.discussionId, runId: run.id }); diff --git a/src/daemon/p2p-orchestrator.ts b/src/daemon/p2p-orchestrator.ts index 4c335648..c4663a8d 100644 --- a/src/daemon/p2p-orchestrator.ts +++ b/src/daemon/p2p-orchestrator.ts @@ -13,8 +13,17 @@ import { randomUUID } from 'node:crypto'; import { sendKeysDelayedEnter } from '../agent/tmux.js'; import { detectStatusAsync } from '../agent/detect.js'; import { getSession } from '../store/session-store.js'; -import { getTransportRuntime } from '../agent/session-manager.js'; +import { getTransportRuntime, launchTransportSession, stopTransportRuntimeSession } from '../agent/session-manager.js'; import { P2P_BASELINE_PROMPT, getP2pMode, getModeForRound, isComboMode, parseModePipeline, roundPrompt, type P2pMode } from '../../shared/p2p-modes.js'; +import { + resolveP2pRoundPlan, + type P2pAdvancedRound, + type P2pContextReducerConfig, + type P2pHelperDiagnostic, + type P2pParticipantSnapshotEntry, + type P2pResolvedPlan, + type P2pResolvedRound, +} from '../../shared/p2p-advanced.js'; import { formatP2pParticipantIdentity, shortP2pSessionName } from '../../shared/p2p-participant.js'; import { P2P_TERMINAL_HOP_STATUSES, @@ -41,6 +50,22 @@ export interface P2pTarget { mode: string; // mode key e.g. 'audit' } +export interface StartP2pRunOptions { + initiatorSession: string; + targets: P2pTarget[]; + userText: string; + fileContents: Array<{ path: string; content: string }>; + serverLink: ServerLink | null; + rounds?: number; + extraPrompt?: string; + modeOverride?: string; + hopTimeoutMs?: number; + advancedPresetKey?: string; + advancedRounds?: P2pAdvancedRound[]; + advancedRunTimeoutMs?: number; + contextReducer?: P2pContextReducerConfig; +} + interface P2pHopRuntime extends P2pHopProgress { section_header: string; artifact_path: string; @@ -56,6 +81,7 @@ export interface P2pRun { initiatorSession: string; currentTargetSession: string | null; finalReturnSession: string; + /** Compatibility-only projection for legacy consumers; advanced runs drain this per execution round. */ remainingTargets: P2pTarget[]; /** Total number of hop targets (excluding initiator phases). Fixed at creation time. */ totalTargets: number; @@ -69,9 +95,9 @@ export interface P2pRun { userText: string; timeoutMs: number; resultSummary: string | null; - /** Sessions whose hops completed successfully. */ + /** Compatibility-only projection for legacy consumers; advanced loop retries may repeat sessions here. */ completedHops: P2pTarget[]; - /** Sessions whose hops were skipped (timeout, sendKeys failure, idle without file change). */ + /** Compatibility-only projection for legacy consumers; advanced loop retries may repeat sessions here. */ skippedHops: string[]; error: string | null; createdAt: string; @@ -90,6 +116,26 @@ export interface P2pRun { /** Parallel hop runtime state across all rounds. */ hopStates: P2pHopRuntime[]; activeTargetSessions: string[]; + advancedP2pEnabled: boolean; + resolvedRounds?: P2pResolvedRound[]; + helperEligibleSnapshot: P2pParticipantSnapshotEntry[]; + contextReducer?: P2pContextReducerConfig; + advancedRunTimeoutMs?: number; + deadlineAt?: number | null; + currentRoundId?: string | null; + currentExecutionStep: number; + currentRoundAttempt: number; + roundAttemptCounts: Record; + roundJumpCounts: Record; + routingHistory: Array<{ + fromRoundId?: string | null; + toRoundId?: string | null; + trigger?: string | null; + atStep: number; + atAttempt?: number | null; + timestamp: number; + }>; + helperDiagnostics: P2pHelperDiagnostic[]; /** Internal: set to true when cancel requested */ _cancelled: boolean; } @@ -113,6 +159,13 @@ export function serializeP2pRun(run: P2pRun): P2pRunUpdatePayload { const currentHopState = activeHopStates[0] ?? null; const currentHop = currentHopState?.session ?? run.activeTargetSessions[0] ?? run.currentTargetSession; const hopCounts = countHopStates(run.hopStates); + const routingHistory = Array.isArray(run.routingHistory) ? run.routingHistory : []; + const latestStepByRoundId = routingHistory.reduce>((acc, entry) => { + if (typeof entry.toRoundId === 'string' && typeof entry.atStep === 'number') { + acc[entry.toRoundId] = entry.atStep; + } + return acc; + }, {}); return { id: run.id, @@ -187,6 +240,30 @@ export function serializeP2pRun(run: P2pRun): P2pRunUpdatePayload { terminal_reason: run.status === 'completed' || run.status === 'timed_out' || run.status === 'failed' || run.status === 'cancelled' ? run.status : null, + advanced_p2p_enabled: run.advancedP2pEnabled || undefined, + current_round_id: run.currentRoundId ?? null, + current_execution_step: run.currentExecutionStep || null, + current_round_attempt: run.currentRoundAttempt || null, + round_attempt_counts: run.advancedP2pEnabled ? { ...run.roundAttemptCounts } : undefined, + round_jump_counts: run.advancedP2pEnabled ? { ...run.roundJumpCounts } : undefined, + routing_history: run.advancedP2pEnabled ? [...routingHistory] : undefined, + helper_diagnostics: run.advancedP2pEnabled && run.helperDiagnostics.length > 0 ? [...run.helperDiagnostics] : undefined, + advanced_nodes: run.advancedP2pEnabled && run.resolvedRounds + ? run.resolvedRounds.map((round) => ({ + id: round.id, + title: round.title, + preset: round.preset, + status: (() => { + if (run.currentRoundId === round.id) { + return P2P_TERMINAL_RUN_STATUSES.has(run.status) ? (run.status === 'completed' ? 'done' : 'skipped') : 'active'; + } + if ((run.roundAttemptCounts[round.id] ?? 0) > 0) return 'done'; + return 'pending'; + })(), + attempt: run.roundAttemptCounts[round.id] ?? 0, + step: latestStepByRoundId[round.id], + })) + : undefined, // Full node list for segmented progress display — compatibility projection all_nodes: (() => { type NodeInfo = { @@ -275,6 +352,7 @@ let IDLE_POLL_MS = 3_000; let GRACE_PERIOD_DEFAULT_MS = 180_000; // 3 min — complex analysis (subagent research + write) takes time let MIN_PROCESSING_MS = 30_000; // Don't trust idle detection until 30s after dispatch let FILE_SETTLE_CYCLES = 3; // File must stop growing for 3 poll cycles (9s) to be "settled" +let ROUND_HOP_CLEANUP_DELAY_MS = 0; /** Override poll interval for tests. */ export function _setIdlePollMs(ms: number): void { IDLE_POLL_MS = ms; } @@ -284,6 +362,8 @@ export function _setGracePeriodMs(ms: number): void { GRACE_PERIOD_DEFAULT_MS = export function _setMinProcessingMs(ms: number): void { MIN_PROCESSING_MS = ms; } /** Override file settle cycles for tests. */ export function _setFileSettleCycles(n: number): void { FILE_SETTLE_CYCLES = n; } +/** Override round hop artifact cleanup delay for tests. */ +export function _setRoundHopCleanupDelayMs(ms: number): void { ROUND_HOP_CLEANUP_DELAY_MS = ms; } // ── Idle event registry (callback-driven, no polling) ───────────────────── @@ -344,19 +424,103 @@ function waitForIdleEvent(session: string, timeoutMs: number): IdleWaiterHandle // ── Start a P2P run ─────────────────────────────────────────────────────── -export async function startP2pRun( - initiatorSession: string, - targets: P2pTarget[], - userText: string, - fileContents: Array<{ path: string; content: string }>, - serverLink: ServerLink | null, - rounds?: number, - extraPrompt?: string, - /** Explicit mode override — used for combo pipelines (e.g. "brainstorm>discuss>plan"). */ - modeOverride?: string, - /** Custom per-hop timeout in ms. Overrides mode default (300s). */ - hopTimeoutMs?: number, +function buildHelperEligibleSnapshot(initiatorSession: string, targets: P2pTarget[]): P2pParticipantSnapshotEntry[] { + const seen = new Set(); + const names = [initiatorSession, ...targets.map((target) => target.session)]; + const snapshot: P2pParticipantSnapshotEntry[] = []; + for (const sessionName of names) { + if (seen.has(sessionName)) continue; + seen.add(sessionName); + const record = getSession(sessionName); + snapshot.push({ + sessionName, + agentType: record?.agentType ?? 'unknown', + parentSession: record?.parentSession ?? null, + }); + } + return snapshot; +} + +function normalizeStartP2pRunArgs( + args: [ + StartP2pRunOptions + ] | [ + string, + P2pTarget[], + string, + Array<{ path: string; content: string }>, + ServerLink | null, + number | undefined, + string | undefined, + string | undefined, + number | undefined, + ], +): StartP2pRunOptions { + if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null && 'initiatorSession' in args[0]) { + return args[0] as StartP2pRunOptions; + } + const [ + initiatorSession, + targets, + userText, + fileContents, + serverLink, + rounds, + extraPrompt, + modeOverride, + hopTimeoutMs, + ] = args as [ + string, + P2pTarget[], + string, + Array<{ path: string; content: string }>, + ServerLink | null, + number | undefined, + string | undefined, + string | undefined, + number | undefined, + ]; + return { + initiatorSession, + targets, + userText, + fileContents, + serverLink, + rounds, + extraPrompt, + modeOverride, + hopTimeoutMs, + }; +} + +export async function startP2pRun(...args: + [StartP2pRunOptions] | [ + string, + P2pTarget[], + string, + Array<{ path: string; content: string }>, + ServerLink | null, + number | undefined, + string | undefined, + string | undefined, + number | undefined, + ] ): Promise { + const { + initiatorSession, + targets, + userText, + fileContents, + serverLink, + rounds, + extraPrompt, + modeOverride, + hopTimeoutMs, + advancedPresetKey, + advancedRounds, + advancedRunTimeoutMs, + contextReducer, + } = normalizeStartP2pRunArgs(args); // Validate same domain const mainSession = extractMainSession(initiatorSession); for (const t of targets) { @@ -365,6 +529,17 @@ export async function startP2pRun( } } + const helperEligibleSnapshot = buildHelperEligibleSnapshot(initiatorSession, targets); + const resolvedPlan: P2pResolvedPlan = resolveP2pRoundPlan({ + modeOverride: modeOverride ?? targets[0]?.mode ?? 'discuss', + roundsOverride: rounds, + hopTimeoutMinutes: hopTimeoutMs != null ? Math.ceil(hopTimeoutMs / 60_000) : undefined, + advancedPresetKey, + advancedRounds, + advancedRunTimeoutMinutes: advancedRunTimeoutMs != null ? Math.ceil(advancedRunTimeoutMs / 60_000) : undefined, + contextReducer, + participants: helperEligibleSnapshot, + }); const mode = modeOverride ?? targets[0]?.mode ?? 'discuss'; const modeConfig = getP2pMode(isComboMode(mode) ? parseModePipeline(mode)[0] : mode); const runId = randomUUID().slice(0, 12); @@ -394,7 +569,9 @@ export async function startP2pRun( await writeFile(contextFilePath, seed, 'utf8'); const P2P_MAX_ROUNDS = 6; - const totalRounds = Math.min(P2P_MAX_ROUNDS, Math.max(1, rounds ?? 1)); + const totalRounds = resolvedPlan.advanced + ? resolvedPlan.rounds.length + : Math.min(P2P_MAX_ROUNDS, Math.max(1, rounds ?? 1)); const run: P2pRun = { id: runId, discussionId, @@ -426,6 +603,23 @@ export async function startP2pRun( hopStartedAt: Date.now(), hopStates: [], activeTargetSessions: [], + advancedP2pEnabled: resolvedPlan.advanced, + resolvedRounds: resolvedPlan.advanced ? resolvedPlan.rounds : undefined, + helperEligibleSnapshot: resolvedPlan.helperEligibleSnapshot ?? helperEligibleSnapshot, + contextReducer: resolvedPlan.contextReducer, + advancedRunTimeoutMs: resolvedPlan.advanced && resolvedPlan.overallRunTimeoutMinutes != null + ? resolvedPlan.overallRunTimeoutMinutes * 60_000 + : undefined, + deadlineAt: resolvedPlan.advanced && resolvedPlan.overallRunTimeoutMinutes != null + ? Date.now() + (resolvedPlan.overallRunTimeoutMinutes * 60_000) + : null, + currentRoundId: resolvedPlan.advanced ? (resolvedPlan.rounds[0]?.id ?? null) : null, + currentExecutionStep: 0, + currentRoundAttempt: 1, + roundAttemptCounts: {}, + roundJumpCounts: {}, + routingHistory: [], + helperDiagnostics: [], _cancelled: false, }; @@ -625,6 +819,15 @@ async function cleanupRoundHopArtifacts(roundHops: P2pHopRuntime[]): Promise { void cleanupRoundHopArtifacts(roundHops); }, ROUND_HOP_CLEANUP_DELAY_MS); +} + function updateHopStatus(run: P2pRun, hop: P2pHopRuntime | null | undefined, status: P2pHopStatus, error: string | null = null): void { if (!hop) return; hop.status = status; @@ -639,6 +842,10 @@ function updateHopStatus(run: P2pRun, hop: P2pHopRuntime | null | undefined, sta } async function executeChain(run: P2pRun, modeConfig: P2pMode | undefined, serverLink: ServerLink | null): Promise { + if (run.advancedP2pEnabled && run.resolvedRounds?.length) { + await executeAdvancedChain(run, serverLink); + return; + } const totalHops = run.allTargets.length; // ── Multi-round loop ── @@ -674,83 +881,82 @@ async function executeChain(run: P2pRun, modeConfig: P2pMode | undefined, server isInitial: true, }, rp); const initialOk = await dispatchHop(run, run.initiatorSession, initialPrompt, serverLink, { sectionHeader: initialHeader, required: true }); - if (!initialOk) return; + if (!initialOk && (run._cancelled || isTerminal(run.status))) return; if (run._cancelled || isTerminal(run.status)) return; } // ── Phase 2: Sub-session hops ── run.activePhase = 'hop'; const roundHops = await createRoundHopStates(run, targets, roundModeKey); - run.activeTargetSessions = roundHops.map((hop) => hop.session); - const hopResults = await Promise.allSettled(targets.map(async (target, i) => { - if (run._cancelled) return false; - const hop = roundHops[i]; - const hopMode = combo ? roundModeKey : target.mode; - const hopLabel = `${discussionParticipantName(target.session)} — ${capitalize(hopMode)} (hop ${i + 1}/${totalHops}${roundLabel})`; - hop.section_header = hopLabel; - const hopModeConfig = combo ? roundModeConfig : (getP2pMode(target.mode) ?? modeConfig); - const hopPrompt = buildHopPrompt(run, hopModeConfig, { - session: target.session, - sectionHeader: hopLabel, - instruction: `Read the discussion file and provide your ${hopMode} analysis. Append your output to the file.\nIMPORTANT: This is ANALYSIS ONLY. Do NOT implement fixes, do NOT edit code files, do NOT run commands. Only write your analysis into this discussion file.`, + try { + run.activeTargetSessions = roundHops.map((hop) => hop.session); + const hopResults = await Promise.allSettled(targets.map(async (target, i) => { + if (run._cancelled) return false; + const hop = roundHops[i]; + const hopMode = combo ? roundModeKey : target.mode; + const hopLabel = `${discussionParticipantName(target.session)} — ${capitalize(hopMode)} (hop ${i + 1}/${totalHops}${roundLabel})`; + hop.section_header = hopLabel; + const hopModeConfig = combo ? roundModeConfig : (getP2pMode(target.mode) ?? modeConfig); + const hopPrompt = buildHopPrompt(run, hopModeConfig, { + session: target.session, + sectionHeader: hopLabel, + instruction: `Read the discussion file and provide your ${hopMode} analysis. Append your output to the file.\nIMPORTANT: This is ANALYSIS ONLY. Do NOT implement fixes, do NOT edit code files, do NOT run commands. Only write your analysis into this discussion file.`, + isInitial: false, + filePath: hop.artifact_path, + }, rp); + logger.info({ runId: run.id, target: target.session, mode: hopMode, hop: i + 1, totalHops, round: run.currentRound }, 'P2P: Phase 2 — dispatching hop'); + return dispatchHop(run, target.session, hopPrompt, serverLink, { + sectionHeader: hopLabel, + hop, + filePath: hop.artifact_path, + }); + })); + run.activeTargetSessions = []; + run.currentTargetSession = null; + if (run._cancelled || isTerminal(run.status)) return; + logger.info({ + runId: run.id, + round: run.currentRound, + settled: hopResults.length, + completed: roundHops.filter((hop) => hop.status === 'completed').length, + }, 'P2P: Phase 2 — round barrier settled'); + await appendRoundEvidence(run, roundHops); + if (run._cancelled || isTerminal(run.status)) return; + + run.remainingTargets = []; + + // ── Round summary: Initiator synthesizes this round ── + if (run._cancelled) return; + run.runPhase = 'summarizing'; + run.summaryPhase = 'running'; + run.activePhase = 'summary'; + const isLastRound = run.currentRound === run.rounds; + const summaryModeConfig = isLastRound && combo + ? getModeForRound(run.mode, run.rounds) // last pipeline mode for final summary + : roundModeConfig; + const roundSummaryHeader = isLastRound + ? `${discussionParticipantNameWithMode(run.initiatorSession, roundModeKey)} — Final Summary` + : `${discussionParticipantNameWithMode(run.initiatorSession, roundModeKey)} — Round ${run.currentRound}/${run.rounds} Summary`; + const roundSummaryInstruction = isLastRound + ? `${summaryModeConfig?.summaryPrompt ?? 'Synthesize a final summary that captures the consensus, key decisions, and any remaining disagreements across all rounds.'}\nBefore writing the summary, use the hop evidence already appended into the discussion file for this round. If the user context clearly specifies a destination file for the final plan, write the complete plan there. Otherwise, write the complete plan at the end of the discussion file.` + : `Synthesize the key points, areas of agreement, and open questions from this round. Then assign specific focus areas or questions for each participant in the next round (round ${run.currentRound + 1}). Append to the file.\nIMPORTANT: This is ANALYSIS ONLY. Do NOT implement fixes, do NOT edit code files, do NOT run commands. Only write your analysis into this discussion file.`; + const roundSummaryPrompt = buildHopPrompt(run, summaryModeConfig, { + session: run.initiatorSession, + sectionHeader: roundSummaryHeader, + instruction: `${roundSummaryInstruction}\nThe orchestrator has already appended each completed hop's evidence into the discussion file. If you write the final plan to another file, still append a short completion note under the new final-summary heading in the discussion file that records the chosen output file path.`, isInitial: false, - filePath: hop.artifact_path, }, rp); - logger.info({ runId: run.id, target: target.session, mode: hopMode, hop: i + 1, totalHops, round: run.currentRound }, 'P2P: Phase 2 — dispatching hop'); - return dispatchHop(run, target.session, hopPrompt, serverLink, { - sectionHeader: hopLabel, - hop, - filePath: hop.artifact_path, + logger.info({ runId: run.id, round: run.currentRound, isLastRound, roundMode: roundModeKey }, isLastRound ? 'P2P: Final summary — initiator' : 'P2P: Round summary — initiator'); + const summaryOk = await dispatchHop(run, run.initiatorSession, roundSummaryPrompt, serverLink, { + sectionHeader: roundSummaryHeader, + required: true, }); - })); - run.activeTargetSessions = []; - run.currentTargetSession = null; - if (run._cancelled || isTerminal(run.status)) return; - logger.info({ - runId: run.id, - round: run.currentRound, - settled: hopResults.length, - completed: roundHops.filter((hop) => hop.status === 'completed').length, - }, 'P2P: Phase 2 — round barrier settled'); - await appendRoundEvidence(run, roundHops); - if (run._cancelled || isTerminal(run.status)) return; - - if (run.currentRound === run.rounds) { - run.remainingTargets = []; - } else { - run.remainingTargets = []; + if (!summaryOk && (run._cancelled || isTerminal(run.status))) return; + run.summaryPhase = summaryOk ? 'completed' : 'failed'; + if (run._cancelled || isTerminal(run.status)) return; + } finally { + scheduleRoundHopArtifactCleanup(roundHops); } - - // ── Round summary: Initiator synthesizes this round ── - if (run._cancelled) return; - run.runPhase = 'summarizing'; - run.summaryPhase = 'running'; - run.activePhase = 'summary'; - const isLastRound = run.currentRound === run.rounds; - const summaryModeConfig = isLastRound && combo - ? getModeForRound(run.mode, run.rounds) // last pipeline mode for final summary - : roundModeConfig; - const roundSummaryHeader = isLastRound - ? `${discussionParticipantNameWithMode(run.initiatorSession, roundModeKey)} — Final Summary` - : `${discussionParticipantNameWithMode(run.initiatorSession, roundModeKey)} — Round ${run.currentRound}/${run.rounds} Summary`; - const roundSummaryInstruction = isLastRound - ? `${summaryModeConfig?.summaryPrompt ?? 'Synthesize a final summary that captures the consensus, key decisions, and any remaining disagreements across all rounds.'}\nBefore writing the summary, use the hop evidence already appended into the discussion file for this round. If the user context clearly specifies a destination file for the final plan, write the complete plan there. Otherwise, write the complete plan at the end of the discussion file.` - : `Synthesize the key points, areas of agreement, and open questions from this round. Then assign specific focus areas or questions for each participant in the next round (round ${run.currentRound + 1}). Append to the file.\nIMPORTANT: This is ANALYSIS ONLY. Do NOT implement fixes, do NOT edit code files, do NOT run commands. Only write your analysis into this discussion file.`; - const roundSummaryPrompt = buildHopPrompt(run, summaryModeConfig, { - session: run.initiatorSession, - sectionHeader: roundSummaryHeader, - instruction: `${roundSummaryInstruction}\nThe orchestrator has already appended each completed hop's evidence into the discussion file. If you write the final plan to another file, still append a short completion note under the new final-summary heading in the discussion file that records the chosen output file path.`, - isInitial: false, - }, rp); - logger.info({ runId: run.id, round: run.currentRound, isLastRound, roundMode: roundModeKey }, isLastRound ? 'P2P: Final summary — initiator' : 'P2P: Round summary — initiator'); - const summaryOk = await dispatchHop(run, run.initiatorSession, roundSummaryPrompt, serverLink, { - sectionHeader: roundSummaryHeader, - required: true, - }); - if (!summaryOk) return; - run.summaryPhase = 'completed'; - setTimeout(() => { void cleanupRoundHopArtifacts(roundHops); }, 30_000); - if (run._cancelled || isTerminal(run.status)) return; } if (run._cancelled || isTerminal(run.status)) return; @@ -782,6 +988,487 @@ async function executeChain(run: P2pRun, modeConfig: P2pMode | undefined, server }, 60_000); } +function addHelperDiagnostic(run: P2pRun, diagnostic: Omit): void { + run.helperDiagnostics.push({ ...diagnostic, timestamp: Date.now() }); +} + +function parseVerdictFromContent(content: string): 'PASS' | 'REWORK' | null { + const matches = [...content.matchAll(//g)]; + const verdict = matches.at(-1)?.[1]; + return verdict === 'PASS' || verdict === 'REWORK' ? verdict : null; +} + +function helperFallbackCandidates(run: P2pRun, exclude: string[]): string[] { + const excluded = new Set(exclude); + return run.helperEligibleSnapshot + .filter((entry) => entry.sessionName !== run.initiatorSession) + .filter((entry) => !!entry.parentSession || entry.sessionName.startsWith('deck_sub_')) + .filter((entry) => !excluded.has(entry.sessionName)) + .map((entry) => entry.sessionName); +} + +function ensureRunDeadline(run: P2pRun, serverLink: ServerLink | null): boolean { + if (!run.advancedRunTimeoutMs || !run.deadlineAt) return true; + if (Date.now() <= run.deadlineAt) return true; + failRun(run, 'timed_out', 'advanced_run_timeout', serverLink); + return false; +} + +async function launchClonedHelperSession(run: P2pRun, templateSession: string): Promise { + const template = getSession(templateSession); + if (!template || !template.runtimeType || template.runtimeType !== 'transport') { + throw new Error(`Helper template is not an eligible SDK transport session: ${templateSession}`); + } + const helperName = `deck_p2p_helper_${run.id}_${run.currentExecutionStep}_${randomUUID().slice(0, 6)}`; + await launchTransportSession({ + name: helperName, + projectName: template.projectName, + role: 'w1', + agentType: template.agentType as any, + projectDir: template.projectDir, + skipStore: true, + fresh: true, + requestedModel: template.requestedModel ?? template.activeModel ?? undefined, + transportConfig: template.transportConfig ?? undefined, + description: `P2P helper for ${run.id}`, + label: helperName, + effort: template.effort, + ...(template.ccPreset ? { ccPreset: template.ccPreset } : {}), + }); + return helperName; +} + +async function teardownHelperSession(run: P2pRun, sessionName: string): Promise { + try { + await stopTransportRuntimeSession(sessionName); + } catch (err) { + addHelperDiagnostic(run, { + code: 'P2P_HELPER_CLEANUP_FAILED', + attempt: run.currentRoundAttempt, + sourceSession: sessionName, + message: err instanceof Error ? err.message : String(err), + }); + } +} + +async function cleanupReducerSummaryFile( + run: P2pRun, + summaryPath: string, + sourceSession?: string | null, + templateSession?: string | null, + fallbackSession?: string | null, +): Promise { + try { + await unlink(summaryPath); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return; + addHelperDiagnostic(run, { + code: 'P2P_HELPER_CLEANUP_FAILED', + attempt: run.currentRoundAttempt, + sourceSession: sourceSession ?? null, + templateSession: templateSession ?? null, + fallbackSession: fallbackSession ?? null, + message: err instanceof Error ? err.message : String(err), + }); + } +} + +async function reduceAdvancedContext( + run: P2pRun, + round: P2pResolvedRound, + serverLink: ServerLink | null, +): Promise { + if (!run.contextReducer) return null; + const info = await stat(run.contextFilePath).catch(() => null); + if (!info || info.size < 32_000) return null; + + const summaryPath = join(dirname(run.contextFilePath), `${run.id}.reducer.${run.currentExecutionStep}.md`); + const sectionHeader = `P2P Helper Summary — ${round.title} (step ${run.currentExecutionStep})`; + const reducerPrompt = [ + `[P2P Helper Task — ${run.id}]`, + `Read the discussion file at ${run.contextFilePath}.`, + `Produce a compact context reduction for the next round.`, + `Focus on: latest implementation attempt, latest audit findings, declared artifact targets, and only the most relevant unresolved issues.`, + `Write the result to ${summaryPath}.`, + `Add a new heading "## ${sectionHeader}" and put the reduced context under it.`, + `Do not change workflow verdicts, do not route, and do not edit any code files.`, + `Start immediately.`, + ].join('\n'); + + const readReducedSummary = async () => { + const content = await readFile(summaryPath, 'utf8').catch(() => ''); + const section = extractHeadingSection(content, sectionHeader) ?? content; + return section.trim() ? section.trim() : null; + }; + + const attemptWithSession = async (sessionName: string, codeOnFailure: P2pHelperDiagnostic['code'], templateSession?: string | null) => { + const ok = await dispatchHop(run, sessionName, reducerPrompt, serverLink, { + sectionHeader, + filePath: summaryPath, + required: false, + }); + if (!ok) { + addHelperDiagnostic(run, { + code: codeOnFailure, + attempt: run.currentRoundAttempt, + sourceSession: sessionName, + templateSession: templateSession ?? null, + }); + return null; + } + return readReducedSummary(); + }; + + await writeFile(summaryPath, '# Helper Summary\n\n', 'utf8'); + try { + if (run.contextReducer.mode === 'reuse_existing_session' && run.contextReducer.sessionName) { + const primaryResult = await attemptWithSession( + run.contextReducer.sessionName, + 'P2P_HELPER_PRIMARY_FAILED', + run.contextReducer.sessionName, + ); + if (primaryResult) return primaryResult; + } else if (run.contextReducer.mode === 'clone_sdk_session' && run.contextReducer.templateSession) { + let helperName: string | null = null; + try { + helperName = await launchClonedHelperSession(run, run.contextReducer.templateSession); + const primaryResult = await attemptWithSession( + helperName, + 'P2P_HELPER_PRIMARY_FAILED', + run.contextReducer.templateSession, + ); + if (primaryResult) return primaryResult; + } catch (err) { + addHelperDiagnostic(run, { + code: 'P2P_HELPER_PRIMARY_FAILED', + attempt: run.currentRoundAttempt, + templateSession: run.contextReducer.templateSession, + message: err instanceof Error ? err.message : String(err), + }); + } finally { + if (helperName) await teardownHelperSession(run, helperName); + } + } + + const fallbackSession = helperFallbackCandidates(run, [ + run.contextReducer.sessionName ?? '', + run.contextReducer.templateSession ?? '', + ])[0]; + if (!fallbackSession) { + addHelperDiagnostic(run, { + code: 'P2P_COMPRESSION_SKIPPED_NO_FALLBACK', + attempt: run.currentRoundAttempt, + templateSession: run.contextReducer.templateSession ?? null, + sourceSession: run.contextReducer.sessionName ?? null, + }); + return null; + } + const fallbackResult = await attemptWithSession(fallbackSession, 'P2P_HELPER_FALLBACK_FAILED', fallbackSession); + if (fallbackResult) return fallbackResult; + failRun(run, 'failed', `helper_fallback_failed:${fallbackSession}`, serverLink); + return null; + } finally { + await cleanupReducerSummaryFile( + run, + summaryPath, + run.contextReducer.sessionName ?? null, + run.contextReducer.templateSession ?? null, + ); + } +} + +async function captureArtifactBaseline(run: P2pRun, round: P2pResolvedRound): Promise> { + const baseline = new Map(); + const record = getSession(run.initiatorSession); + const projectDir = record?.projectDir ?? process.cwd(); + if (round.artifactConvention === 'openspec_convention') { + const target = join(projectDir, 'openspec', 'changes'); + try { + const entries = await readdir(target); + baseline.set(target, entries.join('\n')); + } catch { + baseline.set(target, null); + } + return baseline; + } + for (const output of round.artifactOutputs) { + const absPath = join(projectDir, output); + try { + baseline.set(absPath, await readFile(absPath, 'utf8')); + } catch { + baseline.set(absPath, null); + } + } + return baseline; +} + +async function validateArtifactOutputsForRound(run: P2pRun, round: P2pResolvedRound, baseline: Map): Promise { + if (round.artifactConvention === 'none') return; + if (round.artifactConvention === 'openspec_convention') { + const target = [...baseline.keys()][0]; + const before = baseline.get(target) ?? null; + try { + const afterEntries = (await readdir(target)).join('\n'); + if (afterEntries === before) throw new Error('openspec_convention artifacts were not observably updated'); + return; + } catch (err) { + throw new Error(err instanceof Error ? err.message : String(err)); + } + } + for (const [absPath, before] of baseline.entries()) { + let after: string | null = null; + try { after = await readFile(absPath, 'utf8'); } catch { after = null; } + if (after == null) throw new Error(`Expected artifact missing after round: ${absPath}`); + if (after === before) throw new Error(`Expected artifact not observably updated: ${absPath}`); + } +} + +async function readAppendedContent(filePath: string, baselineSize: number): Promise { + const buffer = await readFile(filePath); + return buffer.subarray(Math.min(buffer.length, baselineSize)).toString('utf8'); +} + +function buildAdvancedRoundPrefix(run: P2pRun, round: P2pResolvedRound): string { + return `[Advanced Round ${run.currentExecutionStep} — ${round.title} — Attempt ${run.currentRoundAttempt}]`; +} + +function buildAdvancedPromptCommon( + run: P2pRun, + round: P2pResolvedRound, + targetSession: string, + filePath: string, + sectionHeader: string, + reducerSummary: string | null, + instruction: string, +): string { + const parts: string[] = []; + parts.push(buildAdvancedRoundPrefix(run, round)); + parts.push(''); + parts.push(P2P_BASELINE_PROMPT); + if (round.presetPrompt) parts.push(round.presetPrompt); + parts.push(''); + parts.push(`[P2P Advanced Task — run ${run.id}]`); + parts.push(`Discussion file: ${filePath}`); + parts.push(`Your identity for this round is "${discussionParticipantName(targetSession)}".`); + parts.push(`Round id: ${round.id}`); + parts.push(`Permission scope: ${round.permissionScope}`); + if (round.artifactConvention === 'openspec_convention') { + parts.push('Required artifact contract: write OpenSpec artifacts under repository OpenSpec conventions inside openspec/changes/.'); + } else if (round.artifactOutputs.length > 0) { + parts.push(`Required artifact outputs: ${round.artifactOutputs.join(', ')}`); + } + if (reducerSummary) { + parts.push(''); + parts.push('Reduced context for this attempt:'); + parts.push(reducerSummary); + } + if (round.promptAppend) { + parts.push(''); + parts.push(`Additional round instructions: ${round.promptAppend}`); + } + parts.push(''); + parts.push(instruction); + parts.push(`Add a new heading "## ${sectionHeader}" at the end of the discussion file and write your result below it.`); + parts.push('Do not ask for confirmation. Start immediately.'); + if (run.extraPrompt) { + parts.push(''); + parts.push(`Additional instructions: ${run.extraPrompt}`); + } + return parts.join('\n'); +} + +function buildAdvancedHopPrompt( + run: P2pRun, + round: P2pResolvedRound, + target: P2pTarget, + filePath: string, + sectionHeader: string, + reducerSummary: string | null, +): string { + const instruction = round.permissionScope === 'analysis_only' + ? 'Read the discussion file and provide analysis only. Do not edit code or other files.' + : round.permissionScope === 'artifact_generation' + ? 'Read the discussion file and produce the required artifacts. You may write only the round outputs and the discussion note for this round.' + : 'Read the discussion file and perform the implementation work required by this round. You may edit code and tests as needed, then append a concise execution note to the discussion file.'; + return buildAdvancedPromptCommon(run, round, target.session, filePath, sectionHeader, reducerSummary, instruction); +} + +function buildAdvancedSynthesisPrompt( + run: P2pRun, + round: P2pResolvedRound, + sectionHeader: string, + reducerSummary: string | null, +): string { + const instruction = round.summaryPrompt + ?? 'Synthesize the evidence appended in this round into one authoritative summary.'; + return buildAdvancedPromptCommon( + run, + round, + run.initiatorSession, + run.contextFilePath, + sectionHeader, + reducerSummary, + instruction, + ); +} + +async function executeAdvancedChain(run: P2pRun, serverLink: ServerLink | null): Promise { + const rounds = run.resolvedRounds ?? []; + let roundIndex = 0; + while (roundIndex < rounds.length) { + if (run._cancelled || isTerminal(run.status)) return; + if (!ensureRunDeadline(run, serverLink)) return; + const round = rounds[roundIndex]; + run.timeoutMs = round.timeoutMs; + run.currentRound = roundIndex + 1; + run.currentRoundId = round.id; + run.currentExecutionStep += 1; + run.roundAttemptCounts[round.id] = (run.roundAttemptCounts[round.id] ?? 0) + 1; + run.currentRoundAttempt = run.roundAttemptCounts[round.id]; + run.runPhase = 'round_execution'; + run.summaryPhase = null; + run.activePhase = round.dispatchStyle === 'initiator_only' ? 'initial' : 'hop'; + pushState(run, serverLink); + + const artifactBaseline = await captureArtifactBaseline(run, round); + const reducerSummary = await reduceAdvancedContext(run, round, serverLink); + if (run._cancelled || isTerminal(run.status)) return; + + let authoritativeSegment = ''; + if (round.dispatchStyle === 'initiator_only') { + const sectionHeader = `${discussionParticipantName(run.initiatorSession)} — ${round.title} (attempt ${run.currentRoundAttempt})`; + const baselineBuffer = await readFile(run.contextFilePath).catch(() => Buffer.from('')); + const prompt = buildAdvancedHopPrompt( + run, + round, + { session: run.initiatorSession, mode: round.modeKey }, + run.contextFilePath, + sectionHeader, + reducerSummary, + ); + const ok = await dispatchHop(run, run.initiatorSession, prompt, serverLink, { + sectionHeader, + required: true, + }); + if (!ok && (run._cancelled || isTerminal(run.status))) return; + authoritativeSegment = ok ? await readAppendedContent(run.contextFilePath, baselineBuffer.length) : ''; + } else { + const targets = [...run.allTargets]; + const roundHops = await createRoundHopStates(run, targets, round.modeKey); + try { + run.activeTargetSessions = roundHops.map((hop) => hop.session); + await Promise.allSettled(targets.map(async (target, index) => { + const hop = roundHops[index]; + const sectionHeader = `${discussionParticipantName(target.session)} — ${round.title} (hop ${index + 1}/${targets.length}, attempt ${run.currentRoundAttempt})`; + hop.section_header = sectionHeader; + const prompt = buildAdvancedHopPrompt(run, round, target, hop.artifact_path, sectionHeader, reducerSummary); + return dispatchHop(run, target.session, prompt, serverLink, { + sectionHeader, + hop, + filePath: hop.artifact_path, + }); + })); + run.activeTargetSessions = []; + run.currentTargetSession = null; + if (run._cancelled || isTerminal(run.status)) return; + await appendRoundEvidence(run, roundHops); + if (round.synthesisStyle === 'initiator_summary') { + run.runPhase = 'summarizing'; + run.summaryPhase = 'running'; + run.activePhase = 'summary'; + const sectionHeader = `${discussionParticipantName(run.initiatorSession)} — ${round.title} Synthesis (attempt ${run.currentRoundAttempt})`; + const baselineBuffer = await readFile(run.contextFilePath).catch(() => Buffer.from('')); + const prompt = buildAdvancedSynthesisPrompt(run, round, sectionHeader, reducerSummary); + const ok = await dispatchHop(run, run.initiatorSession, prompt, serverLink, { + sectionHeader, + required: true, + }); + if (!ok && (run._cancelled || isTerminal(run.status))) return; + authoritativeSegment = ok ? await readAppendedContent(run.contextFilePath, baselineBuffer.length) : ''; + run.summaryPhase = ok ? 'completed' : 'failed'; + } + } finally { + scheduleRoundHopArtifactCleanup(roundHops); + } + } + + await validateArtifactOutputsForRound(run, round, artifactBaseline).catch((err) => { + failRun(run, 'failed', err instanceof Error ? err.message : String(err), serverLink); + }); + if (run._cancelled || isTerminal(run.status)) return; + + const verdict = round.requiresVerdict ? parseVerdictFromContent(authoritativeSegment) : null; + const effectiveVerdict = round.requiresVerdict + ? (verdict ?? (() => { + addHelperDiagnostic(run, { + code: 'P2P_VERDICT_MISSING', + attempt: run.currentRoundAttempt, + sourceSession: run.initiatorSession, + message: `Missing verdict marker for round ${round.id}`, + }); + return 'REWORK' as const; + })()) + : null; + + const jump = round.allowRouting && round.jumpRule + ? (() => { + const jumpCount = run.roundJumpCounts[round.id] ?? 0; + const belowMax = jumpCount < round.jumpRule!.maxTriggers; + if (!belowMax) return null; + if (round.verdictPolicy === 'forced_rework') { + if (jumpCount < round.jumpRule.minTriggers) return round.jumpRule.targetRoundId; + return effectiveVerdict === (round.jumpRule.marker ?? 'REWORK') ? round.jumpRule.targetRoundId : null; + } + return effectiveVerdict === (round.jumpRule.marker ?? 'REWORK') ? round.jumpRule.targetRoundId : null; + })() + : null; + + if (jump) { + run.roundJumpCounts[round.id] = (run.roundJumpCounts[round.id] ?? 0) + 1; + run.routingHistory.push({ + fromRoundId: round.id, + toRoundId: jump, + trigger: effectiveVerdict, + atStep: run.currentExecutionStep, + atAttempt: run.currentRoundAttempt, + timestamp: Date.now(), + }); + roundIndex = rounds.findIndex((entry) => entry.id === jump); + continue; + } + + roundIndex += 1; + } + + if (!ensureRunDeadline(run, serverLink) || run._cancelled || isTerminal(run.status)) return; + run.runPhase = 'summarizing'; + run.summaryPhase = 'running'; + run.activePhase = 'summary'; + const finalRound = rounds[Math.max(rounds.length - 1, 0)]; + run.timeoutMs = finalRound?.timeoutMs ?? run.timeoutMs; + const finalPrompt = buildHopPrompt(run, getP2pMode(finalRound?.modeKey ?? run.mode), { + session: run.initiatorSession, + sectionHeader: `${discussionParticipantNameWithMode(run.initiatorSession, finalRound?.modeKey ?? run.mode)} — Final Summary`, + instruction: `${getP2pMode(finalRound?.modeKey ?? run.mode)?.summaryPrompt ?? 'Synthesize a final summary that captures the consensus, key decisions, and any remaining disagreements across all rounds.'}\nBefore writing the summary, use the hop evidence already appended into the discussion file for this round. If the user context clearly specifies a destination file for the final plan, write the complete plan there. Otherwise, write the complete plan at the end of the discussion file.`, + isInitial: false, + }); + const summaryOk = await dispatchHop(run, run.initiatorSession, finalPrompt, serverLink, { + sectionHeader: `${discussionParticipantNameWithMode(run.initiatorSession, finalRound?.modeKey ?? run.mode)} — Final Summary`, + required: true, + }); + if (!summaryOk && (run._cancelled || isTerminal(run.status))) return; + run.summaryPhase = summaryOk ? 'completed' : 'failed'; + + let fullContent = ''; + try { + fullContent = await readFile(run.contextFilePath, 'utf8'); + run.resultSummary = fullContent.slice(-2000); + } catch { /* ignore */ } + run.completedAt = new Date().toISOString(); + transition(run, 'completed', serverLink); + setTimeout(() => { activeRuns.delete(run.id); }, 60_000); +} + // ── Single hop dispatch + wait ──────────────────────────────────────────── interface DispatchHopOptions { @@ -852,6 +1539,15 @@ async function dispatchHop( } }; + const abortForOverallTimeout = async () => { + if (hop) { + await finishHop('timed_out', 'advanced_run_timeout'); + } else { + run.currentTargetSession = null; + run.activeTargetSessions = run.activeTargetSessions.filter((item) => item !== session); + } + }; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (run._cancelled) { await finishHop('cancelled'); @@ -898,6 +1594,11 @@ async function dispatchHop( let headingFoundAt = 0; while (Date.now() < deadline) { + if (!ensureRunDeadline(run, serverLink)) { + idleWaiter.cancel(); + await abortForOverallTimeout(); + return false; + } if (Date.now() >= hardDeadline) { logger.warn({ runId: run.id, session }, 'P2P: hard deadline reached, force-skipping hop'); break; @@ -908,6 +1609,11 @@ async function dispatchHop( return false; } await sleep(IDLE_POLL_MS); + if (!ensureRunDeadline(run, serverLink)) { + idleWaiter.cancel(); + await abortForOverallTimeout(); + return false; + } if (Date.now() >= hardDeadline) { logger.warn({ runId: run.id, session }, 'P2P: hard deadline reached, force-skipping hop'); break; @@ -1012,8 +1718,7 @@ async function dispatchHop( logger.warn({ runId: run.id, session }, 'P2P: agent idle without file change after retry'); idleWaiter.cancel(); await finishHop('failed', 'idle_without_file_change'); - if (required) failRun(run, 'dispatch_failed', 'idle_without_file_change', serverLink); - else pushState(run, serverLink); + pushState(run, serverLink); return false; } } @@ -1126,11 +1831,16 @@ function transition(run: P2pRun, status: P2pRunStatus, serverLink: ServerLink | run.summaryPhase = 'completed'; } else if (status === 'cancelled') { run.runPhase = 'cancelled'; - } else if (status === 'failed' || status === 'timed_out') { + } else if (status === 'failed') { run.runPhase = 'failed'; } if (P2P_TERMINAL_RUN_STATUSES.has(status)) { run.completedAt = run.completedAt ?? new Date().toISOString(); + if (run.advancedP2pEnabled) { + void cleanupRoundHopArtifacts(run.hopStates); + } else { + scheduleRoundHopArtifactCleanup(run.hopStates); + } } run.updatedAt = new Date().toISOString(); logger.info({ runId: run.id, status }, 'P2P run state transition'); @@ -1143,8 +1853,17 @@ function failRun(run: P2pRun, errorType: string, message: string, serverLink: Se run.updatedAt = new Date().toISOString(); const status: P2pRunStatus = errorType === 'timed_out' ? 'timed_out' : 'failed'; run.status = status; - run.runPhase = 'failed'; - if (run.activePhase === 'summary') run.summaryPhase = 'failed'; + if (status === 'failed') { + run.runPhase = 'failed'; + } + if (run.activePhase === 'summary') { + run.summaryPhase = 'failed'; + } + if (run.advancedP2pEnabled) { + void cleanupRoundHopArtifacts(run.hopStates); + } else { + scheduleRoundHopArtifactCleanup(run.hopStates); + } logger.warn({ runId: run.id, errorType, message }, 'P2P run failed'); pushState(run, serverLink); } diff --git a/src/daemon/transport-relay.ts b/src/daemon/transport-relay.ts index 73cb5889..b4ef1601 100644 --- a/src/daemon/transport-relay.ts +++ b/src/daemon/transport-relay.ts @@ -5,7 +5,7 @@ * JSONL watchers), so ChatView renders them without any special handling. * Also cached to local JSONL for replay on reconnect/restart. */ -import type { TransportProvider, ProviderError } from '../agent/transport-provider.js'; +import type { TransportProvider, ProviderError, ProviderStatusUpdate } from '../agent/transport-provider.js'; import type { MessageDelta, AgentMessage, ToolCallEvent } from '../../shared/agent-message.js'; import { TRANSPORT_MSG } from '../../shared/transport-events.js'; import { resolveSessionName } from '../agent/session-manager.js'; @@ -16,6 +16,72 @@ import { resolveContextWindow } from '../util/model-context.js'; let sendToServer: ((msg: Record) => void) | null = null; const inFlightMessages = new Map(); +const pendingStreamUpdates = new Map | null; +}>(); +const STREAM_UPDATE_INTERVAL_MS = 80; + +function emitStreamingAssistantText(sessionName: string, eventId: string, text: string): void { + timelineEmitter.emit(sessionName, 'assistant.text', { + text, + streaming: true, + }, { source: 'daemon', confidence: 'high', eventId }); +} + +function clearPendingStreamUpdate(eventId: string): void { + const pending = pendingStreamUpdates.get(eventId); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + pendingStreamUpdates.delete(eventId); +} + +function flushPendingStreamUpdate(eventId: string): void { + const pending = pendingStreamUpdates.get(eventId); + if (!pending || pending.pendingText == null) return; + pending.timer = null; + pending.lastEmitAt = Date.now(); + const nextText = pending.pendingText; + pending.pendingText = null; + emitStreamingAssistantText(pending.sessionName, pending.eventId, nextText); +} + +function emitThrottledStreamingAssistantText(sessionName: string, eventId: string, text: string): void { + const now = Date.now(); + let pending = pendingStreamUpdates.get(eventId); + if (!pending) { + pending = { + sessionName, + eventId, + lastEmitAt: 0, + pendingText: null, + timer: null, + }; + pendingStreamUpdates.set(eventId, pending); + } else { + pending.sessionName = sessionName; + } + + if (pending.lastEmitAt === 0 || now - pending.lastEmitAt >= STREAM_UPDATE_INTERVAL_MS) { + if (pending.timer) { + clearTimeout(pending.timer); + pending.timer = null; + } + pending.pendingText = null; + pending.lastEmitAt = now; + emitStreamingAssistantText(sessionName, eventId, text); + return; + } + + pending.pendingText = text; + if (pending.timer) return; + pending.timer = setTimeout(() => { + flushPendingStreamUpdate(eventId); + }, STREAM_UPDATE_INTERVAL_MS - (now - pending.lastEmitAt)); +} /** Set the send function (called once during server-link setup) */ export function setTransportRelaySend(fn: (msg: Record) => void): void { @@ -34,16 +100,17 @@ export function wireProviderToRelay(provider: TransportProvider): void { // Use delta.delta as the display text directly — the provider's internal // accumulator handles cumulative vs incremental differences. const stableEventId = `transport:${sessionName}:${delta.messageId}`; + const previous = inFlightMessages.get(sessionName); + if (previous && previous.messageId !== delta.messageId) { + clearPendingStreamUpdate(previous.eventId); + } inFlightMessages.set(sessionName, { messageId: delta.messageId, eventId: stableEventId, text: delta.delta, }); - timelineEmitter.emit(sessionName, 'assistant.text', { - text: delta.delta, - streaming: true, - }, { source: 'daemon', confidence: 'high', eventId: stableEventId }); + emitThrottledStreamingAssistantText(sessionName, stableEventId, delta.delta); }); provider.onComplete((providerSid: string, message: AgentMessage) => { @@ -60,6 +127,7 @@ export function wireProviderToRelay(provider: TransportProvider): void { ? tracked.eventId : `transport:${sessionName}:${message.id}`; inFlightMessages.delete(sessionName); + clearPendingStreamUpdate(stableEventId); timelineEmitter.emit(sessionName, 'assistant.text', { text: finalText, streaming: false, @@ -102,6 +170,7 @@ export function wireProviderToRelay(provider: TransportProvider): void { const tracked = inFlightMessages.get(sessionName); inFlightMessages.delete(sessionName); + if (tracked) clearPendingStreamUpdate(tracked.eventId); const errorText = tracked?.text ? `${tracked.text}\n\n⚠️ Error: ${error.message}` : `⚠️ Error: ${error.message}`; @@ -175,6 +244,19 @@ export function wireProviderToRelay(provider: TransportProvider): void { ...(tool.detail !== undefined ? { detail: tool.detail } : {}), }); }); + + provider.onStatus?.((providerSid: string, status: ProviderStatusUpdate) => { + const sessionName = resolveSessionName(providerSid); + if (!sessionName) { + logger.debug({ providerSid }, 'transport-relay: unresolved route for status — dropped'); + return; + } + + timelineEmitter.emit(sessionName, 'agent.status', { + status: status.status, + ...(status.label !== undefined ? { label: status.label } : {}), + }, { source: 'daemon', confidence: 'high' }); + }); } /** Emit user.message through timeline when user sends to a transport session. */ diff --git a/src/index.ts b/src/index.ts index 79390fb9..241f74fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -708,7 +708,7 @@ program program .command('restart') .description('Restart the imcodes daemon service') - .action(() => { + .action(async () => { ensureServiceForeground(); const platform = process.platform; if (platform === 'darwin') { @@ -729,11 +729,15 @@ program process.exit(1); } } else if (platform === 'win32') { - if (!restartWindowsDaemon(process.pid)) { + // Use the single reusable ensureDaemonRunning() that handles all + // edge cases: orphan daemons holding the named pipe, crash-loop + // watchdogs from the BOM bug, missing PID files, etc. + const { ensureDaemonRunning } = await import('./util/windows-daemon.js'); + if (!ensureDaemonRunning(process.pid)) { console.error('Watchdog not found. Run "imcodes bind" first.'); process.exit(1); } - console.log('Daemon will restart in ~5 seconds (via watchdog).'); + console.log('Daemon restarted (orphans cleared, fresh watchdog launched).'); } else { console.error(`Unsupported platform: ${platform}`); process.exit(1); } @@ -839,7 +843,8 @@ program program .command('repair-watchdog') - .description('Regenerate Windows daemon watchdog files with current paths') + .alias('r') + .description('Regenerate Windows daemon watchdog files and (re)start the watchdog (alias: r)') .action(async () => { if (process.platform !== 'win32') { console.log('This command is only needed on Windows.'); @@ -847,7 +852,14 @@ program } const { regenerateAllArtifacts } = await import('./util/windows-launch-artifacts.js'); await regenerateAllArtifacts(); - console.log('Watchdog files regenerated with current Node.js and imcodes paths.'); + // Use the single reusable ensureDaemonRunning() that handles every + // edge case (orphan daemons, crash-loop watchdogs, stale PID files). + const { ensureDaemonRunning } = await import('./util/windows-daemon.js'); + if (ensureDaemonRunning(process.pid)) { + console.log('Watchdog files regenerated and daemon restarted.'); + } else { + console.log('Watchdog files regenerated. Daemon will start on next login (Startup shortcut).'); + } }); program.parseAsync(process.argv).catch((err: unknown) => { diff --git a/src/shared/transport/fs.ts b/src/shared/transport/fs.ts index 987346ff..15291a1c 100644 --- a/src/shared/transport/fs.ts +++ b/src/shared/transport/fs.ts @@ -15,6 +15,8 @@ export interface FsEntry { export interface GitStatusEntry { path: string; code: string; + additions?: number; + deletions?: number; } interface FsBaseResponse { diff --git a/src/util/windows-daemon.ts b/src/util/windows-daemon.ts index 693824e9..b1ee8ab1 100644 --- a/src/util/windows-daemon.ts +++ b/src/util/windows-daemon.ts @@ -1,7 +1,7 @@ import { execSync, spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { resolve } from 'node:path'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; const WINDOWS_DAEMON_TASK = 'imcodes-daemon'; @@ -29,6 +29,84 @@ function sleepMs(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } +/** Tree-kill every cmd.exe process whose command line references + * daemon-watchdog. Works on any Windows locale and any Windows version + * (Windows 10/11, Server 2016/2019/2022/2025) — newer Windows Server + * images have deprecated `wmic`, so we use PowerShell's CIM cmdlets. + * + * Why this exists: + * - Old daemon installs (pre-fix) wrote watchdog.cmd with a UTF-8 BOM. + * - cmd.exe parses [BOM]@echo as the unknown command "[BOM]@echo" and + * crash-loops forever printing the same error. + * - Restart/upgrade must KILL these zombies before laying down new + * files; otherwise the old watchdog re-spawns on the next loop tick + * and overwrites the daemon PID with a stale one. + * + * This function is best-effort: it logs nothing and swallows all errors. */ +export function killAllStaleWatchdogs(): void { + if (process.platform !== 'win32') return; + const pids = findStaleWatchdogPids(); + for (const pid of pids) { + try { execSync(`taskkill /f /t /pid ${pid}`, { stdio: 'ignore', windowsHide: true }); } catch { /* already dead */ } + } +} + +/** Enumerate cmd.exe processes whose command line references daemon-watchdog. + * Tries PowerShell via a temp .ps1 file (works on every Windows since 7), + * then falls back to wmic for legacy Windows where PowerShell is missing. + * + * CRITICAL: PowerShell command must be in a .ps1 FILE, not passed via + * `-Command "..."`. When the script contains nested double quotes + * (e.g. inside a Filter clause), cmd.exe→powershell command-line parsing + * closes the outer quote prematurely and the script becomes truncated. + * This was the root cause of the CI failure. */ +function findStaleWatchdogPids(): number[] { + const pids = new Set(); + // ── PowerShell path (works on every Windows since 7) ──────────────────── + let scriptDir: string | null = null; + try { + scriptDir = mkdtempSync(join(tmpdir(), 'imcodes-watchdog-query-')); + const scriptPath = join(scriptDir, 'find-stale.ps1'); + // Use single quotes around 'cmd.exe' inside the filter to avoid escaping + // headaches. Format-Wide -Property ProcessId so each PID is on its own + // line for easy parsing. + writeFileSync( + scriptPath, + "Get-CimInstance Win32_Process -Filter \"Name='cmd.exe'\" | " + + "Where-Object { $_.CommandLine -like '*daemon-watchdog*' } | " + + "ForEach-Object { $_.ProcessId }\r\n", + ); + const out = execSync( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, + ); + for (const line of out.split(/\r?\n/)) { + const pid = parseInt(line.trim(), 10); + if (Number.isFinite(pid) && pid > 0) pids.add(pid); + } + } catch { /* fall through to wmic */ } finally { + if (scriptDir) { + try { rmSync(scriptDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + if (pids.size > 0) return [...pids]; + // ── Legacy wmic path ──────────────────────────────────────────────────── + try { + const out = execSync( + 'wmic process where "Name=\'cmd.exe\' and CommandLine like \'%daemon-watchdog%\'" get ProcessId /format:list', + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, + ); + for (const line of out.split(/\r?\n/)) { + const m = line.match(/^ProcessId=(\d+)/); + if (m) { + const pid = parseInt(m[1], 10); + if (Number.isFinite(pid) && pid > 0) pids.add(pid); + } + } + } catch { /* both methods failed */ } + return [...pids]; +} + // ── Launcher methods (all hidden — no visible windows) ────────────────────── function tryStartVbsLauncher(): boolean { @@ -40,7 +118,7 @@ function tryStartVbsLauncher(): boolean { function tryStartScheduledTask(): boolean { try { - execSync(`schtasks /Run /TN ${WINDOWS_DAEMON_TASK}`, { stdio: 'ignore' }); + execSync(`schtasks /Run /TN ${WINDOWS_DAEMON_TASK}`, { stdio: 'ignore', windowsHide: true }); return true; } catch { return false; @@ -82,8 +160,14 @@ export function restartWindowsDaemon(currentPid?: number): boolean { if (previousPid) { // Kill the daemon process. The watchdog loop will detect the exit and // restart it automatically (within ~5 seconds). - try { execSync(`taskkill /f /pid ${previousPid}`, { stdio: 'ignore' }); } catch { /* not running */ } + try { execSync(`taskkill /f /pid ${previousPid}`, { stdio: 'ignore', windowsHide: true }); } catch { /* not running */ } } + // CRITICAL: also tree-kill any stale daemon-watchdog cmd.exe processes by + // command-line pattern. This handles the upgrade-from-bad-watchdog case + // where an OLD watchdog with a UTF-8 BOM is in a crash-loop printing + // "is not a recognized command" forever. Without this kill, the new + // watchdog we spawn below will race with the old one. + killAllStaleWatchdogs(); // If no watchdog is running (e.g. first start after bind), launch one. // Priority: VBS (always hidden) > scheduled task > startup shortcut. @@ -109,3 +193,101 @@ export function restartWindowsDaemon(currentPid?: number): boolean { } return false; } + +/** Forcefully kill any node.exe process that is listening on the imcodes + * daemon's named pipe (`\\.\pipe\imcodes-daemon-lock`). This handles the + * edge case where an orphan daemon survives `taskkill` because it was + * spawned with elevated privileges. We use `wmic process delete` (the + * one method that works against permission-denied targets in our test + * environment) and PowerShell `Stop-Process -Force` as fallback. + * + * This is the LAST RESORT before bind/restart give up. Without it, a + * user with an orphan daemon from a crashed previous session can never + * restart imcodes successfully. + * + * Returns true if at least one orphan was killed. */ +export function killOrphanDaemonProcesses(): boolean { + if (process.platform !== 'win32') return false; + let killed = false; + let scriptDir: string | null = null; + try { + // Find every node.exe process whose command line references imcodes + // (covers `node imcodes/dist/src/index.js`, the npm shim, etc.) + scriptDir = mkdtempSync(join(tmpdir(), 'imcodes-orphan-query-')); + const scriptPath = join(scriptDir, 'find-orphans.ps1'); + // CRITICAL: filter must be SPECIFIC to the daemon entry point, not just + // any process with "imcodes" in its command line. The repo working + // directory itself contains "imcodes" (C:\Users\X\imcodes-src) so a + // loose `*imcodes*` filter would kill the test runner itself. + // + // The npm-installed imcodes daemon always runs as one of: + // "C:\Program Files\nodejs\node.exe" "\node_modules\imcodes\dist\src\index.js" + // "C:\Users\\AppData\Roaming\npm\imcodes.cmd" start --foreground + // + // We match the substring "node_modules\imcodes\dist" which appears in + // both cases (the .cmd shim resolves to the dist path internally). + writeFileSync( + scriptPath, + "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | " + + "Where-Object { $_.CommandLine -like '*node_modules\\imcodes\\dist*' } | " + + "ForEach-Object { $_.ProcessId }\r\n", + ); + const out = execSync( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, + ); + const orphanPids = out + .split(/\r?\n/) + .map((line) => parseInt(line.trim(), 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid); + + for (const pid of orphanPids) { + // Try taskkill first (fast path). Always pass windowsHide so no + // console window flashes during the kill chain. + try { + execSync(`taskkill /f /pid ${pid}`, { stdio: 'ignore', windowsHide: true }); + if (!isPidAlive(pid)) { killed = true; continue; } + } catch { /* try next method */ } + // Fallback: wmic delete (works against access-denied targets in some cases) + try { + execSync(`wmic process where ProcessId=${pid} delete`, { stdio: 'ignore', windowsHide: true }); + if (!isPidAlive(pid)) { killed = true; continue; } + } catch { /* try next method */ } + // Last resort: PowerShell Stop-Process + try { + execSync( + `powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`, + { stdio: 'ignore', windowsHide: true }, + ); + if (!isPidAlive(pid)) { killed = true; } + } catch { /* gave up */ } + } + } catch { /* enumeration failed */ } finally { + if (scriptDir) { + try { rmSync(scriptDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + return killed; +} + +/** Ensure the imcodes daemon is running on Windows. + * + * This is the SINGLE entry point that bind / restart / repair-watchdog / + * upgrade should all call. It handles every edge case we've hit: + * + * 1. Stale daemon.pid file pointing at a dead process + * 2. Crash-looping watchdog from the BOM bug (killed via command-line match) + * 3. Orphan daemon holding the named-pipe lock (killed via wmic delete) + * 4. Missing watchdog files (caller should regenerate before calling this) + * 5. Multiple watchdogs racing (de-duped: kill all → spawn one) + * + * Returns true if a daemon is alive at the end. */ +export function ensureDaemonRunning(currentPid?: number): boolean { + if (process.platform !== 'win32') return false; + // Step 1: kill orphan daemons holding the named-pipe lock + killOrphanDaemonProcesses(); + // Step 2: kill any stale crash-looping watchdog cmd.exe processes + killAllStaleWatchdogs(); + // Step 3: spawn a fresh hidden watchdog (VBS > schtask > startup shortcut) + return restartWindowsDaemon(currentPid); +} diff --git a/src/util/windows-launch-artifacts.ts b/src/util/windows-launch-artifacts.ts index ce52d475..603e0dda 100644 --- a/src/util/windows-launch-artifacts.ts +++ b/src/util/windows-launch-artifacts.ts @@ -1,9 +1,9 @@ import { writeFile, mkdir, stat, truncate } from 'fs/promises'; -import { existsSync } from 'fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { homedir } from 'os'; +import { homedir, tmpdir } from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -35,42 +35,66 @@ export function resolveLaunchPaths(): LaunchPaths { } /** Write the daemon-watchdog.cmd that loops and restarts the daemon. - * Uses the npm global shim (`imcodes.cmd`) instead of hard-coding - * node.exe + script paths — this way the watchdog always launches - * whatever version is currently installed, even after npm upgrades. */ + * + * IMPORTANT: this file is parsed by cmd.exe. Two non-obvious rules: + * + * 1. cmd.exe does NOT understand a UTF-8 BOM at the start of a .cmd file. + * The BOM bytes (EF BB BF) are treated as part of the first command, + * so `[BOM]@echo off` becomes the unknown command "[BOM]@echo". + * => never write a BOM here. + * + * 2. To support non-ASCII paths (e.g. usernames with Chinese characters) + * we never hard-code absolute paths. Instead we use environment-variable + * expansion (%USERPROFILE%, %APPDATA%) which cmd.exe resolves at runtime + * using the OS's native wide-character API — no encoding round-trip. + * + * 3. Uses the npm global shim (`imcodes.cmd`) by default so the watchdog + * always launches whatever version is currently installed, even after + * npm upgrades. Falls back to direct node+script for dev installs + * where the shim isn't on %APPDATA%\npm. */ export async function writeWatchdogCmd(paths: LaunchPaths): Promise { await mkdir(dirname(paths.watchdogPath), { recursive: true }); - // Resolve the npm global shim path (e.g. C:\Users\X\AppData\Roaming\npm\imcodes.cmd) + // Detect whether the npm global shim exists. When yes, the watchdog can + // use the parameter-free env-var path; when no (e.g. tests, dev installs) + // we fall back to a direct node+script invocation with absolute paths. const npmGlobalBin = dirname(paths.imcodesScript).replace(/[/\\]node_modules[/\\]imcodes[/\\]dist[/\\]src$/i, ''); const shimPath = join(npmGlobalBin, 'imcodes.cmd'); - // Prefer the shim if it exists; fall back to direct node+script for dev setups - const launchCmd = existsSync(shimPath) - ? `"${shimPath}" start --foreground` - : `"${paths.nodeExe}" "${paths.imcodesScript}" start --foreground`; - const lockFile = UPGRADE_LOCK_FILE.replace(/\//g, '\\'); + const useShim = existsSync(shimPath); + + // Build the launch line. Either form gets prefixed with `call ` so cmd.exe + // returns to the loop after the daemon exits (without `call`, control would + // hand off to the .cmd shim and never come back). + const launchCmd = useShim + ? `call "%APPDATA%\\npm\\imcodes.cmd" start --foreground` + : `call "${paths.nodeExe}" "${paths.imcodesScript}" start --foreground`; + const watchdog = [ '@echo off', 'chcp 65001 >nul 2>&1', ':loop', - `if exist "${lockFile}" (`, - ` echo Upgrade in progress, waiting... >> "${paths.logPath}"`, + 'if exist "%USERPROFILE%\\.imcodes\\upgrade.lock" (', + ' echo Upgrade in progress, waiting... >> "%USERPROFILE%\\.imcodes\\watchdog.log"', ' timeout /t 5 /nobreak >nul', ' goto loop', ')', - `${launchCmd} >> "${paths.logPath}" 2>&1`, + `${launchCmd} >> "%USERPROFILE%\\.imcodes\\watchdog.log" 2>&1`, 'timeout /t 5 /nobreak >nul', 'goto loop', '', ].join('\r\n'); - // Write as UTF-8 + BOM so cmd.exe parses non-ASCII paths correctly - // (e.g. usernames with Chinese characters). Without the BOM cmd.exe - // reads the file using the system codepage and mangles UTF-8 bytes. - await writeFile(paths.watchdogPath, encodeCmdAsUtf8Bom(watchdog)); + + // CRITICAL: write as plain UTF-8 with NO BOM. cmd.exe does not understand + // BOMs in batch files — the BOM bytes get prepended to the first command + // and cmd reports "[BOM]@echo is not a recognized command". + await writeFile(paths.watchdogPath, watchdog, 'utf8'); } -/** Encode a .cmd file as UTF-8 with BOM so cmd.exe handles non-ASCII paths. */ +/** @deprecated cmd.exe does not understand UTF-8 BOMs in batch files; the BOM + * bytes break the very first command of the script. Kept here only so + * upgrade scripts that already imported it continue to compile — they should + * switch to plain `writeFile(..., 'utf8')` instead. */ export function encodeCmdAsUtf8Bom(content: string): Buffer { - return Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(content, 'utf8')]); + return Buffer.from(content, 'utf8'); } /** Write the daemon-launcher.vbs that starts the watchdog CMD hidden. @@ -103,7 +127,7 @@ export function updateSchtasks(paths: LaunchPaths): boolean { 'schtasks', '/Change', '/TN', TASK_NAME, '/TR', `wscript "${paths.vbsPath}"`, - ].join(' '), { stdio: 'ignore' }); + ].join(' '), { stdio: 'ignore', windowsHide: true }); return true; } catch { return false; @@ -122,11 +146,72 @@ export async function rotateWatchdogLog(paths: LaunchPaths): Promise { } } -/** Regenerate all Windows daemon launch artifacts with current paths. */ +/** Regenerate all Windows daemon launch artifacts with current paths. + * + * Tree-kills any existing daemon-watchdog cmd.exe processes BEFORE writing + * the new files. This is critical when the user is recovering from a + * crash-loop caused by an OLD watchdog file with a UTF-8 BOM (cmd.exe + * parses [BOM]@echo as the unknown command "[BOM]@echo" forever). + * Without the kill, the old watchdog still has the bad file mapped and + * will overwrite our PID file with stale data. + * + * Process matching is by command-line pattern via wmic, which is + * language-independent — works on en-US, zh-CN, ja-JP and any other Windows + * locale. */ export async function regenerateAllArtifacts(): Promise { + killAllStaleWatchdogsBeforeRegen(); const paths = resolveLaunchPaths(); await writeWatchdogCmd(paths); await writeVbsLauncher(paths); updateSchtasks(paths); await rotateWatchdogLog(paths); } + +function killAllStaleWatchdogsBeforeRegen(): void { + if (process.platform !== 'win32') return; + // PowerShell first (works on every Windows including ones where wmic is gone) + // CRITICAL: use a temp .ps1 file, NOT `-Command "..."` — nested double + // quotes inside the script body get truncated by cmd.exe→powershell + // command-line parsing. See windows-daemon.ts findStaleWatchdogPids. + let pids: number[] = []; + let scriptDir: string | null = null; + try { + scriptDir = mkdtempSync(join(tmpdir(), 'imcodes-watchdog-regen-')); + const scriptPath = join(scriptDir, 'find-stale.ps1'); + writeFileSync( + scriptPath, + "Get-CimInstance Win32_Process -Filter \"Name='cmd.exe'\" | " + + "Where-Object { $_.CommandLine -like '*daemon-watchdog*' } | " + + "ForEach-Object { $_.ProcessId }\r\n", + ); + const out = execSync( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, + ); + for (const line of out.split(/\r?\n/)) { + const pid = parseInt(line.trim(), 10); + if (Number.isFinite(pid) && pid > 0) pids.push(pid); + } + } catch { /* fall through */ } finally { + if (scriptDir) { + try { rmSync(scriptDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + if (pids.length === 0) { + try { + const out = execSync( + 'wmic process where "Name=\'cmd.exe\' and CommandLine like \'%daemon-watchdog%\'" get ProcessId /format:list', + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, + ); + pids = out + .split(/\r?\n/) + .map((line) => line.match(/^ProcessId=(\d+)/)) + .filter((m): m is RegExpMatchArray => m !== null) + .map((m) => parseInt(m[1], 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0); + } catch { /* both methods failed */ } + } + for (const pid of pids) { + try { execSync(`taskkill /f /t /pid ${pid}`, { stdio: 'ignore', windowsHide: true }); } catch { /* already dead */ } + } +} diff --git a/src/util/windows-upgrade-script.ts b/src/util/windows-upgrade-script.ts index e5104ed0..31db6829 100644 --- a/src/util/windows-upgrade-script.ts +++ b/src/util/windows-upgrade-script.ts @@ -60,23 +60,62 @@ echo upgrade > "%UPGRADE_LOCK%"\r echo Upgrade lock created >> "%LOG_FILE%"\r \r rem ── Kill daemon + old watchdog so npm can overwrite files cleanly ─────\r -rem Old watchdog versions don't know about the lock file, so we must kill\r -rem them. After npm install, repair-watchdog generates a new watchdog\r -rem that respects the lock.\r +rem Three failure modes we must handle:\r +rem 1. Healthy daemon — kill PIDFILE and parent watchdog tree\r +rem 2. Daemon crashed but watchdog still spamming an error in a tight\r +rem loop because the OLD watchdog.cmd had a UTF-8 BOM and/or no\r +rem 'call' prefix (cmd.exe quoted-command parse rule). In this case\r +rem there is NO daemon.pid but the watchdog cmd.exe processes are\r +rem still running. We must find them by command-line pattern.\r +rem 3. Multiple watchdog instances (race after past upgrades)\r +rem Tree-kill EVERY cmd.exe whose command line references daemon-watchdog.\r +rem This catches both the healthy case and the crash-loop case.\r +echo Killing all daemon-watchdog cmd.exe processes... >> "%LOG_FILE%"\r +rem Try PowerShell first (works on Windows 11 / Server 2025 where wmic is\r +rem deprecated/removed), fall back to wmic for legacy Windows installs.\r +rem CRITICAL: write the PowerShell script to a .ps1 file rather than passing\r +rem it via -Command "..." — nested double quotes inside a -Command argument\r +rem get truncated by cmd.exe→powershell command-line parsing.\r +set "PS_SCRIPT=%SCRIPT_DIR%\\find-stale-watchdog.ps1"\r +> "%PS_SCRIPT%" echo Get-CimInstance Win32_Process -Filter "Name='cmd.exe'" ^| Where-Object { $_.CommandLine -like '*daemon-watchdog*' } ^| ForEach-Object { $_.ProcessId }\r +for /f "usebackq delims=" %%w in (\`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%PS_SCRIPT%" 2^>nul\`) do (\r + set "STALE_WD=%%w"\r + set "STALE_WD=!STALE_WD: =!"\r + if defined STALE_WD if not "!STALE_WD!"=="" (\r + echo tree-killing watchdog PID !STALE_WD! ^(via powershell^) >> "%LOG_FILE%"\r + taskkill /f /t /pid !STALE_WD! >nul 2>&1\r + )\r +)\r +rem Fallback for systems where powershell returns nothing (or is unavailable).\r +for /f "tokens=2 delims==" %%w in ('wmic process where "Name='cmd.exe' and CommandLine like '%%daemon-watchdog%%'" get ProcessId /format:list 2^>nul ^| find "="') do (\r + set "STALE_WD=%%w"\r + set "STALE_WD=!STALE_WD: =!"\r + if defined STALE_WD if not "!STALE_WD!"=="" (\r + echo tree-killing watchdog PID !STALE_WD! ^(via wmic^) >> "%LOG_FILE%"\r + taskkill /f /t /pid !STALE_WD! >nul 2>&1\r + )\r +)\r +rem Also kill the daemon directly if PIDFILE has a fresh value.\r set "PIDFILE=%USERPROFILE%\\.imcodes\\daemon.pid"\r if exist "%PIDFILE%" (\r set /p OLD_PID=<"%PIDFILE%"\r - echo Stopping daemon PID !OLD_PID! and old watchdog... >> "%LOG_FILE%"\r - rem Find watchdog (parent of daemon) and tree-kill it\r - for /f "tokens=2 delims==" %%a in ('wmic process where "ProcessId=!OLD_PID!" get ParentProcessId /format:list 2^>nul ^| find "="') do (\r - set "WD_PID=%%a"\r + if defined OLD_PID if not "!OLD_PID!"=="" (\r + echo Stopping daemon PID !OLD_PID!... >> "%LOG_FILE%"\r + taskkill /f /pid !OLD_PID! >nul 2>&1\r )\r - if defined WD_PID (\r - taskkill /f /t /pid !WD_PID! >nul 2>&1\r - )\r - taskkill /f /pid !OLD_PID! >nul 2>&1\r - timeout /t 2 /nobreak >nul\r )\r +rem Belt-and-suspenders: if the watchdog file itself has a BOM (the bug we\r +rem just fixed), the new repair-watchdog step below will overwrite it with\r +rem clean bytes. Until then, prevent the freshly-killed watchdog from being\r +rem respawned by anyone (e.g. a scheduled task) by leaving the lock in place.\r +timeout /t 2 /nobreak >nul\r +\r +if defined NODE_OPTIONS (\r + set "NODE_OPTIONS=%NODE_OPTIONS% --max-old-space-size=16384"\r +) else (\r + set "NODE_OPTIONS=--max-old-space-size=16384"\r +)\r +echo Using NODE_OPTIONS=%NODE_OPTIONS% >> "%LOG_FILE%"\r \r echo Installing ${pkgSpec}... >> "%LOG_FILE%"\r call "${npmCmd}" install -g ${pkgSpec} >> "%LOG_FILE%" 2>&1\r diff --git a/test/agent/claude-code-sdk-provider.test.ts b/test/agent/claude-code-sdk-provider.test.ts index cfd13f4b..a9daf58a 100644 --- a/test/agent/claude-code-sdk-provider.test.ts +++ b/test/agent/claude-code-sdk-provider.test.ts @@ -261,6 +261,29 @@ describe('ClaudeCodeSdkProvider', () => { }); }); + it('passes runtime-only system prompts without polluting description', async () => { + sdkMock.setNextMessages([ + { type: 'system', subtype: 'init', session_id: 'session-system', model: 'claude-sonnet-4-6' }, + { type: 'result', session_id: 'session-system', subtype: 'success', is_error: false, result: 'OK', usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 } }, + ]); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ + sessionKey: 'route-system', + cwd: '/tmp/project', + resumeId: 'session-system', + description: 'Visible description', + systemPrompt: 'Runtime note only', + }); + + await provider.send('route-system', 'hello'); + await flush(); + + const run = sdkMock.runs.at(-1)!; + expect(run.options.appendSystemPrompt).toBe('Visible description\n\nRuntime note only'); + }); + it('emits a fallback streaming delta from assistant text when the SDK does not send text_delta events', async () => { sdkMock.setNextMessages([ { type: 'system', subtype: 'init', session_id: 'session-fallback', model: 'claude-sonnet-4-6' }, @@ -388,6 +411,52 @@ describe('ClaudeCodeSdkProvider', () => { const run = sdkMock.runs.at(-1)!; expect(run.options.effort).toBe('high'); }); + + it('emits compacting status updates from Claude SDK system status messages', async () => { + sdkMock.setNextMessages([ + { type: 'system', subtype: 'init', session_id: 'session-status', model: 'claude-sonnet-4-6' }, + { type: 'system', subtype: 'status', session_id: 'session-status', status: 'compacting' }, + { type: 'system', subtype: 'status', session_id: 'session-status', status: null }, + { type: 'result', session_id: 'session-status', subtype: 'success', is_error: false, result: 'OK', usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 } }, + ]); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ sessionKey: 'route-status', cwd: '/tmp/project', resumeId: 'session-status' }); + + const statuses: Array<{ status: string | null; label?: string | null }> = []; + provider.onStatus?.((_sid, status) => statuses.push(status)); + + await provider.send('route-status', 'hello'); + await flush(); + + expect(statuses).toEqual([ + { status: 'compacting', label: 'Compacting conversation...' }, + { status: null, label: null }, + ]); + }); + + it('emits compacting status from compact boundary system messages', async () => { + sdkMock.setNextMessages([ + { type: 'system', subtype: 'init', session_id: 'session-boundary', model: 'claude-sonnet-4-6' }, + { type: 'system', subtype: 'compact_boundary', session_id: 'session-boundary' }, + { type: 'result', session_id: 'session-boundary', subtype: 'success', is_error: false, result: 'OK', usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 } }, + ]); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ sessionKey: 'route-boundary', cwd: '/tmp/project', resumeId: 'session-boundary' }); + + const statuses: Array<{ status: string | null; label?: string | null }> = []; + provider.onStatus?.((_sid, status) => statuses.push(status)); + + await provider.send('route-boundary', 'hello'); + await flush(); + + expect(statuses).toEqual([ + { status: 'compacting', label: 'Compacting conversation...' }, + ]); + }); }); type ToolEventSnapshot = { name: string; status: string; input: unknown; output?: unknown; detail?: unknown }; diff --git a/test/agent/codex-sdk-provider.test.ts b/test/agent/codex-sdk-provider.test.ts index b4a8ff0c..1bbc2fd8 100644 --- a/test/agent/codex-sdk-provider.test.ts +++ b/test/agent/codex-sdk-provider.test.ts @@ -309,4 +309,28 @@ describe('CodexSdkProvider', () => { const turnStartReq = child.requests.find((req) => req.method === 'turn/start'); expect(turnStartReq?.params?.effort).toBe('high'); }); + + it('emits thinking status from reasoning items and clears it on streamed assistant text', async () => { + const provider = new CodexSdkProvider(); + await provider.connect({ binaryPath: 'codex' }); + await provider.createSession({ sessionKey: 'route-status', cwd: '/tmp/project' }); + + const statuses: Array<{ status: string | null; label?: string | null }> = []; + provider.onStatus?.((_sid, status) => statuses.push(status)); + + await provider.send('route-status', 'hello'); + const child = childProcessMock.children[0]; + child.emits({ + method: 'item/started', + params: { threadId: 'thread-1', turnId: 'turn-1', item: { id: 'reason-1', type: 'reasoning', text: 'Planning next step' } }, + }); + child.emits({ method: 'item/agentMessage/delta', params: { threadId: 'thread-1', turnId: 'turn-1', itemId: 'msg-1', delta: 'O' } }); + child.emits({ method: 'turn/completed', params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed', error: null } } }); + await flush(); + + expect(statuses).toEqual([ + { status: 'thinking', label: 'Thinking...' }, + { status: null, label: null }, + ]); + }); }); diff --git a/test/agent/qwen-provider.test.ts b/test/agent/qwen-provider.test.ts index e7af72c4..f066add2 100644 --- a/test/agent/qwen-provider.test.ts +++ b/test/agent/qwen-provider.test.ts @@ -139,6 +139,51 @@ describe('QwenProvider', () => { }); }); + it('merges provided qwen settings with reasoning settings', async () => { + const provider = new QwenProvider(); + await provider.connect({}); + await provider.createSession({ + sessionKey: 'sess-preset', + cwd: '/tmp/project', + effort: 'high', + agentId: 'MiniMax-M2.7', + settings: { + security: { auth: { selectedType: 'anthropic' } }, + model: { name: 'MiniMax-M2.7' }, + modelProviders: { + anthropic: [ + { + id: 'MiniMax-M2.7', + envKey: 'ANTHROPIC_API_KEY', + baseUrl: 'https://api.minimax.io/anthropic', + }, + ], + }, + }, + }); + + await provider.send('sess-preset', 'hello'); + const first = lastSpawn(); + const settingsPath = first.env?.QWEN_CODE_SYSTEM_SETTINGS_PATH; + expect(typeof settingsPath).toBe('string'); + expect(JSON.parse(await readFile(String(settingsPath), 'utf8'))).toEqual({ + security: { auth: { selectedType: 'anthropic' } }, + model: { + name: 'MiniMax-M2.7', + generationConfig: { reasoning: { effort: 'high' } }, + }, + modelProviders: { + anthropic: [ + { + id: 'MiniMax-M2.7', + envKey: 'ANTHROPIC_API_KEY', + baseUrl: 'https://api.minimax.io/anthropic', + }, + ], + }, + }); + }); + it('uses --session-id on first send, streams cumulative deltas, then resumes with --resume', async () => { const provider = new QwenProvider(); await provider.connect({}); @@ -185,6 +230,51 @@ describe('QwenProvider', () => { expect(second.args).not.toContain('--session-id'); }); + it('falls back to a fresh session when --resume points to a missing qwen conversation id', async () => { + const provider = new QwenProvider(); + await provider.connect({}); + await provider.createSession({ + sessionKey: 'sess-resume-fallback', + cwd: '/tmp/project', + }); + + const completed: string[] = []; + const resumeIds: string[] = []; + provider.onComplete((_sid, msg) => completed.push(String(msg.content))); + provider.onSessionInfo?.((_sid, info) => { + if (typeof info.resumeId === 'string') resumeIds.push(info.resumeId); + }); + + await provider.send('sess-resume-fallback', 'hello'); + const first = lastSpawn(); + first.child.stdout.write(`${JSON.stringify({ type: 'system', subtype: 'session_start', session_id: 'sess-resume-fallback' })}\n`); + first.child.stdout.write(`${JSON.stringify({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'Hello' }] } })}\n`); + first.child.emit('close', 0, null); + await flushIO(); + + await provider.send('sess-resume-fallback', 'again'); + const second = childProcessMock.spawned[1]; + expect(second?.args).toContain('--resume'); + second?.child.stderr.write('No saved session found with ID sess-resume-fallback\n'); + second?.child.emit('close', 1, null); + await waitForSpawnCount(3); + + const third = childProcessMock.spawned[2]; + expect(third?.args).toContain('--session-id'); + expect(third?.args).not.toContain('--resume'); + const sessionIdIndex = third?.args.indexOf('--session-id') ?? -1; + expect(sessionIdIndex).toBeGreaterThanOrEqual(0); + expect(third?.args[sessionIdIndex + 1]).not.toBe('sess-resume-fallback'); + expect(resumeIds).toContain(third?.args[sessionIdIndex + 1]); + + third?.child.stdout.write(`${JSON.stringify({ type: 'system', subtype: 'session_start', session_id: third?.args[sessionIdIndex + 1] })}\n`); + third?.child.stdout.write(`${JSON.stringify({ type: 'assistant', message: { id: 'msg-2', content: [{ type: 'text', text: 'Recovered' }] } })}\n`); + third?.child.emit('close', 0, null); + await flushIO(); + + expect(completed).toContain('Recovered'); + }); + it('normalizes Windows cwd before spawning qwen', async () => { const origPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32' }); @@ -404,4 +494,28 @@ describe('QwenProvider', () => { }, ]); }); + + it('emits thinking status from qwen thinking blocks and clears it on text output', async () => { + const provider = new QwenProvider(); + await provider.connect({}); + await provider.createSession({ sessionKey: 'sess-thinking', cwd: '/tmp/project' }); + + const statuses: Array<{ status: string | null; label?: string | null }> = []; + provider.onStatus?.((_sid, status) => statuses.push(status)); + + await provider.send('sess-thinking', 'think'); + const run = lastSpawn(); + run.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-thinking' } } })}\n`); + run.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'thinking' } } })}\n`); + run.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Analyzing...' } } })}\n`); + run.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'Done' } } })}\n`); + run.child.emit('close', 0, null); + await flushIO(); + + expect(statuses).toEqual([ + { status: null, label: null }, + { status: 'thinking', label: 'Thinking...' }, + { status: null, label: null }, + ]); + }); }); diff --git a/test/daemon/cc-presets.test.ts b/test/daemon/cc-presets.test.ts index 3ad82cea..dc19f102 100644 --- a/test/daemon/cc-presets.test.ts +++ b/test/daemon/cc-presets.test.ts @@ -55,6 +55,7 @@ describe('cc presets', () => { await expect(resolvePresetEnv('MiniMax')).resolves.toMatchObject({ ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_API_KEY: 'test-token', ANTHROPIC_MODEL: 'MiniMax-M2.7', ANTHROPIC_SMALL_FAST_MODEL: 'MiniMax-M2.7', ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.7', @@ -63,4 +64,34 @@ describe('cc presets', () => { IMCODES_CONTEXT_WINDOW: '200000', }); }); + + it('builds qwen transport config for anthropic-compatible presets', async () => { + const { getQwenPresetTransportConfig } = await import('../../src/daemon/cc-presets.js'); + + await expect(getQwenPresetTransportConfig('MiniMax')).resolves.toEqual({ + env: { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: 'test-token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }, + model: 'MiniMax-M2.7', + settings: { + security: { auth: { selectedType: 'anthropic' } }, + model: { name: 'MiniMax-M2.7' }, + modelProviders: { + anthropic: [ + { + id: 'MiniMax-M2.7', + name: 'minimax', + envKey: 'ANTHROPIC_API_KEY', + baseUrl: 'https://api.minimax.io/anthropic', + generationConfig: { + contextWindowSize: 200000, + }, + }, + ], + }, + }, + }); + }); }); diff --git a/test/daemon/command-handler-transport-queue.test.ts b/test/daemon/command-handler-transport-queue.test.ts index 53d0d07b..c0564284 100644 --- a/test/daemon/command-handler-transport-queue.test.ts +++ b/test/daemon/command-handler-transport-queue.test.ts @@ -178,6 +178,31 @@ describe('handleWebCommand transport queue behavior', () => { ); }); + it('does not short-circuit transport identity questions in the daemon', async () => { + const transportSend = vi.fn(() => 'sent'); + getTransportRuntimeMock.mockReturnValue({ + send: transportSend, + pendingCount: 0, + }); + + handleWebCommand({ + type: 'session.send', + session: 'deck_transport_brain', + text: '你在用什么模型', + commandId: 'cmd-identity', + }, serverLink as any); + await flushAsync(); + + expect(transportSend).toHaveBeenCalledWith('你在用什么模型'); + expect(emitMock).toHaveBeenCalledWith('deck_transport_brain', 'user.message', { text: '你在用什么模型', allowDuplicate: true }); + expect(emitMock).not.toHaveBeenCalledWith( + 'deck_transport_brain', + 'assistant.text', + expect.objectContaining({ text: expect.any(String), streaming: false }), + expect.anything(), + ); + }); + it('waits for an in-flight settings restart before sending the first transport message', async () => { let restartResolved = false; let resolveRestart: (() => void) | null = null; diff --git a/test/daemon/cron-executor.test.ts b/test/daemon/cron-executor.test.ts index c0bfe1a0..c6102150 100644 --- a/test/daemon/cron-executor.test.ts +++ b/test/daemon/cron-executor.test.ts @@ -286,17 +286,18 @@ describe('executeCronJob', () => { await executeCronJob(msg, mockServerLink); - expect(startP2pRun).toHaveBeenCalledWith( - 'deck_myapp_brain', - [ + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + expect(startP2pRun).toHaveBeenCalledWith(expect.objectContaining({ + initiatorSession: 'deck_myapp_brain', + targets: [ { session: 'deck_myapp_w1', mode: 'audit' }, { session: 'deck_myapp_w2', mode: 'audit' }, ], - 'code review', - [], - mockServerLink, - 3, - ); + userText: 'code review', + fileContents: [], + serverLink: mockServerLink, + rounds: 3, + })); }); // 14. P2P with no valid participants — skips @@ -362,14 +363,15 @@ describe('executeCronJob', () => { await executeCronJob(msg, mockServerLink); - expect(startP2pRun).toHaveBeenCalledWith( - 'deck_myapp_brain', - [{ session: 'deck_myapp_w1', mode: 'review' }], - 'quick sync', - [], - mockServerLink, - 1, - ); + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + expect(startP2pRun).toHaveBeenCalledWith(expect.objectContaining({ + initiatorSession: 'deck_myapp_brain', + targets: [{ session: 'deck_myapp_w1', mode: 'review' }], + userText: 'quick sync', + fileContents: [], + serverLink: mockServerLink, + rounds: 1, + })); }); it('logs warning for unknown action type', async () => { @@ -453,14 +455,15 @@ describe('executeCronJob', () => { await executeCronJob(msg, mockServerLink); - expect(startP2pRun).toHaveBeenCalledWith( - 'deck_myapp_brain', - [{ session: 'deck_sub_worker1', mode: 'review' }], - 'architecture review', - [], - mockServerLink, - 1, - ); + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + expect(startP2pRun).toHaveBeenCalledWith(expect.objectContaining({ + initiatorSession: 'deck_myapp_brain', + targets: [{ session: 'deck_sub_worker1', mode: 'review' }], + userText: 'architecture review', + fileContents: [], + serverLink: mockServerLink, + rounds: 1, + })); }); it('sends command result with executionId when assistant output completes', async () => { diff --git a/test/daemon/fs-git-cache.test.ts b/test/daemon/fs-git-cache.test.ts new file mode 100644 index 00000000..106cec32 --- /dev/null +++ b/test/daemon/fs-git-cache.test.ts @@ -0,0 +1,558 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'node:path'; +import { homedir } from 'node:os'; +import * as fsp from 'node:fs/promises'; +import * as childProcess from 'node:child_process'; + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readdir: vi.fn(), + realpath: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + copyFile: vi.fn(), + }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + const exec = vi.fn(); + const execFile = vi.fn(); + (exec as any)[Symbol.for('nodejs.util.promisify.custom')] = (command: string, options?: unknown) => new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + exec(command, options, (err: Error | null, stdout = '', stderr = '') => { + if (err) { + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); + (execFile as any)[Symbol.for('nodejs.util.promisify.custom')] = (file: string, args: string[], options?: unknown) => new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile(file, args, options, (err: Error | null, stdout = '', stderr = '') => { + if (err) { + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); + return { + ...actual, + exec, + execFile, + }; +}); + +import { handleWebCommand, __resetFsGitCachesForTests } from '../../src/daemon/command-handler.js'; + +const mockRealpath = vi.mocked(fsp.realpath); +const mockReadFile = vi.mocked(fsp.readFile); +const mockStat = vi.mocked(fsp.stat); +const mockWriteFile = vi.mocked(fsp.writeFile); +const mockExec = vi.mocked(childProcess.exec); +const mockExecFile = vi.mocked(childProcess.execFile); + +const sent: unknown[] = []; +const mockServerLink = { + send: vi.fn((msg: unknown) => { sent.push(msg); }), + sendBinary: vi.fn(), +}; + +const flushAsync = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeStats(kind: 'file' | 'dir', mtimeMs: number, size = 0) { + return { + mtimeMs, + size, + isDirectory: () => kind === 'dir', + isFile: () => kind === 'file', + } as unknown as fsp.Stats; +} + +function setupRepoMocks(repoRoot: string, filePath?: string) { + const gitDir = path.join(repoRoot, '.git'); + const headPath = path.join(gitDir, 'HEAD'); + const refPath = path.join(gitDir, 'refs', 'heads', 'main'); + const indexPath = path.join(gitDir, 'index'); + + mockRealpath.mockImplementation(async (target) => String(target)); + mockReadFile.mockImplementation(async (target) => { + if (String(target) === headPath) return 'ref: refs/heads/main\n' as any; + return '' as any; + }); + mockStat.mockImplementation(async (target) => { + const normalized = String(target); + if (normalized === gitDir) return makeStats('dir', 10); + if (normalized === headPath) return makeStats('file', 11, 20); + if (normalized === refPath) return makeStats('file', 12, 21); + if (normalized === indexPath) return makeStats('file', 13, 22); + if (filePath && normalized === filePath) return makeStats('file', 14, 30); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); +} + +describe('fs git cache handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + sent.length = 0; + mockServerLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + __resetFsGitCachesForTests(); + }); + + it('single-flights repo status requests and reuses cached numstat data', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') { + callback = options; + } + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M src/a.ts\0?? new.txt\0', ''); + return {} as any; + } + if (command === 'git diff --numstat -z HEAD') { + callback(null, ['3\t1\tsrc/a.ts', '5\t0\tnew.txt', ''].join('\0'), ''); + return {} as any; + } + callback(new Error(`unexpected command: ${String(command)}`), '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'req-1', includeStats: true }, mockServerLink as any); + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'req-2', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect(mockExec).toHaveBeenCalledTimes(2); + expect(mockExec.mock.calls[0]?.[0]).toBe('git status --porcelain=v1 -z -u'); + expect(mockExec.mock.calls[1]?.[0]).toBe('git diff --numstat -z HEAD'); + expect(sent).toHaveLength(2); + expect((sent[0] as any).files).toEqual([ + { path: '/home/k/project/src/a.ts', code: 'M', additions: 3, deletions: 1 }, + { path: '/home/k/project/new.txt', code: '??', additions: 5, deletions: 0 }, + ]); + expect((sent[1] as any).files).toEqual((sent[0] as any).files); + }); + + it('skips numstat work when git status is requested without stats', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') { + callback = options; + } + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M src/a.ts\0', ''); + return {} as any; + } + callback(new Error(`unexpected command: ${String(command)}`), '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'req-plain' }, mockServerLink as any); + await flushAsync(); + + expect(mockExec).toHaveBeenCalledTimes(1); + expect(mockExec.mock.calls[0]?.[0]).toBe('git status --porcelain=v1 -z -u'); + expect((sent[0] as any).files).toEqual([ + { path: '/home/k/project/src/a.ts', code: 'M' }, + ]); + }); + + it('caches file diffs by file signature and repo signature', async () => { + const repoRoot = '/home/k/project'; + const filePath = '/home/k/project/foo.ts'; + setupRepoMocks(repoRoot, filePath); + mockExecFile.mockImplementation((file: any, args: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (file === 'git' && Array.isArray(args) && args[0] === 'diff' && args[1] === 'HEAD') { + callback(null, '+const x = 1;\n', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-1' }, mockServerLink as any); + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-2' }, mockServerLink as any); + await flushAsync(); + + expect(mockExecFile).toHaveBeenCalledTimes(1); + expect(mockExecFile.mock.calls[0]?.[0]).toBe('git'); + expect(mockExecFile.mock.calls[0]?.[1]).toEqual(['diff', 'HEAD', '--', 'foo.ts']); + expect((sent[0] as any).diff).toBe('+const x = 1;\n'); + expect((sent[1] as any).diff).toBe('+const x = 1;\n'); + }); + + it('starts fresh fs.read work when file freshness changes and stale late completion cannot replace the active cache', async () => { + const filePath = '/home/k/project/notes.md'; + const first = createDeferred(); + let currentFileStats = makeStats('file', 14, 30); + let readCount = 0; + + mockRealpath.mockImplementation(async (target) => String(target)); + mockStat.mockImplementation(async (target) => { + if (String(target) === filePath) return currentFileStats; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReadFile.mockImplementation(async (target) => { + if (String(target) !== filePath) return '' as any; + readCount += 1; + if (readCount === 1) return await first.promise as any; + return 'fresh content' as any; + }); + + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'read-1' }, mockServerLink as any); + await flushAsync(); + expect(readCount).toBe(1); + + currentFileStats = makeStats('file', 20, 45); + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'read-2' }, mockServerLink as any); + await flushAsync(); + expect(readCount).toBe(2); + expect((sent.find((msg: any) => msg.requestId === 'read-2') as any)?.content).toBe('fresh content'); + + first.resolve('stale content'); + await flushAsync(); + + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'read-3' }, mockServerLink as any); + await flushAsync(); + + expect(readCount).toBe(2); + expect((sent.find((msg: any) => msg.requestId === 'read-3') as any)?.content).toBe('fresh content'); + }); + + it('starts fresh diff work after file freshness changes and ignores stale late completions', async () => { + const repoRoot = '/home/k/project'; + const filePath = '/home/k/project/foo.ts'; + setupRepoMocks(repoRoot, filePath); + const first = createDeferred<{ stdout: string; stderr: string }>(); + + mockExecFile.mockImplementation((file: any, args: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (file === 'git' && Array.isArray(args) && args[0] === 'diff' && args[1] === 'HEAD') { + const callIndex = mockExecFile.mock.calls.filter((call) => call[0] === 'git' && Array.isArray(call[1]) && call[1][0] === 'diff' && call[1][1] === 'HEAD').length; + if (callIndex === 1) { + void first.promise.then( + ({ stdout, stderr }) => callback(null, stdout, stderr), + (err) => callback(err, '', ''), + ); + return {} as any; + } + callback(null, '+new diff\n', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-1' }, mockServerLink as any); + await flushAsync(); + expect(mockExecFile).toHaveBeenCalledTimes(1); + + mockStat.mockImplementation(async (target) => { + const normalized = String(target); + if (normalized === path.join(repoRoot, '.git')) return makeStats('dir', 10); + if (normalized === path.join(repoRoot, '.git', 'HEAD')) return makeStats('file', 11, 20); + if (normalized === path.join(repoRoot, '.git', 'refs', 'heads', 'main')) return makeStats('file', 12, 21); + if (normalized === path.join(repoRoot, '.git', 'index')) return makeStats('file', 13, 22); + if (normalized === filePath) return makeStats('file', 20, 99); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-2' }, mockServerLink as any); + await flushAsync(); + expect(mockExecFile).toHaveBeenCalledTimes(2); + expect((sent.find((msg: any) => msg.requestId === 'diff-2') as any)?.diff).toBe('+new diff\n'); + + first.resolve({ stdout: '+old diff\n', stderr: '' }); + await flushAsync(); + expect((sent.find((msg: any) => msg.requestId === 'diff-1') as any)?.diff).toBe('+old diff\n'); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-3' }, mockServerLink as any); + await flushAsync(); + expect(mockExecFile).toHaveBeenCalledTimes(2); + expect((sent.find((msg: any) => msg.requestId === 'diff-3') as any)?.diff).toBe('+new diff\n'); + }); + + it('starts fresh repo status work after repo freshness changes', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + const first = createDeferred<{ stdout: string; stderr: string }>(); + + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (command === 'git status --porcelain=v1 -z -u') { + const callIndex = mockExec.mock.calls.filter((call) => call[0] === 'git status --porcelain=v1 -z -u').length; + if (callIndex === 1) { + void first.promise.then( + ({ stdout, stderr }) => callback(null, stdout, stderr), + (err) => callback(err, '', ''), + ); + return {} as any; + } + callback(null, 'M src/b.ts\0', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-1' }, mockServerLink as any); + await flushAsync(); + expect(mockExec).toHaveBeenCalledTimes(1); + + mockStat.mockImplementation(async (target) => { + const normalized = String(target); + if (normalized === path.join(repoRoot, '.git')) return makeStats('dir', 10); + if (normalized === path.join(repoRoot, '.git', 'HEAD')) return makeStats('file', 11, 20); + if (normalized === path.join(repoRoot, '.git', 'refs', 'heads', 'main')) return makeStats('file', 12, 21); + if (normalized === path.join(repoRoot, '.git', 'index')) return makeStats('file', 30, 22); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-2' }, mockServerLink as any); + await flushAsync(); + expect(mockExec).toHaveBeenCalledTimes(2); + expect((sent.find((msg: any) => msg.requestId === 'status-2') as any)?.files).toEqual([ + { path: '/home/k/project/src/b.ts', code: 'M' }, + ]); + + first.resolve({ stdout: 'M src/a.ts\0', stderr: '' }); + await flushAsync(); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-3' }, mockServerLink as any); + await flushAsync(); + expect(mockExec).toHaveBeenCalledTimes(2); + expect((sent.find((msg: any) => msg.requestId === 'status-3') as any)?.files).toEqual([ + { path: '/home/k/project/src/b.ts', code: 'M' }, + ]); + }); + + it('reuses the cached repo signature when repo freshness has not changed', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M src/a.ts\0', ''); + return {} as any; + } + callback(new Error(`unexpected command: ${String(command)}`), '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-cache-1' }, mockServerLink as any); + await flushAsync(); + const headPath = path.join(repoRoot, '.git', 'HEAD'); + const headReadsAfterFirst = mockReadFile.mock.calls.filter((call) => String(call[0]) === headPath).length; + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-cache-2' }, mockServerLink as any); + await flushAsync(); + const headReadsAfterSecond = mockReadFile.mock.calls.filter((call) => String(call[0]) === headPath).length; + + expect(headReadsAfterFirst).toBeGreaterThan(0); + expect(headReadsAfterSecond).toBe(headReadsAfterFirst); + }); + + it('returns diffs for deleted tracked files without requiring realpath on the file itself', async () => { + const repoRoot = '/home/k/project'; + const filePath = '/home/k/project/deleted.ts'; + setupRepoMocks(repoRoot); + mockRealpath.mockImplementation(async (target) => { + const normalized = String(target); + if (normalized === filePath) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return normalized as any; + }); + mockExecFile.mockImplementation((file: any, args: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (file === 'git' && Array.isArray(args) && args[0] === 'diff' && args[1] === 'HEAD') { + callback(null, 'diff --git a/deleted.ts b/deleted.ts\n', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-deleted' }, mockServerLink as any); + await flushAsync(); + + expect((sent[0] as any).status).toBe('ok'); + expect((sent[0] as any).diff).toContain('deleted.ts'); + }); + + it('passes git diff paths as argv literals instead of shell command strings', async () => { + const repoRoot = '/home/k/project'; + const filePath = '/home/k/project/$(echo hacked).ts'; + setupRepoMocks(repoRoot, filePath); + mockExecFile.mockImplementation((file: any, args: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_diff', path: filePath, requestId: 'diff-literal' }, mockServerLink as any); + await flushAsync(); + + expect(mockExecFile).toHaveBeenCalled(); + expect(mockExecFile.mock.calls[0]?.[0]).toBe('git'); + expect(mockExecFile.mock.calls[0]?.[1]).toEqual(['diff', 'HEAD', '--', '$(echo hacked).ts']); + }); + + it('normalizes rename status and numstat to the current logical path', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'R old name.ts\0new name.ts\0', ''); + return {} as any; + } + if (command === 'git diff --numstat -z HEAD') { + callback(null, '7\t2\t\0old name.ts\0new name.ts\0', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-rename', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect((sent[0] as any).files).toEqual([ + { path: '/home/k/project/new name.ts', code: 'R', additions: 7, deletions: 2 }, + ]); + }); + + it('preserves quoted and escaped paths consistently across status and numstat', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M dir with spaces/file\t\"quoted\".ts\0', ''); + return {} as any; + } + if (command === 'git diff --numstat -z HEAD') { + callback(null, '4\t1\tdir with spaces/file\t\"quoted\".ts\0', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-quoted', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect((sent[0] as any).files).toEqual([ + { path: '/home/k/project/dir with spaces/file\t"quoted".ts', code: 'M', additions: 4, deletions: 1 }, + ]); + }); + + it('returns an empty ok response outside a git repo', async () => { + const projectRoot = '/home/k/project'; + mockRealpath.mockImplementation(async (target) => String(target)); + mockStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + handleWebCommand({ type: 'fs.git_status', path: projectRoot, requestId: 'status-empty', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect((sent[0] as any)).toMatchObject({ status: 'ok', files: [] }); + }); + + it('preserves forbidden-path behavior for git status and git diff', async () => { + const sshRoot = path.join(homedir(), '.ssh'); + const forbiddenFile = path.join(sshRoot, 'config'); + mockRealpath.mockImplementation(async (target) => String(target)); + mockStat.mockResolvedValue(makeStats('file', 10, 10)); + + handleWebCommand({ type: 'fs.git_status', path: sshRoot, requestId: 'status-forbidden' }, mockServerLink as any); + handleWebCommand({ type: 'fs.git_diff', path: forbiddenFile, requestId: 'diff-forbidden' }, mockServerLink as any); + await flushAsync(); + + expect((sent.find((msg: any) => msg.requestId === 'status-forbidden') as any)?.error).toBe('forbidden_path'); + expect((sent.find((msg: any) => msg.requestId === 'diff-forbidden') as any)?.error).toBe('forbidden_path'); + }); + + it('keeps the changed-file list usable when numstat is unavailable', async () => { + const repoRoot = '/home/k/project'; + setupRepoMocks(repoRoot); + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') callback = options; + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M src/a.ts\0', ''); + return {} as any; + } + callback(new Error('unsupported numstat'), '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-nostat', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect((sent[0] as any).files).toEqual([ + { path: '/home/k/project/src/a.ts', code: 'M' }, + ]); + }); + + it('invalidates cached repo status after fs.write succeeds', async () => { + const repoRoot = '/home/k/project'; + const filePath = '/home/k/project/foo.ts'; + setupRepoMocks(repoRoot, filePath); + + mockExec.mockImplementation((command: any, options: any, callback: any) => { + if (typeof options === 'function') { + callback = options; + } + if (command === 'git status --porcelain=v1 -z -u') { + callback(null, 'M foo.ts\0', ''); + return {} as any; + } + if (command === 'git diff --numstat -z HEAD') { + callback(null, '1\t0\tfoo.ts\0', ''); + return {} as any; + } + callback(null, '', ''); + return {} as any; + }); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-1', includeStats: true }, mockServerLink as any); + await flushAsync(); + expect(mockExec).toHaveBeenCalledTimes(2); + + mockStat.mockImplementation(async (target) => { + const normalized = String(target); + if (normalized === path.join(repoRoot, '.git')) return makeStats('dir', 10); + if (normalized === path.join(repoRoot, '.git', 'HEAD')) return makeStats('file', 11, 20); + if (normalized === path.join(repoRoot, '.git', 'refs', 'heads', 'main')) return makeStats('file', 12, 21); + if (normalized === path.join(repoRoot, '.git', 'index')) return makeStats('file', 13, 22); + if (normalized === filePath) return makeStats('file', 15, 31); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockWriteFile.mockResolvedValue(undefined); + + handleWebCommand({ type: 'fs.write', path: filePath, content: 'updated', requestId: 'write-1' }, mockServerLink as any); + await flushAsync(); + + handleWebCommand({ type: 'fs.git_status', path: repoRoot, requestId: 'status-2', includeStats: true }, mockServerLink as any); + await flushAsync(); + + expect(mockExec).toHaveBeenCalledTimes(4); + expect((sent.find((msg: any) => msg.requestId === 'write-1') as any)?.status).toBe('ok'); + }); +}); diff --git a/test/daemon/fs-write.test.ts b/test/daemon/fs-write.test.ts index e8bec0cd..68361ab0 100644 --- a/test/daemon/fs-write.test.ts +++ b/test/daemon/fs-write.test.ts @@ -30,7 +30,7 @@ const mockStat = vi.mocked(fsp.stat); const mockReadFile = vi.mocked(fsp.readFile); const mockWriteFile = vi.mocked(fsp.writeFile); -import { handleWebCommand } from '../../src/daemon/command-handler.js'; +import { handleWebCommand, __resetFsGitCachesForTests } from '../../src/daemon/command-handler.js'; /** Flush the microtask + macrotask queue so async handlers complete. */ const flushAsync = () => new Promise((r) => setTimeout(r, 0)); @@ -40,6 +40,7 @@ describe('fs.write handler', () => { vi.clearAllMocks(); sent.length = 0; mockServerLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + __resetFsGitCachesForTests(); }); afterEach(() => { @@ -214,6 +215,7 @@ describe('fs.write mtime conflict detection', () => { vi.clearAllMocks(); sent.length = 0; mockServerLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + __resetFsGitCachesForTests(); }); it('passes when expectedMtime matches disk mtime', async () => { @@ -294,6 +296,7 @@ describe('fs.read handler — mtime field', () => { vi.clearAllMocks(); sent.length = 0; mockServerLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + __resetFsGitCachesForTests(); }); it('includes mtime in successful text read response', async () => { @@ -314,4 +317,65 @@ describe('fs.read handler — mtime field', () => { mtime, }); }); + + it('reuses cached text content when file signature is unchanged', async () => { + const filePath = path.join(homedir(), 'cached-read.txt'); + const mtime = 1700000000000; + mockRealpath.mockResolvedValue(filePath as unknown as string); + mockStat.mockResolvedValue({ size: 100, mtimeMs: mtime } as fsp.Stats); + mockReadFile.mockResolvedValue('hello cache' as unknown as Buffer); + + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-read-1' }, mockServerLink as any); + await flushAsync(); + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-read-2' }, mockServerLink as any); + await flushAsync(); + + expect(mockReadFile).toHaveBeenCalledTimes(1); + expect((sent[1] as any).content).toBe('hello cache'); + }); + + it('single-flights concurrent reads for the same file', async () => { + const filePath = path.join(homedir(), 'inflight-read.txt'); + const mtime = 1700000000000; + let release: ((value: string) => void) | null = null; + mockRealpath.mockResolvedValue(filePath as unknown as string); + mockStat.mockResolvedValue({ size: 100, mtimeMs: mtime } as fsp.Stats); + mockReadFile.mockImplementation(() => new Promise((resolve) => { release = resolve as (value: string) => void; }) as any); + + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-inflight-1' }, mockServerLink as any); + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-inflight-2' }, mockServerLink as any); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockReadFile).toHaveBeenCalledTimes(1); + + release?.('shared content'); + await flushAsync(); + + expect(sent).toHaveLength(2); + expect((sent[0] as any).content).toBe('shared content'); + expect((sent[1] as any).content).toBe('shared content'); + }); + + it('invalidates cached reads after fs.write succeeds', async () => { + const filePath = path.join(homedir(), 'cache-invalidated.txt'); + mockRealpath.mockResolvedValue(filePath as unknown as string); + mockStat + .mockResolvedValueOnce({ size: 100, mtimeMs: 1000 } as fsp.Stats) + .mockResolvedValueOnce({ size: 100, mtimeMs: 1000 } as fsp.Stats) + .mockResolvedValueOnce({ mtimeMs: 2000 } as fsp.Stats) + .mockResolvedValueOnce({ size: 120, mtimeMs: 3000 } as fsp.Stats); + mockReadFile + .mockResolvedValueOnce('before write' as unknown as Buffer) + .mockResolvedValueOnce('after write' as unknown as Buffer); + mockWriteFile.mockResolvedValue(undefined); + + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-pre-write' }, mockServerLink as any); + await flushAsync(); + handleWebCommand({ type: 'fs.write', path: filePath, content: 'updated', requestId: 'req-write-ok' }, mockServerLink as any); + await flushAsync(); + handleWebCommand({ type: 'fs.read', path: filePath, requestId: 'req-post-write' }, mockServerLink as any); + await flushAsync(); + + expect(mockReadFile).toHaveBeenCalledTimes(2); + expect((sent.find((msg: any) => msg.requestId === 'req-post-write') as any)?.content).toBe('after write'); + }); }); diff --git a/test/daemon/oc-streaming-integration.test.ts b/test/daemon/oc-streaming-integration.test.ts index e4f13cbb..d7f7b0cd 100644 --- a/test/daemon/oc-streaming-integration.test.ts +++ b/test/daemon/oc-streaming-integration.test.ts @@ -74,6 +74,10 @@ function emitAgentEvent(payload: Record): void { lastWs().emit('message', JSON.stringify({ type: 'event', event: 'agent', payload })); } +function advanceStreamWindow(): void { + vi.advanceTimersByTime(250); +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe('OC streaming integration: provider → relay → emitter', () => { @@ -105,9 +109,13 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:sess' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '你' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '好' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '世' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '界' }, key: 'test:sess' }); + advanceStreamWindow(); // Filter to assistant.text events for the sanitized session const textEvents = emittedEvents.filter( @@ -135,7 +143,9 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:s2' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'Hello ' }, key: 'test:s2' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'World' }, key: 'test:s2' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'end' }, key: 'test:s2' }); const textEvents = emittedEvents.filter( @@ -161,7 +171,9 @@ describe('OC streaming integration: provider → relay → emitter', () => { // OC sends text=delta (both incremental, text field NOT cumulative) emitAgentEvent({ runId, stream: 'assistant', data: { text: '收到', delta: '收到' }, key: 'test:nc' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { text: '主人', delta: '主人' }, key: 'test:nc' }); + advanceStreamWindow(); const textEvents = emittedEvents.filter( (e) => e.type === 'assistant.text' && e.sessionId === 'test___nc', @@ -203,8 +215,11 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:dd' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'A' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'B' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'C' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'end' }, key: 'test:dd' }); const textEvents = emittedEvents.filter( diff --git a/test/daemon/p2p-discussion-list.test.ts b/test/daemon/p2p-discussion-list.test.ts new file mode 100644 index 00000000..e2012de7 --- /dev/null +++ b/test/daemon/p2p-discussion-list.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { handleWebCommand } from '../../src/daemon/command-handler.js'; +import { imcSubDir } from '../../src/util/imc-dir.js'; +import { listSessions, removeSession, upsertSession } from '../../src/store/session-store.js'; + +const sent: unknown[] = []; +const serverLink = { + send: vi.fn((msg: unknown) => { sent.push(msg); }), + sendBinary: vi.fn(), +}; + +async function waitForSentCount(count: number): Promise { + for (let i = 0; i < 50; i += 1) { + if (sent.length >= count) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('p2p.list_discussions', () => { + let projectDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + sent.length = 0; + serverLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + projectDir = await mkdtemp(join(tmpdir(), 'imcodes-p2p-discussions-')); + await mkdir(imcSubDir(projectDir, 'discussions'), { recursive: true }); + upsertSession({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'claude-code', + projectDir, + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + + afterEach(async () => { + for (const session of listSessions()) removeSession(session.name); + if (projectDir) await rm(projectDir, { recursive: true, force: true }); + }); + + it('returns only the canonical discussion file and excludes hop artifacts', async () => { + const discussionsDir = imcSubDir(projectDir, 'discussions'); + await writeFile(join(discussionsDir, 'run-main.md'), '## User Request\nmain request\n', 'utf8'); + await writeFile(join(discussionsDir, 'run-main.round1.hop1.md'), '## User Request\nhop 1\n', 'utf8'); + await writeFile(join(discussionsDir, 'run-main.round1.hop2.md'), '## User Request\nhop 2\n', 'utf8'); + await writeFile(join(discussionsDir, 'run-main.reducer.2.md'), '# reducer snapshot\n', 'utf8'); + + handleWebCommand({ type: 'p2p.list_discussions' }, serverLink as any); + await waitForSentCount(1); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + type: 'p2p.list_discussions_response', + discussions: [ + expect.objectContaining({ + id: 'run-main', + fileName: 'run-main.md', + preview: 'main request', + }), + ], + }); + const response = sent[0] as { discussions: Array<{ fileName: string }> }; + expect(response.discussions.map((d) => d.fileName)).toEqual(['run-main.md']); + }); +}); diff --git a/test/daemon/p2p-orchestrator.test.ts b/test/daemon/p2p-orchestrator.test.ts index 3576400e..dd3f834d 100644 --- a/test/daemon/p2p-orchestrator.test.ts +++ b/test/daemon/p2p-orchestrator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const isDarwin = process.platform === 'darwin'; -import { mkdir, readFile, rm, appendFile, writeFile, utimes, access } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rm, appendFile, writeFile, utimes, access } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -12,6 +12,8 @@ const { getSessionMock, detectStatusMock, detectStatusAsyncMock, + launchTransportSessionMock, + stopTransportRuntimeSessionMock, serverLinkMock, } = vi.hoisted(() => ({ sendKeysDelayedEnterMock: vi.fn().mockResolvedValue(undefined), @@ -20,6 +22,8 @@ const { getSessionMock: vi.fn(), detectStatusMock: vi.fn().mockReturnValue('idle'), detectStatusAsyncMock: vi.fn().mockResolvedValue('idle'), + launchTransportSessionMock: vi.fn().mockResolvedValue(undefined), + stopTransportRuntimeSessionMock: vi.fn().mockResolvedValue(undefined), serverLinkMock: { send: vi.fn() }, })); @@ -41,6 +45,8 @@ vi.mock('../../src/agent/detect.js', () => ({ vi.mock('../../src/agent/session-manager.js', () => ({ getTransportRuntime: vi.fn(), + launchTransportSession: launchTransportSessionMock, + stopTransportRuntimeSession: stopTransportRuntimeSessionMock, })); vi.mock('../../src/util/logger.js', () => ({ @@ -63,6 +69,7 @@ import { _setGracePeriodMs, _setIdlePollMs, _setMinProcessingMs, + _setRoundHopCleanupDelayMs, type P2pRun, type P2pRunStatus, } from '../../src/daemon/p2p-orchestrator.js'; @@ -93,12 +100,27 @@ async function waitForStatus(runId: string, expected: P2pRunStatus[], maxMs = 10 throw new Error(`Run ${runId} ended in ${run.status}, expected ${expected.join(', ')}`); } +async function waitForNoRoundHopArtifacts(projectDir: string, runId: string, maxMs = 1000): Promise { + const discussionsDir = join(projectDir, '.imc', 'discussions'); + const start = Date.now(); + while (Date.now() - start < maxMs) { + const remainingArtifacts = (await readdir(discussionsDir).catch(() => [] as string[])) + .filter((name) => name.startsWith(runId) && /\.round\d+\.hop\d+\.md$/.test(name)); + if (remainingArtifacts.length === 0) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const remainingArtifacts = (await readdir(discussionsDir).catch(() => [] as string[])) + .filter((name) => name.startsWith(runId) && /\.round\d+\.hop\d+\.md$/.test(name)); + expect(remainingArtifacts).toEqual([]); +} + beforeEach(async () => { vi.clearAllMocks(); _setIdlePollMs(20); _setGracePeriodMs(80); _setMinProcessingMs(0); _setFileSettleCycles(1); + _setRoundHopCleanupDelayMs(0); tempProjectDir = join(tmpdir(), `p2p-par-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); await mkdir(tempProjectDir, { recursive: true }); @@ -133,6 +155,7 @@ afterEach(async () => { _setGracePeriodMs(180000); _setMinProcessingMs(30000); _setFileSettleCycles(3); + _setRoundHopCleanupDelayMs(0); await rm(tempProjectDir, { recursive: true, force: true }).catch(() => {}); }); @@ -152,6 +175,65 @@ describe('P2P orchestrator — parallel rounds', () => { expect(done.hopStates[0].artifact_path).toContain(`${done.id}.round1.hop1.md`); }); + it('removes legacy round hop artifacts after merging them into the main discussion file', async () => { + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'audit' }], + 'legacy artifact cleanup', + [], + serverLinkMock as any, + ); + + const done = await waitForStatus(run.id, ['completed']); + await waitForNoRoundHopArtifacts(tempProjectDir, done.id); + }); + + it('keeps legacy single-mode runs on the legacy payload path without advanced fields', async () => { + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'audit' }], + 'legacy single mode', + [], + serverLinkMock as any, + ); + + const done = await waitForStatus(run.id, ['completed']); + const payload = serializeP2pRun(done); + + expect(done.advancedP2pEnabled).toBe(false); + expect(payload.advanced_p2p_enabled).toBeUndefined(); + expect(payload.helper_diagnostics).toBeUndefined(); + expect(payload.all_nodes?.length).toBeGreaterThan(0); + expect(payload.mode_key).toBe('audit'); + expect(done.completedHops.map((hop) => hop.session)).toEqual(['deck_proj_w1']); + expect(done.skippedHops).toEqual([]); + expect(done.remainingTargets).toEqual([]); + }); + + it('preserves legacy combo-mode sequencing without advanced fields', async () => { + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'brainstorm>discuss' as any }], + 'legacy combo mode', + [], + serverLinkMock as any, + 2, + undefined, + 'brainstorm>discuss', + ); + + const done = await waitForStatus(run.id, ['completed']); + const comboHops = done.hopStates.filter((hop) => hop.session === 'deck_proj_w1'); + + expect(done.advancedP2pEnabled).toBe(false); + expect(comboHops.map((hop) => hop.round_index)).toEqual([1, 2]); + expect(comboHops.map((hop) => hop.mode)).toEqual(['brainstorm', 'discuss']); + expect(done.completedHops.map((hop) => hop.session)).toEqual(['deck_proj_w1', 'deck_proj_w1']); + expect(done.skippedHops).toEqual([]); + expect(done.remainingTargets).toEqual([]); + expect(done.resultSummary).toContain('Final Summary'); + }); + it.skipIf(isDarwin)('cleans stale orphan hop artifacts when a new run starts', async () => { const discussionsDir = join(tempProjectDir, '.imc', 'discussions'); await mkdir(discussionsDir, { recursive: true }); @@ -400,6 +482,38 @@ describe('P2P orchestrator — parallel rounds', () => { expect(payload.summary_phase).toBe('completed'); }); + it('does not fail the whole run when the initiator goes idle without writing', async () => { + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + if (session === 'deck_proj_brain') { + setTimeout(() => notifySessionIdle(session), 20); + return; + } + await appendFile(filePath, `\n## ${heading}\n\nSUCCESS-${session}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'audit' }], + 'initiator idle without file change should not fail run', + [], + serverLinkMock as any, + 1, + undefined, + undefined, + 120, + ); + + const done = await waitForStatus(run.id, ['completed']); + expect(done.status).toBe('completed'); + expect(done.skippedHops).toContain('deck_proj_brain'); + expect(done.hopStates.some((hop) => hop.session === 'deck_proj_w1' && hop.status === 'completed')).toBe(true); + const content = await readFile(done.contextFilePath, 'utf8'); + expect(content).toContain('SUCCESS-deck_proj_w1'); + }); + it('uses isolated cross-project hop copies and copies completed artifacts back to the main project hop file', async () => { await mkdir(join(tempProjectDir, 'other'), { recursive: true }); getSessionMock.mockImplementation((name: string) => { @@ -429,8 +543,7 @@ describe('P2P orchestrator — parallel rounds', () => { expect(hop).toBeDefined(); expect(hop?.working_path).toContain(join(tempProjectDir, 'other')); expect(hop?.artifact_path).toContain(tempProjectDir); - const artifact = await readFile(hop!.artifact_path, 'utf8'); - expect(artifact).toContain('CROSS-PROJECT-deck_proj_w2'); + await expect(access(hop!.artifact_path)).rejects.toBeTruthy(); const main = await readFile(done.contextFilePath, 'utf8'); expect(main).toContain('CROSS-PROJECT-deck_proj_w2'); }); @@ -534,6 +647,244 @@ describe('P2P orchestrator — parallel rounds', () => { expect(payload.hop_counts?.completed).toBeGreaterThanOrEqual(1); }); + it('preserves the active run phase when an advanced whole-run timeout fires', async () => { + let runId = ''; + sendKeysDelayedEnterMock.mockImplementationOnce(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nSlow output from ${session}.\n`, 'utf8'); + setTimeout(() => { + const current = getP2pRun(runId); + if (current) current.deadlineAt = Date.now() - 1; + notifySessionIdle(session); + }, 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'timeout before final summary', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRunTimeoutMs: 10, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + }); + runId = run.id; + + const done = await waitForStatus(run.id, ['timed_out'], 15_000); + const payload = serializeP2pRun(done); + + expect(payload.status).toBe('timed_out'); + expect(payload.run_phase).toBe('round_execution'); + expect(payload.summary_phase).toBeNull(); + expect(payload.error).toContain('advanced_run_timeout'); + }, 20_000); + + it('serializes helper diagnostics only for advanced runs with the documented shape', () => { + const timestamp = Date.now(); + const run: P2pRun = { + id: 'run_helper_diag', + discussionId: 'disc_helper_diag', + mainSession: 'deck_proj_brain', + initiatorSession: 'deck_proj_brain', + currentTargetSession: null, + finalReturnSession: 'deck_proj_brain', + remainingTargets: [], + totalTargets: 0, + mode: 'plan', + status: 'running', + runPhase: 'round_execution', + summaryPhase: null, + activePhase: 'hop', + contextFilePath: '/tmp/run_helper_diag.md', + userText: 'helper diagnostics payload', + timeoutMs: 120000, + resultSummary: null, + completedHops: [], + skippedHops: [], + error: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + completedAt: null, + rounds: 1, + currentRound: 1, + allTargets: [], + extraPrompt: '', + hopStartedAt: Date.now(), + hopStates: [], + activeTargetSessions: [], + advancedP2pEnabled: true, + resolvedRounds: [], + helperEligibleSnapshot: [], + contextReducer: undefined, + advancedRunTimeoutMs: undefined, + deadlineAt: null, + currentRoundId: 'implementation', + currentExecutionStep: 2, + currentRoundAttempt: 3, + roundAttemptCounts: { implementation: 3 }, + roundJumpCounts: {}, + routingHistory: [], + helperDiagnostics: [ + { + code: 'P2P_HELPER_FALLBACK_FAILED', + attempt: 3, + sourceSession: 'deck_sub_helper', + templateSession: 'deck_proj_brain', + fallbackSession: 'deck_sub_helper', + timestamp, + message: 'fallback session timed out', + }, + ], + _cancelled: false, + }; + + const payload = serializeP2pRun(run); + + expect(payload.helper_diagnostics).toEqual([ + { + code: 'P2P_HELPER_FALLBACK_FAILED', + attempt: 3, + sourceSession: 'deck_sub_helper', + templateSession: 'deck_proj_brain', + fallbackSession: 'deck_sub_helper', + timestamp, + message: 'fallback session timed out', + }, + ]); + + const legacyPayload = serializeP2pRun({ + ...run, + id: 'run_helper_diag_legacy', + advancedP2pEnabled: false, + }); + expect(legacyPayload.helper_diagnostics).toBeUndefined(); + }); + + it('serializes the latest routing step for looped advanced rounds', () => { + const run: P2pRun = { + id: 'run_advanced_latest_step', + discussionId: 'disc_latest_step', + mainSession: 'deck_proj_brain', + initiatorSession: 'deck_proj_brain', + currentTargetSession: null, + finalReturnSession: 'deck_proj_brain', + remainingTargets: [], + totalTargets: 0, + mode: 'discuss', + status: 'running', + runPhase: 'round_execution', + summaryPhase: null, + activePhase: 'hop', + contextFilePath: '/tmp/run_advanced_latest_step.md', + userText: 'latest step serialization', + timeoutMs: 120000, + resultSummary: null, + completedHops: [], + skippedHops: [], + error: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + completedAt: null, + rounds: 2, + currentRound: 1, + allTargets: [], + extraPrompt: '', + hopStartedAt: Date.now(), + hopStates: [], + activeTargetSessions: [], + advancedP2pEnabled: true, + resolvedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + promptSuffix: '', + timeoutMs: 60_000, + requiresVerdict: false, + allowRouting: false, + jumpRule: null, + output: { kind: 'discussion_append' }, + helperTask: null, + modeKey: 'discuss', + verdictPolicy: 'none', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + promptSuffix: '', + timeoutMs: 60_000, + requiresVerdict: true, + allowRouting: true, + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + output: { kind: 'discussion_append' }, + helperTask: null, + modeKey: 'audit', + verdictPolicy: 'smart_gate', + }, + ], + helperEligibleSnapshot: ['deck_proj_brain'], + contextReducer: undefined, + advancedRunTimeoutMs: undefined, + deadlineAt: null, + currentRoundId: 'implementation', + currentExecutionStep: 5, + currentRoundAttempt: 3, + roundAttemptCounts: { + implementation: 3, + implementation_audit: 2, + }, + roundJumpCounts: { + implementation_audit: 2, + }, + routingHistory: [ + { + fromRoundId: 'implementation_audit', + toRoundId: 'implementation', + trigger: 'REWORK', + atStep: 3, + atAttempt: 1, + timestamp: 1, + }, + { + fromRoundId: 'implementation_audit', + toRoundId: 'implementation', + trigger: 'REWORK', + atStep: 5, + atAttempt: 2, + timestamp: 2, + }, + ], + helperDiagnostics: [], + _cancelled: false, + }; + + const payload = serializeP2pRun(run); + + expect(payload.advanced_nodes?.find((node) => node.id === 'implementation')).toMatchObject({ + attempt: 3, + step: 5, + }); + }); + it('projects all active hops into all_nodes for parallel round progress', () => { const run: P2pRun = { id: 'run_parallel', @@ -617,4 +968,1149 @@ describe('P2P orchestrator — parallel rounds', () => { expect(payload.current_target_session).toBe('deck_proj_w1'); expect(payload.active_hop_number).toBe(1); }); + + it('falls back to an sdk child helper when the primary reducer session fails', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_sub_helper') return { agentType: 'qwen', projectDir: tempProjectDir, parentSession: 'deck_proj_brain', label: 'helper' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + if (prompt.includes('[P2P Helper Task') && session === 'deck_proj_brain') { + throw new Error('primary reducer unavailable'); + } + const filePath = [...prompt.matchAll(/\/\S+?\.md/g)].at(-1)?.[0] ?? pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_sub_helper', mode: 'audit' }], + userText: 'x'.repeat(40_000), + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + contextReducer: { + mode: 'reuse_existing_session', + sessionName: 'deck_proj_brain', + }, + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(done.helperEligibleSnapshot).toEqual([ + { sessionName: 'deck_proj_brain', agentType: 'claude-code-sdk', parentSession: null }, + { sessionName: 'deck_sub_helper', agentType: 'qwen', parentSession: 'deck_proj_brain' }, + ]); + expect(done.helperDiagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'P2P_HELPER_PRIMARY_FAILED', + sourceSession: 'deck_proj_brain', + }), + ])); + const reducerPath = join(tempProjectDir, '.imc', 'discussions', `${done.id}.reducer.1.md`); + await expect(access(reducerPath)).rejects.toThrow(); + expect(done.error).toBeNull(); + }, 15_000); + + it('cleans up clone-mode temporary sdk helper sessions after reducer use', async () => { + let helperName: string | null = null; + launchTransportSessionMock.mockImplementation(async (opts: any) => { + helperName = opts.name; + }); + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') { + return { + agentType: 'claude-code-sdk', + runtimeType: 'transport', + projectDir: tempProjectDir, + projectName: 'proj', + parentSession: undefined, + label: 'brain', + requestedModel: 'claude-sonnet', + transportConfig: { source: 'test' }, + }; + } + if (helperName && name === helperName) { + return { + agentType: 'claude-code-sdk', + runtimeType: 'transport', + projectDir: tempProjectDir, + projectName: 'proj', + parentSession: 'deck_proj_brain', + label: helperName, + requestedModel: 'claude-sonnet', + transportConfig: { source: 'test' }, + }; + } + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = [...prompt.matchAll(/\/\S+?\.md/g)].at(-1)?.[0] ?? pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: `clone sdk helper cleanup ${'x'.repeat(40_000)}`, + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(launchTransportSessionMock).toHaveBeenCalledTimes(1); + expect(stopTransportRuntimeSessionMock).toHaveBeenCalledWith(helperName); + }, 20_000); + + it('fails the run when both the primary reducer path and fallback child fail', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_sub_helper') return { agentType: 'qwen', projectDir: tempProjectDir, parentSession: 'deck_proj_brain', label: 'helper' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + if (prompt.includes('[P2P Helper Task')) { + throw new Error(`helper failed for ${session}`); + } + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_sub_helper', mode: 'audit' }], + userText: 'x'.repeat(40_000), + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + contextReducer: { + mode: 'reuse_existing_session', + sessionName: 'deck_proj_brain', + }, + }); + + const done = await waitForStatus(run.id, ['failed'], 15_000); + expect(done.error).toContain('helper_fallback_failed:deck_sub_helper'); + expect(done.helperDiagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'P2P_HELPER_PRIMARY_FAILED', sourceSession: 'deck_proj_brain' }), + expect.objectContaining({ code: 'P2P_HELPER_FALLBACK_FAILED', sourceSession: 'deck_sub_helper' }), + ])); + }, 20_000); + + it('filters cli participants out of the advanced helper snapshot stored on the run', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_sub_helper') return { agentType: 'qwen', projectDir: tempProjectDir, parentSession: 'deck_proj_brain', label: 'helper' }; + if (name === 'deck_proj_cli') return { agentType: 'codex', projectDir: tempProjectDir, parentSession: undefined, label: 'cli' }; + return null; + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [ + { session: 'deck_sub_helper', mode: 'audit' }, + { session: 'deck_proj_cli', mode: 'review' }, + ], + userText: 'helper snapshot should stay sdk-only', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + }); + + expect(run.helperEligibleSnapshot).toEqual([ + { sessionName: 'deck_proj_brain', agentType: 'claude-code-sdk', parentSession: null }, + { sessionName: 'deck_sub_helper', agentType: 'qwen', parentSession: 'deck_proj_brain' }, + ]); + + await cancelP2pRun(run.id, serverLinkMock as any); + }); + + it('fails before creating a run when advanced config is rejected by the resolver', async () => { + const beforeIds = listP2pRuns().map((run) => run.id); + + await expect(startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'invalid advanced config should not start', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation_audit', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ], + })).rejects.toThrow(/jump backward/i); + + expect(listP2pRuns().map((run) => run.id)).toEqual(beforeIds); + }); + + it('launches and tears down clone-mode helper sessions', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { + agentType: 'claude-code-sdk', + runtimeType: 'transport', + projectDir: tempProjectDir, + parentSession: undefined, + label: 'brain', + projectName: 'proj', + requestedModel: 'claude-sonnet', + activeModel: 'claude-sonnet', + transportConfig: { baseUrl: 'http://localhost:1234' }, + effort: 'high', + }; + if (name === 'deck_sub_helper') return { agentType: 'qwen', projectDir: tempProjectDir, parentSession: 'deck_proj_brain', label: 'helper' }; + return null; + }); + + let helperSessionName = ''; + launchTransportSessionMock.mockImplementation(async (opts: any) => { + helperSessionName = opts.name; + return undefined; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = [...prompt.matchAll(/\/\S+?\.md/g)].at(-1)?.[0] ?? pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_sub_helper', mode: 'audit' }], + userText: 'x'.repeat(40_000), + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(launchTransportSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + agentType: 'claude-code-sdk', + projectDir: tempProjectDir, + requestedModel: 'claude-sonnet', + transportConfig: { baseUrl: 'http://localhost:1234' }, + skipStore: true, + fresh: true, + })); + expect(stopTransportRuntimeSessionMock).toHaveBeenCalledWith(helperSessionName); + }, 15_000); + + it('treats missing advanced audit verdicts as rework and records jump history', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + const body = heading.includes('Implementation Audit') + ? 'Audit completed without verdict marker.' + : `Output from ${session}.`; + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'loop until the audit stabilizes', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(done.roundJumpCounts.implementation_audit).toBe(2); + expect(done.routingHistory).toHaveLength(2); + expect(done.helperDiagnostics.filter((entry) => entry.code === 'P2P_VERDICT_MISSING').length).toBeGreaterThanOrEqual(1); + expect(done.resultSummary).toContain('Final Summary'); + }); + + it('forces the minimum rework loops before handing off to smart-gate evaluation', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + return null; + }); + + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = `Audit pass ${auditCount}.\n`; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'forced rework should ignore PASS until minTriggers is satisfied', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'forced_rework', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 2, + maxTriggers: 3, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(auditCount).toBe(3); + expect(done.roundJumpCounts.implementation_audit).toBe(2); + expect(done.routingHistory).toHaveLength(2); + expect(done.routingHistory.every((entry) => entry.trigger === 'PASS')).toBe(true); + }); + + it('hands off forced_rework rounds to smart-gate behavior after minTriggers is satisfied', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + const body = heading.includes('Implementation Audit') + ? '\nAudit says pass.' + : `Implementation output ${session}.`; + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'forced rework should stop looping once the minimum rework count is satisfied and audit passes', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'forced_rework', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 2, + maxTriggers: 4, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(done.roundAttemptCounts.implementation).toBe(3); + expect(done.roundAttemptCounts.implementation_audit).toBe(3); + expect(done.roundJumpCounts.implementation_audit).toBe(2); + expect(done.routingHistory).toHaveLength(2); + }, 20_000); + + it('continues forced_rework routing on REWORK after minTriggers until maxTriggers is exhausted', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + return null; + }); + + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Implementation output ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = `Audit says rework ${auditCount}.\n`; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'keep reworking until the forced loop budget is exhausted', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'forced_rework', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 1, + maxTriggers: 2, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(auditCount).toBe(3); + expect(done.roundAttemptCounts.implementation).toBe(3); + expect(done.roundAttemptCounts.implementation_audit).toBe(3); + expect(done.roundJumpCounts.implementation_audit).toBe(2); + expect(done.routingHistory).toHaveLength(2); + expect(done.routingHistory.every((entry) => entry.trigger === 'REWORK')).toBe(true); + }, 20_000); + + it('uses initiator synthesis as the authoritative verdict source for multi-dispatch audit rounds', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit Synthesis')) { + body = 'Synthesis accepts the implementation.\n'; + } else if (heading.includes('Implementation Audit (hop')) { + body = 'Worker thinks this still needs work.\n'; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'worker verdicts must not override synthesis', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'multi_dispatch', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 15_000); + expect(done.status).toBe('completed'); + expect(done.routingHistory).toEqual([]); + expect(done.roundJumpCounts.implementation_audit).toBeUndefined(); + }, 20_000); + + it('rejects invalid advanced configs before creating or running a daemon run', async () => { + await expect(startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'invalid advanced config', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'missing_round', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 1, + }, + }, + ], + })).rejects.toThrow(/unknown target/i); + + expect(listP2pRuns()).toHaveLength(0); + expect(serverLinkMock.send).not.toHaveBeenCalled(); + }); + + it('times out if the whole-run deadline expires during final summary dispatch', async () => { + let activeRunId = ''; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + if (heading.includes('Final Summary')) { + setTimeout(() => { + const current = getP2pRun(activeRunId); + if (current) current.deadlineAt = Date.now() - 1; + }, 0); + setTimeout(() => { + const current = getP2pRun(activeRunId); + if (current) current.deadlineAt = Date.now() - 1; + notifySessionIdle(session); + }, 50); + return; + } + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'summary timeout boundary', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRunTimeoutMs: 60_000, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ], + }); + activeRunId = run.id; + + const done = await waitForStatus(run.id, ['timed_out'], 15_000); + const payload = serializeP2pRun(done); + + expect(payload.status).toBe('timed_out'); + expect(payload.run_phase).toBe('summarizing'); + expect(payload.summary_phase).toBe('failed'); + expect(payload.error).toContain('advanced_run_timeout'); + }, 20_000); + + it('fails artifact_generation rounds when declared outputs are left stale', async () => { + const stalePath = join(tempProjectDir, 'docs', 'plan.md'); + await mkdir(join(tempProjectDir, 'docs'), { recursive: true }); + await writeFile(stalePath, 'stale artifact\n', 'utf8'); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'artifact round must update the file', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'custom_artifact', + title: 'Custom Artifact', + preset: 'custom', + executionMode: 'single_main', + permissionScope: 'artifact_generation', + artifactOutputs: ['docs/plan.md'], + }, + ], + }); + + const done = await waitForStatus(run.id, ['failed'], 15_000); + expect(done.error).toContain('Expected artifact not observably updated'); + expect(done.error).toContain('docs/plan.md'); + }, 20_000); + + it('completes the openspec preset after proposal artifacts are created and audit eventually passes', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('OpenSpec Propose')) { + const changeDir = join(tempProjectDir, 'openspec', 'changes', 'smart-p2p-upgrade-e2e'); + await mkdir(join(changeDir, 'specs', 'smart-p2p-rounds'), { recursive: true }); + await writeFile(join(changeDir, 'proposal.md'), '# proposal\n', 'utf8'); + await writeFile(join(changeDir, 'design.md'), '# design\n', 'utf8'); + await writeFile(join(changeDir, 'tasks.md'), '# tasks\n', 'utf8'); + await writeFile(join(changeDir, 'specs', 'smart-p2p-rounds', 'spec.md'), '# spec\n', 'utf8'); + body = 'OpenSpec artifacts written.'; + } else if (heading.includes('Implementation Audit Synthesis') || heading.includes('Implementation Audit')) { + auditCount += 1; + body = auditCount === 1 + ? 'Audit requests another pass.\n' + : 'Audit accepts the implementation.\n'; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'run the openspec preset end to end', + fileContents: [], + serverLink: serverLinkMock as any, + advancedPresetKey: 'openspec', + }); + + const done = await waitForStatus(run.id, ['completed'], 20_000); + expect(done.status).toBe('completed'); + expect(auditCount).toBe(2); + expect(done.roundJumpCounts.implementation_audit).toBe(1); + await expect(access(join(tempProjectDir, 'openspec', 'changes', 'smart-p2p-upgrade-e2e', 'proposal.md'))).resolves.toBeUndefined(); + }, 25_000); + + it('cleans up loop-generated hop artifacts after repeated advanced attempts settle', async () => { + _setRoundHopCleanupDelayMs(20); + + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = auditCount < 3 + ? `Audit iteration ${auditCount}.\n` + : 'Audit accepts the latest attempt.\n'; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'loop cleanup regression', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 3, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 20_000); + expect(done.roundJumpCounts.implementation_audit).toBe(2); + + await new Promise((resolve) => setTimeout(resolve, 120)); + + const discussionsDir = join(tempProjectDir, '.imc', 'discussions'); + const files = await readdir(discussionsDir); + expect(files).toContain(`${done.id}.md`); + expect(files.filter((file) => file.includes('.round'))).toEqual([]); + }, 25_000); + + it('applies per-round timeout budgets for advanced rounds', async () => { + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nSlow output from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 50); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: 'per-round timeout should override the default hop timeout', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRunTimeoutMs: 60_000, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + timeoutMinutes: 0, + }, + ], + }); + + const done = await waitForStatus(run.id, ['timed_out'], 15_000); + expect(done.status).toBe('timed_out'); + expect(done.runPhase).toBe('round_execution'); + expect(done.error).toContain('timed_out'); + }, 20_000); + + it('initializes advanced runs with deterministic bookkeeping fields', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_sub_helper') return { agentType: 'qwen', projectDir: tempProjectDir, parentSession: 'deck_proj_brain', label: 'helper' }; + return null; + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_sub_helper', mode: 'audit' }], + userText: 'bookkeeping init', + fileContents: [], + serverLink: serverLinkMock as any, + advancedPresetKey: 'openspec', + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + advancedRunTimeoutMs: 120_000, + }); + + expect(run.advancedP2pEnabled).toBe(true); + expect(run.currentRoundId).toBe('discussion'); + expect(run.currentExecutionStep).toBe(1); + expect(run.currentRoundAttempt).toBe(1); + expect(run.roundAttemptCounts).toEqual({ discussion: 1 }); + expect(run.roundJumpCounts).toEqual({}); + expect(run.routingHistory).toEqual([]); + expect(run.helperEligibleSnapshot).toEqual([ + { sessionName: 'deck_proj_brain', agentType: 'claude-code-sdk', parentSession: null }, + { sessionName: 'deck_sub_helper', agentType: 'qwen', parentSession: 'deck_proj_brain' }, + ]); + expect(run.deadlineAt).toBeTypeOf('number'); + expect(run.deadlineAt).toBeGreaterThan(Date.now()); + + await cancelP2pRun(run.id, serverLinkMock as any); + }); + + it('keeps advanced loop bookkeeping deterministic while legacy projections remain compatibility-only', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + let auditCount = 0; + let finalSummaryCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = auditCount === 1 + ? 'Needs one more pass.\n' + : 'Looks good now.\n'; + } + if (heading.includes('Final Summary')) finalSummaryCount += 1; + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'compatibility bookkeeping under loop-back', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 20_000); + const payload = serializeP2pRun(done); + + expect(finalSummaryCount).toBe(1); + expect(done.roundAttemptCounts).toMatchObject({ + implementation: 2, + implementation_audit: 2, + }); + expect(done.currentExecutionStep).toBe(4); + expect(done.routingHistory).toHaveLength(1); + expect(done.remainingTargets).toEqual([]); + expect(payload.remaining_count).toBe(0); + expect(payload.completed_hops_count).toBe(1); + expect(payload.hop_counts?.completed).toBe(1); + expect(payload.advanced_p2p_enabled).toBe(true); + expect(payload.advanced_nodes?.find((node) => node.id === 'implementation')?.attempt).toBe(2); + expect(payload.all_nodes?.length).toBeGreaterThan(0); + }, 25_000); + + it('injects reducer summaries into later loop prompts and keeps the helper prompt focused on the latest attempt context', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + return null; + }); + + const helperPrompts: string[] = []; + const implementationPrompts: string[] = []; + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const helperPath = [...prompt.matchAll(/\/\S+?\.md/g)].at(-1)?.[0]; + const heading = headingFromPrompt(prompt); + if (prompt.includes('[P2P Helper Task')) { + helperPrompts.push(prompt); + if (!helperPath) throw new Error('missing helper summary path'); + await appendFile(helperPath, `\n## ${heading}\n\nREDUCED-CONTEXT-LATEST\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + return; + } + if (heading.includes('Implementation (attempt')) implementationPrompts.push(prompt); + const filePath = pathFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = auditCount === 1 + ? 'Try once more.\n' + : 'Pass now.\n'; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [], + userText: `latest-attempt reducer focus ${'x'.repeat(40_000)}`, + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ], + contextReducer: { + mode: 'reuse_existing_session', + sessionName: 'deck_proj_brain', + }, + }); + + const done = await waitForStatus(run.id, ['completed'], 20_000); + expect(done.status).toBe('completed'); + expect(helperPrompts.length).toBeGreaterThan(0); + expect(helperPrompts[0]).toContain('Focus on: latest implementation attempt, latest audit findings, declared artifact targets'); + const laterImplementationPrompt = implementationPrompts.find((entry) => entry.includes('attempt 2')); + expect(laterImplementationPrompt).toContain('Reduced context for this attempt:'); + expect(laterImplementationPrompt).toContain('REDUCED-CONTEXT-LATEST'); + }, 25_000); + + it('cleans worker-hop artifacts after repeated loop attempts', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + let auditCount = 0; + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + let body = `Output from ${session}.`; + if (heading.includes('Implementation Audit')) { + auditCount += 1; + body = auditCount < 3 + ? `Needs more work ${auditCount}.\n` + : 'Approved.\n'; + } + await appendFile(filePath, `\n## ${heading}\n\n${body}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'artifact cleanup across repeated loop attempts', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 3, + }, + }, + ], + }); + + const done = await waitForStatus(run.id, ['completed'], 25_000); + expect(done.roundAttemptCounts.implementation).toBe(3); + await waitForNoRoundHopArtifacts(tempProjectDir, done.id); + }, 30_000); + + it('cleans worker-hop artifacts even when advanced synthesis fails before the run completes', async () => { + getSessionMock.mockImplementation((name: string) => { + if (name === 'deck_proj_brain') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'brain' }; + if (name === 'deck_proj_w1') return { agentType: 'claude-code-sdk', projectDir: tempProjectDir, parentSession: undefined, label: 'w1' }; + return null; + }); + + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + if (heading.includes('Implementation Synthesis')) { + throw new Error('synthesis unavailable'); + } + await appendFile(filePath, `\n## ${heading}\n\nOutput from ${session}.\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'cleanup worker artifacts on synthesis failure', + fileContents: [], + serverLink: serverLinkMock as any, + advancedRounds: [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + }, + ], + }); + + const done = await waitForStatus(run.id, ['failed'], 20_000); + expect(done.error).toContain('synthesis unavailable'); + await waitForNoRoundHopArtifacts(tempProjectDir, done.id); + }, 25_000); }); diff --git a/test/daemon/p2p-parser.test.ts b/test/daemon/p2p-parser.test.ts index b3e71ddb..916fe880 100644 --- a/test/daemon/p2p-parser.test.ts +++ b/test/daemon/p2p-parser.test.ts @@ -264,7 +264,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(startP2pRun).toHaveBeenCalledTimes(1); - const [_initiator, targets] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w1', mode: 'brainstorm>discuss' }, ]); @@ -286,7 +286,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(startP2pRun).toHaveBeenCalledTimes(1); - const [_initiator, targets] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w1', mode: 'audit' }, { session: 'deck_proj_w2', mode: 'review' }, @@ -306,7 +306,8 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets.length).toBeGreaterThan(0); expect(targets.every((t: any) => t.mode === 'audit')).toBe(true); // Text should be clean — no @@tokens @@ -326,7 +327,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w1', mode: 'review' }, ]); @@ -348,10 +349,55 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const extraPrompt = (startP2pRun as ReturnType).mock.calls[0][6]; + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + const [{ extraPrompt }] = (startP2pRun as ReturnType).mock.calls[0]; expect(extraPrompt).toContain("Use the user's selected i18n language (Chinese (Simplified)) for the discussion."); }); + it('forwards advanced p2p options through the structured session.send path', async () => { + const advancedRounds = [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + ]; + + handleWebCommand({ + type: 'session.send', + sessionName: 'deck_proj_brain', + text: 'run advanced p2p', + commandId: 'cmd-advanced-1', + p2pAtTargets: [{ session: 'deck_proj_w1', mode: 'audit' }], + p2pAdvancedPresetKey: 'openspec', + p2pAdvancedRounds: advancedRounds as any, + p2pAdvancedRunTimeoutMinutes: 45, + p2pContextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + }, mockServerLink as any); + + await new Promise((r) => setTimeout(r, 100)); + + expect(startP2pRun).toHaveBeenCalledOnce(); + expect((startP2pRun as ReturnType).mock.calls[0]).toHaveLength(1); + expect((startP2pRun as ReturnType).mock.calls[0]?.[0]).toMatchObject({ + initiatorSession: 'deck_proj_brain', + targets: [{ session: 'deck_proj_w1', mode: 'audit' }], + userText: 'run advanced p2p', + advancedPresetKey: 'openspec', + advancedRounds, + advancedRunTimeoutMs: 45 * 60_000, + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + }); + }); + it('structured p2pAtTargets stays authoritative for single-target P2P runs', async () => { handleWebCommand({ type: 'session.send', @@ -364,7 +410,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w1', mode: 'review' }, ]); @@ -387,7 +433,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w2', mode: 'discuss' }, { session: 'deck_proj_w1', mode: 'audit' }, @@ -407,7 +453,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets.length).toBeGreaterThan(0); expect(targets.every((t: any) => t.mode === 'brainstorm')).toBe(true); expect(cleanText).toBe('brainstorm ideas'); @@ -440,7 +486,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets, cleanText] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets, userText: cleanText }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets).toEqual([ { session: 'deck_proj_w1', mode: 'audit' }, ]); @@ -469,7 +515,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const [_initiator, targets] = (startP2pRun as ReturnType).mock.calls[0]; + const [{ targets }] = (startP2pRun as ReturnType).mock.calls[0]; expect(targets.length).toBeGreaterThan(0); expect(targets.every((t: any) => t.mode === 'audit')).toBe(true); }); @@ -485,7 +531,7 @@ describe('structured P2P routing via WS fields', () => { await new Promise((r) => setTimeout(r, 100)); expect(startP2pRun).toHaveBeenCalledOnce(); - const call = (startP2pRun as ReturnType).mock.calls[0]; - expect(call[5]).toBe(2); + const [{ rounds }] = (startP2pRun as ReturnType).mock.calls[0]; + expect(rounds).toBe(2); }); }); diff --git a/test/daemon/transport-relay.test.ts b/test/daemon/transport-relay.test.ts index 0ec27cdd..00209724 100644 --- a/test/daemon/transport-relay.test.ts +++ b/test/daemon/transport-relay.test.ts @@ -48,12 +48,14 @@ type DeltaCb = (sessionId: string, delta: MessageDelta) => void; type CompleteCb = (sessionId: string, message: AgentMessage) => void; type ErrorCb = (sessionId: string, error: { code: string; message: string; recoverable: boolean }) => void; type ToolCb = (sessionId: string, tool: ToolCallEvent) => void; +type StatusCb = (sessionId: string, status: { status: string | null; label?: string | null }) => void; function makeMockProvider() { let deltaCb: DeltaCb | undefined; let completeCb: CompleteCb | undefined; let errorCb: ErrorCb | undefined; let toolCb: ToolCb | undefined; + let statusCb: StatusCb | undefined; return { provider: { @@ -61,11 +63,13 @@ function makeMockProvider() { onComplete: (cb: CompleteCb) => { completeCb = cb; return () => { completeCb = undefined; }; }, onError: (cb: ErrorCb) => { errorCb = cb; return () => { errorCb = undefined; }; }, onToolCall: (cb: ToolCb) => { toolCb = cb; }, + onStatus: (cb: StatusCb) => { statusCb = cb; return () => { statusCb = undefined; }; }, } as unknown as TransportProvider, fireDelta: (sid: string, delta: MessageDelta) => deltaCb?.(sid, delta), fireComplete: (sid: string, msg: AgentMessage) => completeCb?.(sid, msg), fireError: (sid: string, err: { code: string; message: string; recoverable: boolean }) => errorCb?.(sid, err), fireTool: (sid: string, tool: ToolCallEvent) => toolCb?.(sid, tool), + fireStatus: (sid: string, status: { status: string | null; label?: string | null }) => statusCb?.(sid, status), }; } @@ -140,33 +144,74 @@ describe('transport-relay (timeline-emitter based)', () => { expect(opts.eventId).toBe('transport:sess-a:msg-abc'); }); - it('multiple deltas pass through directly (provider handles accumulation)', () => { + it('throttles streaming updates to at most one emit every 80ms and keeps the latest text', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + const { provider, fireDelta } = makeMockProvider(); wireProviderToRelay(provider); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'foo ' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'bar ' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'baz' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'a' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'ab' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'abc' })); + + expect(emitMock).toHaveBeenCalledTimes(1); + expect(emitMock.mock.calls[0][2].text).toBe('a'); - expect(emitMock).toHaveBeenCalledTimes(3); + vi.advanceTimersByTime(79); + expect(emitMock).toHaveBeenCalledTimes(1); - // Each delta is passed through directly — provider already accumulated - expect(emitMock.mock.calls[0][2].text).toBe('foo '); - expect(emitMock.mock.calls[1][2].text).toBe('bar '); - expect(emitMock.mock.calls[2][2].text).toBe('baz'); + vi.advanceTimersByTime(1); + expect(emitMock).toHaveBeenCalledTimes(2); + expect(emitMock.mock.calls[1][2].text).toBe('abc'); + + vi.useRealTimers(); }); - it('uses the same stable eventId across multiple deltas for the same message', () => { + it('does not let a new message flush an old throttled delta later', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + const { provider, fireDelta } = makeMockProvider(); wireProviderToRelay(provider); - fireDelta('sess-a', makeDelta({ messageId: 'msg-stable', delta: 'a' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-stable', delta: 'b' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-old', delta: 'old-a' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-old', delta: 'old-ab' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-new', delta: 'new-a' })); + + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(2); + expect(emitMock.mock.calls[0][2].text).toBe('old-a'); + expect(emitMock.mock.calls[1][2].text).toBe('new-a'); + + vi.advanceTimersByTime(500); + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(2); + expect(textCalls.map(c => c[2].text)).not.toContain('old-ab'); + + vi.useRealTimers(); + }); + + it('throttles independently per session', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A1' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A2' })); + fireDelta('sess-b', makeDelta({ messageId: 'msg-b', delta: 'B1' })); + fireDelta('sess-b', makeDelta({ messageId: 'msg-b', delta: 'B2' })); + + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(2); + vi.advanceTimersByTime(80); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(4); + expect(textCalls.filter(c => c[0] === 'sess-a').map(c => c[2].text)).toEqual(['A1', 'A2']); + expect(textCalls.filter(c => c[0] === 'sess-b').map(c => c[2].text)).toEqual(['B1', 'B2']); - const id1 = emitMock.mock.calls[0][3].eventId; - const id2 = emitMock.mock.calls[1][3].eventId; - expect(id1).toBe('transport:sess-a:msg-stable'); - expect(id2).toBe('transport:sess-a:msg-stable'); + vi.useRealTimers(); }); it('does NOT cache to JSONL via appendTransportEvent', () => { @@ -286,6 +331,52 @@ describe('transport-relay (timeline-emitter based)', () => { expect(textCall![3].eventId).toBe('transport:sess-1:msg-4'); }); + it('emits final completion immediately even when a throttled delta is pending', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireComplete } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-1', makeDelta({ messageId: 'msg-final', delta: 'a' })); + fireDelta('sess-1', makeDelta({ messageId: 'msg-final', delta: 'ab' })); + emitMock.mockClear(); + + fireComplete('sess-1', makeMessage({ id: 'msg-final', content: 'final answer' })); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][2].text).toBe('final answer'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.advanceTimersByTime(500); + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(1); + + vi.useRealTimers(); + }); + + it('completion still emits immediately when another session has a pending throttled delta', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireComplete } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A1' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A2' })); + emitMock.mockClear(); + + fireComplete('sess-b', makeMessage({ id: 'msg-b', sessionId: 'sess-b', content: 'done-b' })); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe('sess-b'); + expect(textCalls[0][2].text).toBe('done-b'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.useRealTimers(); + }); + it('caches to JSONL via appendTransportEvent with type assistant.text', async () => { const { provider, fireComplete } = makeMockProvider(); wireProviderToRelay(provider); @@ -416,6 +507,30 @@ describe('transport-relay (timeline-emitter based)', () => { expect(textCall?.[2]?.streaming).toBe(false); expect(textCall?.[2]?.text).toBe('partial answer\n\n⚠️ Error: boom'); }); + + it('emits error immediately even when a throttled delta is pending and suppresses the delayed flush', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireError } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-err', makeDelta({ messageId: 'msg-err-2', delta: 'partial' })); + fireDelta('sess-err', makeDelta({ messageId: 'msg-err-2', delta: 'partial+' })); + emitMock.mockClear(); + + fireError('sess-err', { code: 'PROVIDER_ERROR', message: 'boom-now', recoverable: true }); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][2].text).toBe('partial+\n\n⚠️ Error: boom-now'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.advanceTimersByTime(500); + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(1); + + vi.useRealTimers(); + }); }); // ── emitTransportUserMessage ───────────────────────────────────────────── @@ -530,6 +645,36 @@ describe('transport-relay (timeline-emitter based)', () => { expect(call![3].eventId).toBe('transport-tool:sess-tool:tool-1:result'); }); }); + + describe('onStatus', () => { + it('emits agent.status into the timeline for provider status updates', () => { + const { provider, fireStatus } = makeMockProvider(); + wireProviderToRelay(provider); + + fireStatus('sess-status', { status: 'compacting', label: 'Compacting conversation...' }); + + expect(emitMock).toHaveBeenCalledWith( + 'sess-status', + 'agent.status', + { status: 'compacting', label: 'Compacting conversation...' }, + expect.objectContaining({ source: 'daemon', confidence: 'high' }), + ); + }); + + it('emits unlabeled status updates so the frontend can clear stale status text', () => { + const { provider, fireStatus } = makeMockProvider(); + wireProviderToRelay(provider); + + fireStatus('sess-status', { status: null, label: null }); + + expect(emitMock).toHaveBeenCalledWith( + 'sess-status', + 'agent.status', + { status: null, label: null }, + expect.objectContaining({ source: 'daemon', confidence: 'high' }), + ); + }); + }); }); // ── useTimeline same-ID replacement (logic extracted for unit testing) ─────── diff --git a/test/e2e/qwen-transport-flow.test.ts b/test/e2e/qwen-transport-flow.test.ts index 3bf5a89b..cfad5e06 100644 --- a/test/e2e/qwen-transport-flow.test.ts +++ b/test/e2e/qwen-transport-flow.test.ts @@ -215,9 +215,8 @@ describe('qwen transport flow e2e', () => { expect(user?.payload.text).toBe('hello'); expect(running).toBeDefined(); expect(thinking?.payload.text).toBe(''); - expect(streaming.map((e) => e.payload.text)).toEqual(['Qwen', 'Qwen: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Qwen']); expect(streaming[0]?.opts?.eventId).toBe(stableEventId); - expect(streaming[1]?.opts?.eventId).toBe(stableEventId); expect(final?.payload.text).toBe('Qwen: hello'); expect(final?.opts?.eventId).toBe(stableEventId); const usage = mocks.emitted.find((e) => e.session === SESSION && e.type === 'usage.update'); diff --git a/test/e2e/sdk-transport-flow.test.ts b/test/e2e/sdk-transport-flow.test.ts index 41915b7d..6c9b2e52 100644 --- a/test/e2e/sdk-transport-flow.test.ts +++ b/test/e2e/sdk-transport-flow.test.ts @@ -24,6 +24,39 @@ const mocks = vi.hoisted(() => { return { store, emitted, claudeCalls, codexCalls }; }); +const PRESET_ENV = { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_API_KEY: 'test-token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + ANTHROPIC_SMALL_FAST_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'MiniMax-M2.7', + IMCODES_CONTEXT_WINDOW: '200000', +}; + +vi.mock('../../src/daemon/cc-presets.js', () => ({ + getPreset: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' + ? { name: 'minimax', env: PRESET_ENV, contextWindow: 200000 } + : undefined + )), + resolvePresetEnv: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' ? { ...PRESET_ENV } : {} + )), + getPresetTransportOverrides: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' + ? { + model: 'MiniMax-M2.7', + systemPrompt: 'Authoritative runtime model: MiniMax-M2.7.', + } + : {} + )), + getPresetInitMessage: vi.fn(() => 'preset-init'), + invalidateCache: vi.fn(), +})); + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); const { EventEmitter } = await import('node:events'); @@ -389,6 +422,48 @@ describe('sdk transport flow e2e', () => { }); }); + it('preserves cc presets when settings restart switches a main session to claude-code-sdk', async () => { + mocks.store.set('deck_settings_preset_brain', { + name: 'deck_settings_preset_brain', + projectName: 'settings_preset', + role: 'brain', + agentType: 'claude-code', + projectDir: '/tmp/settings-preset', + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + ccSessionId: 'cc-settings-preset', + ccPreset: 'MiniMax', + }); + + const serverLink = { send: vi.fn() } as any; + handleWebCommand({ + type: 'session.restart', + sessionName: 'deck_settings_preset_brain', + agentType: 'claude-code-sdk', + }, serverLink); + await flushAsync(); + await waitForCondition(() => serverLink.send.mock.calls.some((call) => call[0]?.type === 'session_list')); + + const switched = mocks.store.get('deck_settings_preset_brain'); + expect(switched?.agentType).toBe('claude-code-sdk'); + expect(switched?.ccPreset).toBe('MiniMax'); + + handleWebCommand({ type: 'session.send', session: 'deck_settings_preset_brain', text: 'hello', commandId: 'cmd-settings-preset' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + it('pushes a corrective session_list when settings restart fails', async () => { const tmuxNewSession = newSession as ReturnType; tmuxNewSession.mockRejectedValueOnce(new Error('tmux create failed')); @@ -669,6 +744,73 @@ describe('sdk transport flow e2e', () => { expect(serverLink.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'session.error' })); }); + it('persists and applies cc presets for claude-code-sdk main sessions', async () => { + const serverLink = { send: vi.fn() } as any; + + handleWebCommand({ + type: 'session.start', + project: 'ccsdk minimax', + dir: '/tmp/ccsdk-minimax-e2e', + agentType: 'claude-code-sdk', + ccPreset: 'MiniMax', + }, serverLink); + await flushAsync(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const sessionName = 'deck_ccsdk_minimax_brain'; + await waitForCondition(() => !!mocks.store.get(sessionName)); + const record = mocks.store.get(sessionName); + expect(record?.ccPreset).toBe('MiniMax'); + + handleWebCommand({ type: 'session.send', session: sessionName, text: 'hello', commandId: 'cmd-ccsdk-minimax' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + + it('persists and applies cc presets for claude-code-sdk sub-sessions', async () => { + const serverLink = { send: vi.fn() } as any; + + handleWebCommand({ + type: 'subsession.start', + id: 'ccsdk_minimax_sub', + sessionType: 'claude-code-sdk', + cwd: '/tmp/ccsdk-minimax-sub-e2e', + parentSession: 'deck_parent_brain', + ccPreset: 'MiniMax', + }, serverLink); + await flushAsync(); + await waitForCondition(() => serverLink.send.mock.calls.length > 0); + + const sessionName = 'deck_sub_ccsdk_minimax_sub'; + const record = mocks.store.get(sessionName); + expect(record?.ccPreset).toBe('MiniMax'); + expect(serverLink.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'subsession.sync', + id: 'ccsdk_minimax_sub', + ccPresetId: 'MiniMax', + })); + + handleWebCommand({ type: 'session.send', session: sessionName, text: 'hello', commandId: 'cmd-ccsdk-minimax-sub' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + beforeEach(() => { mocks.store.clear(); mocks.emitted.length = 0; @@ -709,9 +851,8 @@ describe('sdk transport flow e2e', () => { const toolResult = mocks.emitted.find((e) => e.session === SESSION_CC && e.type === 'tool.result'); const claudeCall = mocks.claudeCalls.at(-1); - expect(streaming.map((e) => e.payload.text)).toEqual(['Claude', 'Claude: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Claude']); expect(streaming[0]?.opts?.eventId).toBe(stableEventId); - expect(streaming[1]?.opts?.eventId).toBe(stableEventId); expect(final?.payload.text).toBe('Claude: hello'); expect(final?.opts?.eventId).toBe(stableEventId); expect(usage?.payload.model).toBe('claude-sonnet-4-6'); @@ -748,9 +889,8 @@ describe('sdk transport flow e2e', () => { const toolResult = mocks.emitted.find((e) => e.session === SESSION_CX && e.type === 'tool.result'); const ack = mocks.emitted.find((e) => e.session === SESSION_CX && e.type === 'command.ack'); - expect(streaming.map((e) => e.payload.text)).toEqual(['Codex', 'Codex: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Codex']); expect(streaming[0]?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); - expect(streaming[1]?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); expect(final?.payload.text).toBe('Codex: hello'); expect(final?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); expect(usage?.payload.inputTokens).toBe(7); diff --git a/test/shared/p2p-advanced.test.ts b/test/shared/p2p-advanced.test.ts new file mode 100644 index 00000000..04271ab8 --- /dev/null +++ b/test/shared/p2p-advanced.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { + BUILT_IN_ADVANCED_PRESETS, + resolveP2pRoundPlan, + type P2pAdvancedRound, +} from '../../shared/p2p-advanced.js'; + +describe('resolveP2pRoundPlan', () => { + it('uses the shared default hop timeout for legacy runs when no override is provided', () => { + const plan = resolveP2pRoundPlan({ + modeOverride: 'audit', + }); + + expect(plan.advanced).toBe(false); + expect(plan.rounds).toHaveLength(1); + expect(plan.rounds[0]?.timeoutMinutes).toBe(8); + }); + + it('preserves legacy combo behavior when advanced config is absent', () => { + const plan = resolveP2pRoundPlan({ + modeOverride: 'brainstorm>discuss', + roundsOverride: 2, + hopTimeoutMinutes: 8, + }); + + expect(plan.advanced).toBe(false); + expect(plan.rounds).toHaveLength(2); + expect(plan.rounds.map((round) => round.modeKey)).toEqual(['brainstorm', 'discuss']); + expect(plan.rounds.every((round) => round.timeoutMinutes === 8)).toBe(true); + }); + + it('resolves the openspec preset and freezes sdk helper eligibility', () => { + const plan = resolveP2pRoundPlan({ + advancedPresetKey: 'openspec', + advancedRunTimeoutMinutes: 45, + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }, + participants: [ + { sessionName: 'deck_proj_brain', agentType: 'claude-code-sdk' }, + { sessionName: 'deck_sub_worker', agentType: 'qwen', parentSession: 'deck_proj_brain' }, + { sessionName: 'deck_proj_cli', agentType: 'codex' }, + ], + }); + + expect(plan.advanced).toBe(true); + expect(plan.rounds.map((round) => round.id)).toEqual(BUILT_IN_ADVANCED_PRESETS.openspec.map((round) => round.id)); + expect(plan.contextReducer).toEqual({ + mode: 'clone_sdk_session', + templateSession: 'deck_proj_brain', + }); + expect(plan.helperEligibleSnapshot).toEqual([ + { sessionName: 'deck_proj_brain', agentType: 'claude-code-sdk' }, + { sessionName: 'deck_sub_worker', agentType: 'qwen', parentSession: 'deck_proj_brain' }, + ]); + expect(plan.overallRunTimeoutMinutes).toBe(45); + }); + + it('rejects forward jumps in advanced rounds', () => { + const rounds: P2pAdvancedRound[] = [ + { + id: 'implement', + title: 'Implement', + preset: 'implementation', + executionMode: 'multi_dispatch', + permissionScope: 'implementation', + }, + { + id: 'audit', + title: 'Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'audit', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ]; + + expect(() => resolveP2pRoundPlan({ advancedRounds: rounds })).toThrow(/jump backward/i); + }); + + it('rejects artifact-generation rounds without declared outputs', () => { + const rounds: P2pAdvancedRound[] = [ + { + id: 'custom_artifact', + title: 'Custom Artifact', + preset: 'custom', + executionMode: 'single_main', + permissionScope: 'artifact_generation', + }, + ]; + + expect(() => resolveP2pRoundPlan({ advancedRounds: rounds })).toThrow(/must declare artifact outputs/i); + }); + + it('rejects forced_rework rounds whose maxTriggers are below minTriggers', () => { + const rounds: P2pAdvancedRound[] = [ + { + id: 'implementation', + title: 'Implementation', + preset: 'implementation', + executionMode: 'single_main', + permissionScope: 'implementation', + }, + { + id: 'implementation_audit', + title: 'Implementation Audit', + preset: 'implementation_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'forced_rework', + jumpRule: { + targetRoundId: 'implementation', + marker: 'REWORK', + minTriggers: 2, + maxTriggers: 1, + }, + }, + ]; + + expect(() => resolveP2pRoundPlan({ advancedRounds: rounds })).toThrow(/invalid maxTriggers/i); + }); + + it('rejects proposal_audit rounds that attempt to drive routing', () => { + const rounds: P2pAdvancedRound[] = [ + { + id: 'discussion', + title: 'Discussion', + preset: 'discussion', + executionMode: 'multi_dispatch', + permissionScope: 'analysis_only', + }, + { + id: 'proposal_audit', + title: 'Proposal Audit', + preset: 'proposal_audit', + executionMode: 'single_main', + permissionScope: 'analysis_only', + verdictPolicy: 'smart_gate', + jumpRule: { + targetRoundId: 'discussion', + marker: 'REWORK', + minTriggers: 0, + maxTriggers: 2, + }, + }, + ]; + + expect(() => resolveP2pRoundPlan({ advancedRounds: rounds })).toThrow(/proposal_audit cannot drive routing/i); + }); + + it('rejects reducer sessions that are not sdk-backed participants', () => { + expect(() => resolveP2pRoundPlan({ + advancedPresetKey: 'openspec', + contextReducer: { + mode: 'reuse_existing_session', + sessionName: 'deck_proj_cli', + }, + participants: [ + { sessionName: 'deck_proj_cli', agentType: 'codex' }, + ], + })).toThrow(/eligible SDK-backed participant/i); + }); + + it('rejects clone-mode reducer templates that are not sdk-backed participants', () => { + expect(() => resolveP2pRoundPlan({ + advancedPresetKey: 'openspec', + contextReducer: { + mode: 'clone_sdk_session', + templateSession: 'deck_proj_cli', + }, + participants: [ + { sessionName: 'deck_proj_cli', agentType: 'codex' }, + ], + })).toThrow(/eligible SDK-backed participant/i); + }); +}); diff --git a/test/util/windows-launch-artifacts.cmd-parse.test.ts b/test/util/windows-launch-artifacts.cmd-parse.test.ts new file mode 100644 index 00000000..c918d9da --- /dev/null +++ b/test/util/windows-launch-artifacts.cmd-parse.test.ts @@ -0,0 +1,164 @@ +/** + * End-to-end regression test for the Windows watchdog .cmd file. + * + * Why this is its own file (and not part of windows-launch-artifacts.test.ts): + * + * The unit test file mocks `fs/promises` so it can capture the bytes that + * would be written without touching the disk. Vitest's module cache means + * those mocks can leak into other test files in the same worker, so we run + * the watchdog generation in a fresh `node` child process. That gives us + * real fs writes — exactly what runs in production — and lets us invoke + * `cmd.exe` against the file to validate that the parser accepts every + * single line. + * + * The original bug was that `writeWatchdogCmd` produced bytes starting + * with `EF BB BF` (UTF-8 BOM). cmd.exe doesn't strip the BOM; it + * concatenates it with the next token, producing + * `[BOM]@echo is not a recognized command`. The watchdog looped forever + * printing this error and never managed to start the daemon. + * + * A unit-level "no BOM byte" assertion is necessary but not sufficient, + * because there are other ways to make a file unparseable to cmd.exe + * (e.g. UTF-16 LE, mixed CRLF, embedded codepage characters in command + * names). An end-to-end "cmd.exe /c " check catches all of them. + * + * Skipped on non-Windows hosts; both Windows CI jobs include this file. + */ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const isWindows = process.platform === 'win32'; +const __filename = fileURLToPath(import.meta.url); +const repoRoot = resolve(__filename, '..', '..', '..'); + +/** Run a small script in a fresh Node process so test-time vi.mock() calls + * can't affect the production fs/promises module. */ +function generateWatchdogInChildProcess(stagingDir: string): { watchdogPath: string } { + const watchdogPath = join(stagingDir, 'daemon-watchdog.cmd'); + const stubScriptPath = join(stagingDir, 'fake', 'index.js'); + const vbsPath = join(stagingDir, 'daemon-launcher.vbs'); + const logPath = join(stagingDir, 'watchdog.log'); + + // The shim path probe inside writeWatchdogCmd starts from imcodesScript + // and walks up to .../npm. We don't want a real shim to be detected here + // because it would inject %APPDATA%\npm\imcodes.cmd which is fine, but + // either branch (shim or fallback) needs to produce a parseable file. + // Test BOTH branches by running the script twice. + + // ESM imports on Windows need file:// URLs, not bare drive-letter paths. + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-launch-artifacts.js')).href; + const driver = ` + import { writeWatchdogCmd } from ${JSON.stringify(moduleUrl)}; + import { mkdirSync, writeFileSync } from 'fs'; + mkdirSync(${JSON.stringify(join(stagingDir, 'fake'))}, { recursive: true }); + writeFileSync(${JSON.stringify(stubScriptPath)}, '// stub'); + await writeWatchdogCmd({ + nodeExe: process.execPath, + imcodesScript: ${JSON.stringify(stubScriptPath)}, + watchdogPath: ${JSON.stringify(watchdogPath)}, + vbsPath: ${JSON.stringify(vbsPath)}, + logPath: ${JSON.stringify(logPath)}, + }); + `; + const driverPath = join(stagingDir, 'driver.mjs'); + writeFileSync(driverPath, driver); + + const result = spawnSync(process.execPath, [driverPath], { + encoding: 'utf8', + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error( + `driver failed (status=${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + } + return { watchdogPath }; +} + +describe('watchdog .cmd file (Windows cmd.exe parser regression)', () => { + it.skipIf(!isWindows)('first byte is NOT 0xEF (UTF-8 BOM)', () => { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-watchdog-bom-')); + try { + const { watchdogPath } = generateWatchdogInChildProcess(dir); + const buf = readFileSync(watchdogPath); + expect(buf.length).toBeGreaterThan(0); + // No UTF-8 BOM + expect(buf[0]).not.toBe(0xEF); + expect(buf[1]).not.toBe(0xBB); + expect(buf[2]).not.toBe(0xBF); + // No UTF-16 LE BOM either + expect(buf[0] === 0xFF && buf[1] === 0xFE).toBe(false); + // First non-empty line must be exactly `@echo off` + const firstLine = buf.toString('utf8').split(/\r?\n/).find((l) => l.trim().length > 0); + expect(firstLine).toBe('@echo off'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(!isWindows)('cmd.exe parses every line — no "is not a recognized command" errors', () => { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-watchdog-cmdparse-')); + try { + const { watchdogPath } = generateWatchdogInChildProcess(dir); + + // Make a "safe" copy that does NOT actually try to launch a daemon and + // does NOT loop forever. We: + // - Replace the upgrade-lock check with a no-op so a stale lock on + // the host doesn't make the script wait forever + // - Replace the daemon-launch line with a marker echo + // - Replace the loop tail with `exit /b 0` so cmd.exe terminates + const original = readFileSync(watchdogPath, 'utf8'); + const safe = original + // Neutralise the upgrade-lock check entirely + .replace(/if exist "[^"]*upgrade\.lock" \([\s\S]*?\)\r?\n/m, 'rem upgrade-lock check disabled in test\r\n') + // Replace either of the two possible launch forms with a marker + .replace(/^call .*$/m, 'echo WATCHDOG_OK') + // Strip the loop tail so the script terminates + .replace(/timeout \/t 5 \/nobreak >nul[\r\n]+goto loop/m, 'exit /b 0'); + const safePath = join(dir, 'safe-watchdog.cmd'); + writeFileSync(safePath, safe); + + const result = spawnSync('cmd.exe', ['/c', safePath], { + encoding: 'utf8', + windowsHide: true, + }); + + const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + // Two failure modes we are guarding against: + // 1. '@echo' is not recognized as an internal or external command + // (BOM glued to the first @echo) + // 2. '"...\imcodes.cmd"' is not recognized as an internal or + // external command (cmd.exe quoted-command parse rule) + expect(combined).not.toMatch(/is not recognized as an internal or external command/i); + // Sanity: the script reached the marker echo + expect(combined).toContain('WATCHDOG_OK'); + expect(result.status).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(!isWindows)('cmd.exe handles env-var expansion correctly with non-ASCII USERPROFILE', () => { + // Don't actually need a non-ASCII USERPROFILE on the host — we just need + // to verify that the .cmd file routes everything through %USERPROFILE% + // and %APPDATA% so cmd.exe can expand them with the OS native API at + // runtime, regardless of the codepage of the actual user folder name. + const dir = mkdtempSync(join(tmpdir(), 'imcodes-watchdog-envvar-')); + try { + const { watchdogPath } = generateWatchdogInChildProcess(dir); + const cmd = readFileSync(watchdogPath, 'utf8'); + // Lock file path + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\upgrade.lock'); + // Log file path + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\watchdog.log'); + // No raw drive-letter user paths leaking through + expect(cmd).not.toMatch(/C:\\Users\\[^\\]+\\\.imcodes\\upgrade\.lock/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/util/windows-launch-artifacts.test.ts b/test/util/windows-launch-artifacts.test.ts index 79159270..c8c7e8f5 100644 --- a/test/util/windows-launch-artifacts.test.ts +++ b/test/util/windows-launch-artifacts.test.ts @@ -48,9 +48,17 @@ vi.mock('child_process', () => ({ execSync: vi.fn(() => ''), })); +/** Reset the existsSync mock to its default (returns true for shim paths) + * so that one test's mockReturnValue(false) doesn't bleed into the next. */ +async function resetExistsSyncMock(): Promise { + const { existsSync } = await import('fs'); + (existsSync as ReturnType).mockImplementation((path: string) => path.endsWith('imcodes.cmd')); +} + describe('writeWatchdogCmd', () => { - beforeEach(() => { + beforeEach(async () => { for (const k of Object.keys(written)) delete written[k]; + await resetExistsSyncMock(); }); it('generates watchdog with upgrade lock check', async () => { @@ -73,16 +81,16 @@ describe('writeWatchdogCmd', () => { expect(lockCheck).toBeGreaterThan(-1); expect(launchCmd).toBeGreaterThan(lockCheck); - // Lock file path must be in the check - const lockPath = UPGRADE_LOCK_FILE.replace(/\//g, '\\'); - expect(cmd).toContain(lockPath); + // Lock file path must be in the check (now via %USERPROFILE% expansion + // so cmd.exe handles non-ASCII usernames natively at runtime). + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\upgrade.lock'); // When locked, should wait and loop back (not launch daemon) expect(cmd).toContain('Upgrade in progress, waiting'); expect(cmd).toContain('goto loop'); }); - it('uses npm global shim instead of hard-coded node+script paths', async () => { + it('uses %APPDATA%\\npm\\imcodes.cmd via env-var expansion (no hardcoded path)', async () => { const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', @@ -94,11 +102,42 @@ describe('writeWatchdogCmd', () => { await writeWatchdogCmd(paths); const cmd = written[paths.watchdogPath]; - // Should use the shim, not the raw node+script - expect(cmd).toContain('imcodes.cmd'); + // Should reference the shim via %APPDATA% so cmd.exe expands at runtime + // (this is the only way non-ASCII usernames work without encoding loss). + expect(cmd).toContain('%APPDATA%\\npm\\imcodes.cmd'); + expect(cmd).not.toContain('C:\\Users\\X\\AppData'); expect(cmd).not.toContain('node_modules'); }); + it('prefixes the launch line with `call` so the loop resumes after daemon exit', async () => { + const paths = { + nodeExe: 'C:\\Program Files\\nodejs\\node.exe', + imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', + watchdogPath: 'C:\\Users\\X\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\X\\.imcodes\\daemon-launcher.vbs', + logPath: 'C:\\Users\\X\\.imcodes\\watchdog.log', + }; + await writeWatchdogCmd(paths); + const cmd = written[paths.watchdogPath]; + // Without `call`, control hands off to the .cmd shim and never returns, + // so the watchdog loop dies after the first daemon exit. + expect(cmd).toContain('call "%APPDATA%\\npm\\imcodes.cmd" start --foreground'); + }); + + it('uses %USERPROFILE% env-var expansion for the lock file path', async () => { + const paths = { + nodeExe: 'node.exe', + imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', + watchdogPath: 'C:\\Users\\X\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\X\\.imcodes\\daemon-launcher.vbs', + logPath: 'C:\\Users\\X\\.imcodes\\watchdog.log', + }; + await writeWatchdogCmd(paths); + const cmd = written[paths.watchdogPath]; + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\upgrade.lock'); + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\watchdog.log'); + }); + it('falls back to node+script when shim not found', async () => { // Override existsSync to return false for shim const { existsSync } = await import('fs'); @@ -139,9 +178,10 @@ describe('writeWatchdogCmd', () => { }); describe('writeVbsLauncher', () => { - beforeEach(() => { + beforeEach(async () => { for (const k of Object.keys(written)) delete written[k]; for (const k of Object.keys(writtenRaw)) delete writtenRaw[k]; + await resetExistsSyncMock(); }); it('runs watchdog CMD hidden (window style 0)', async () => { @@ -163,8 +203,8 @@ describe('writeVbsLauncher', () => { it('writes VBS as UTF-16 LE with BOM (required for non-ASCII paths)', async () => { const paths = { nodeExe: '', imcodesScript: '', logPath: '', - watchdogPath: 'C:\\Users\\云科I\\.imcodes\\daemon-watchdog.cmd', - vbsPath: 'C:\\Users\\云科I\\.imcodes\\daemon-launcher.vbs', + watchdogPath: 'C:\\Users\\用户测试\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\用户测试\\.imcodes\\daemon-launcher.vbs', }; await writeVbsLauncher(paths); @@ -178,7 +218,7 @@ describe('writeVbsLauncher', () => { expect(raw[1]).toBe(0xFE); // Decoded content must contain the Chinese path intact const decoded = raw.slice(2).toString('utf16le'); - expect(decoded).toContain('云科I'); + expect(decoded).toContain('用户测试'); expect(decoded).toContain('daemon-watchdog.cmd'); } }); @@ -196,55 +236,112 @@ describe('writeVbsLauncher', () => { }); }); -describe('encodeCmdAsUtf8Bom', () => { - it('prepends UTF-8 BOM (EF BB BF)', async () => { - const buf = encodeCmdAsUtf8Bom('@echo off\r\necho 云科\r\n'); - expect(buf[0]).toBe(0xEF); - expect(buf[1]).toBe(0xBB); - expect(buf[2]).toBe(0xBF); - // Rest is UTF-8 of the content - const decoded = buf.slice(3).toString('utf8'); - expect(decoded).toContain('云科'); +describe('encodeCmdAsUtf8Bom (deprecated)', () => { + it('returns plain UTF-8 — cmd.exe does not understand BOMs in batch files', async () => { + // Regression: a previous implementation prepended an EF BB BF BOM here. + // cmd.exe parses the BOM as part of the first command on the first line, + // so `[BOM]@echo off` becomes "[BOM]@echo is not a recognized command". + // The function MUST NOT emit a BOM. + const buf = encodeCmdAsUtf8Bom('@echo off\r\necho 测试用户\r\n'); + expect(buf[0]).not.toBe(0xEF); + expect(buf[1]).not.toBe(0xBB); + expect(buf[2]).not.toBe(0xBF); + // Content is plain UTF-8 + expect(buf.toString('utf8')).toContain('测试用户'); + expect(buf.toString('utf8').startsWith('@echo off')).toBe(true); }); }); describe('encodeVbsAsUtf16', () => { it('prepends UTF-16 LE BOM (FF FE)', async () => { - const buf = encodeVbsAsUtf16('WScript.Echo "云科"'); + const buf = encodeVbsAsUtf16('WScript.Echo "测试用户"'); expect(buf[0]).toBe(0xFF); expect(buf[1]).toBe(0xFE); const decoded = buf.slice(2).toString('utf16le'); - expect(decoded).toContain('云科'); + expect(decoded).toContain('测试用户'); }); }); -describe('writeWatchdogCmd encoding', () => { - beforeEach(() => { +describe('writeWatchdogCmd encoding (regression: cmd.exe BOM bug)', () => { + beforeEach(async () => { for (const k of Object.keys(written)) delete written[k]; for (const k of Object.keys(writtenRaw)) delete writtenRaw[k]; + await resetExistsSyncMock(); }); - it('writes watchdog .cmd as UTF-8 with BOM (required for non-ASCII paths)', async () => { + it('writes watchdog .cmd WITHOUT a UTF-8 BOM', async () => { + // REGRESSION GUARD — see fix(daemon-watchdog): the previous implementation + // wrote a UTF-8 BOM at the start of the .cmd file. cmd.exe doesn't strip + // the BOM; instead it concatenates the BOM bytes with the next token, + // producing `[BOM]@echo is not a recognized command`. The watchdog + // looped forever printing this error and never managed to start the + // daemon. The fix: write plain UTF-8 with no BOM. const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', - imcodesScript: 'C:\\Users\\云科I\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', - watchdogPath: 'C:\\Users\\云科I\\.imcodes\\daemon-watchdog.cmd', - vbsPath: 'C:\\Users\\云科I\\.imcodes\\daemon-launcher.vbs', - logPath: 'C:\\Users\\云科I\\.imcodes\\watchdog.log', + imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', + watchdogPath: 'C:\\Users\\X\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\X\\.imcodes\\daemon-launcher.vbs', + logPath: 'C:\\Users\\X\\.imcodes\\watchdog.log', }; - await writeWatchdogCmd(paths); const raw = writtenRaw[paths.watchdogPath]; - expect(Buffer.isBuffer(raw)).toBe(true); + // Either string (utf8) or Buffer is allowed, but the first 3 bytes + // must NOT be the UTF-8 BOM. if (Buffer.isBuffer(raw)) { - // UTF-8 BOM - expect(raw[0]).toBe(0xEF); - expect(raw[1]).toBe(0xBB); - expect(raw[2]).toBe(0xBF); - // Path with Chinese characters preserved as UTF-8 - const decoded = raw.slice(3).toString('utf8'); - expect(decoded).toContain('云科I'); + expect(raw[0]).not.toBe(0xEF); + } else if (typeof raw === 'string') { + expect(raw.charCodeAt(0)).not.toBe(0xFEFF); + } else { + throw new Error('writeWatchdogCmd produced no output'); + } + + // The first non-empty line must be exactly `@echo off` so cmd.exe + // recognises it as the disable-echo directive. + const decoded = written[paths.watchdogPath]; + const firstLine = decoded.split(/\r?\n/).find((line) => line.trim().length > 0); + expect(firstLine).toBe('@echo off'); + }); + + it('survives non-ASCII usernames by using %APPDATA% / %USERPROFILE% expansion', async () => { + // The fix for non-ASCII paths is NOT to encode the bytes correctly — + // it's to never bake the path into the file at all. cmd.exe expands + // env vars at runtime via the OS native wide-char API, so the actual + // username encoding doesn't matter. + const paths = { + nodeExe: 'C:\\Program Files\\nodejs\\node.exe', + // Path with Chinese chars only matters for the SHIM detection probe. + imcodesScript: 'C:\\Users\\用户测试\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', + watchdogPath: 'C:\\Users\\用户测试\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\用户测试\\.imcodes\\daemon-launcher.vbs', + logPath: 'C:\\Users\\用户测试\\.imcodes\\watchdog.log', + }; + await writeWatchdogCmd(paths); + const cmd = written[paths.watchdogPath]; + + // The Chinese username MUST NOT appear inside the file. Everything is + // routed through %APPDATA% and %USERPROFILE%. + expect(cmd).not.toContain('用户测试'); + expect(cmd).toContain('%APPDATA%\\npm\\imcodes.cmd'); + expect(cmd).toContain('%USERPROFILE%\\.imcodes\\watchdog.log'); + }); + + it('every line is plain ASCII (no embedded user paths)', async () => { + // Belt-and-suspenders: scan every byte to ensure the watchdog file + // contains no characters above 0x7F. Anything above means we baked + // a user path in by mistake — and that user path could be any + // codepage on a real Windows machine. + const paths = { + nodeExe: 'C:\\Program Files\\nodejs\\node.exe', + imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', + watchdogPath: 'C:\\Users\\X\\.imcodes\\daemon-watchdog.cmd', + vbsPath: 'C:\\Users\\X\\.imcodes\\daemon-launcher.vbs', + logPath: 'C:\\Users\\X\\.imcodes\\watchdog.log', + }; + await writeWatchdogCmd(paths); + const cmd = written[paths.watchdogPath]; + for (let i = 0; i < cmd.length; i++) { + expect(cmd.charCodeAt(i)).toBeLessThan(0x80); } }); }); diff --git a/test/util/windows-stale-watchdog-cleanup.test.ts b/test/util/windows-stale-watchdog-cleanup.test.ts new file mode 100644 index 00000000..d2c84a64 --- /dev/null +++ b/test/util/windows-stale-watchdog-cleanup.test.ts @@ -0,0 +1,325 @@ +/** + * Windows-only end-to-end test: the watchdog cleanup logic must reliably + * tree-kill orphan daemon-watchdog cmd.exe processes by command-line pattern. + * + * This test runs ONLY on Windows CI. It is the regression guard for + * + * fix(daemon-watchdog): cmd.exe BOM bug + watchdog crash-loop recovery + * + * Scenario: + * 1. An OLD imcodes install wrote daemon-watchdog.cmd with a UTF-8 BOM. + * cmd.exe parses [BOM]@echo as the unknown command "[BOM]@echo" and + * crash-loops forever. + * 2. The user runs `imcodes restart` or `imcodes upgrade` (which calls + * `imcodes repair-watchdog`). + * 3. The cleanup logic must find every cmd.exe process whose command line + * references daemon-watchdog and tree-kill it. Otherwise the old + * crash-loop keeps running and overwrites the PID file with stale data. + * + * The kill is implemented in two places that share the same wmic+taskkill + * pattern: + * - src/util/windows-daemon.ts (killAllStaleWatchdogs) — used by `restart` + * - src/util/windows-launch-artifacts.ts (killAllStaleWatchdogsBeforeRegen) + * — used by `repair-watchdog` + * + * Both call sites are exercised by this test by importing the source modules + * in a fresh Node child process (vitest mocks would otherwise interfere). + * + * Skipped on non-Windows hosts; both Windows CI jobs include this file. + */ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const isWindows = process.platform === 'win32'; +const __filename = fileURLToPath(import.meta.url); +const repoRoot = resolve(__filename, '..', '..', '..'); + +/** Wait until predicate returns true or timeout (ms) elapses. */ +async function waitFor(pred: () => boolean, timeoutMs: number, intervalMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (pred()) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return pred(); +} + +/** Check whether a PID is still alive. + * + * Uses `tasklist` (available on every Windows Server / Windows 10/11) + * rather than `wmic` because `wmic` is deprecated and not always installed + * on newer GitHub Actions Windows runner images. */ +function pidAlive(pid: number): boolean { + if (!isWindows) return false; + const result = spawnSync('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { + encoding: 'utf8', + windowsHide: true, + }); + // tasklist with /nh /fo csv outputs `"name.exe","PID",...` + // When no process matches, stdout is empty or contains "INFO:". + const stdout = result.stdout ?? ''; + return stdout.includes(`"${pid}"`); +} + +/** Spawn a fake daemon-watchdog cmd.exe process that just loops printing. */ +function spawnFakeWatchdog(stagingDir: string): number { + const fakePath = join(stagingDir, 'daemon-watchdog.cmd'); + writeFileSync( + fakePath, + '@echo off\r\n:loop\r\nping -n 60 127.0.0.1 >nul\r\ngoto loop\r\n', + ); + const child = spawn('cmd.exe', ['/c', fakePath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + return child.pid!; +} + +/** Run a small script in a fresh Node process so test-time vi.mock() calls + * can't affect the production module under test. */ +function runInChildProcess(driverSource: string): { status: number; stdout: string; stderr: string } { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-stale-driver-')); + try { + const driverPath = join(dir, 'driver.mjs'); + writeFileSync(driverPath, driverSource); + const result = spawnSync(process.execPath, [driverPath], { + encoding: 'utf8', + windowsHide: true, + }); + return { + status: result.status ?? -1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + } finally { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +} + +describe('stale watchdog cleanup (Windows-only end-to-end)', () => { + it.skipIf(!isWindows)('killAllStaleWatchdogs() in windows-daemon.ts kills orphan cmd.exe by command-line pattern', async () => { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-stale-1-')); + let fakePid: number | null = null; + try { + fakePid = spawnFakeWatchdog(dir); + // Wait until wmic actually sees it + const spawned = await waitFor(() => pidAlive(fakePid!), 3000); + expect(spawned, `fake watchdog PID ${fakePid} did not appear`).toBe(true); + + // Run killAllStaleWatchdogs() in a child process so vitest mocks don't + // touch the real fs/child_process modules. Also dump what PowerShell + // sees so CI failures expose the actual CommandLine values. + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-daemon.js')).href; + const driverSource = ` + import { killAllStaleWatchdogs } from ${JSON.stringify(moduleUrl)}; + import { execSync } from 'child_process'; + console.error('[driver] before kill, fakePid=${fakePid}'); + // Diagnostic: show every cmd.exe and its CommandLine so we can see + // why the filter may not be matching on this Windows runner. + try { + const all = execSync('powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter \\\\"Name=\\'cmd.exe\\'\\\\" | Select-Object ProcessId, CommandLine | Format-List"', { encoding: 'utf8' }); + console.error('[driver] all cmd.exe processes:\\n' + all); + } catch (e) { console.error('[driver] diag failed:', String(e)); } + killAllStaleWatchdogs(); + console.error('[driver] after kill'); + `; + const result = runInChildProcess(driverSource); + expect(result.status, `driver failed: status=${result.status} stdout=${result.stdout} stderr=${result.stderr}`).toBe(0); + + // Wait up to 15s for the PowerShell+taskkill chain to complete. + // PowerShell startup alone is 1-3s on CI runners, so 5s is too tight. + const dead = await waitFor(() => !pidAlive(fakePid!), 15000); + expect(dead, `fake watchdog PID ${fakePid} should be killed.\nDriver stdout: ${result.stdout}\nDriver stderr: ${result.stderr}`).toBe(true); + } finally { + // Best-effort cleanup if the test failed + if (fakePid && pidAlive(fakePid)) { + spawnSync('taskkill', ['/f', '/t', '/pid', String(fakePid)], { windowsHide: true }); + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60_000); + + it.skipIf(!isWindows)('regenerateAllArtifacts() in windows-launch-artifacts.ts also kills stale watchdogs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-stale-2-')); + let fakePid: number | null = null; + try { + fakePid = spawnFakeWatchdog(dir); + const spawned = await waitFor(() => pidAlive(fakePid!), 3000); + expect(spawned, `fake watchdog PID ${fakePid} did not appear`).toBe(true); + + // We need regenerateAllArtifacts to exercise the kill path WITHOUT + // touching the real ~/.imcodes directory. Stub the entry point that + // resolves paths so it returns staging paths instead. + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-launch-artifacts.js')).href; + const stagingPaths = { + nodeExe: process.execPath, + imcodesScript: join(dir, 'fake', 'index.js'), + watchdogPath: join(dir, 'daemon-watchdog-fake.cmd'), + vbsPath: join(dir, 'daemon-launcher.vbs'), + logPath: join(dir, 'watchdog.log'), + }; + const driverSource = ` + import { writeWatchdogCmd, writeVbsLauncher } from ${JSON.stringify(moduleUrl)}; + import { mkdirSync, writeFileSync } from 'fs'; + mkdirSync(${JSON.stringify(join(dir, 'fake'))}, { recursive: true }); + writeFileSync(${JSON.stringify(stagingPaths.imcodesScript)}, '// stub'); + // We can't call regenerateAllArtifacts directly because it uses the + // user's home dir. Instead reproduce its kill logic via the dedicated + // helper that the daemon module exports for restart use. + const { killAllStaleWatchdogs } = await import(${JSON.stringify(pathToFileURL(join(repoRoot, 'dist/src/util/windows-daemon.js')).href)}); + console.error('[driver] before kill, fakePid=${fakePid}'); + killAllStaleWatchdogs(); + console.error('[driver] after kill'); + await writeWatchdogCmd(${JSON.stringify(stagingPaths)}); + await writeVbsLauncher(${JSON.stringify(stagingPaths)}); + `; + const result = runInChildProcess(driverSource); + expect(result.status, `driver failed: status=${result.status} stdout=${result.stdout} stderr=${result.stderr}`).toBe(0); + + const dead = await waitFor(() => !pidAlive(fakePid!), 15000); + expect(dead, `fake watchdog PID ${fakePid} should be killed`).toBe(true); + } finally { + if (fakePid && pidAlive(fakePid)) { + spawnSync('taskkill', ['/f', '/t', '/pid', String(fakePid)], { windowsHide: true }); + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60_000); + + it.skipIf(!isWindows)('killAllStaleWatchdogs() is a no-op when no watchdogs are running', async () => { + // Make sure we don't accidentally kill unrelated cmd.exe processes. + // Spawn a non-watchdog cmd.exe and verify it survives. + const dir = mkdtempSync(join(tmpdir(), 'imcodes-stale-3-')); + let unrelatedPid: number | null = null; + try { + const benignPath = join(dir, 'benign-loop.cmd'); + writeFileSync( + benignPath, + '@echo off\r\n:loop\r\nping -n 60 127.0.0.1 >nul\r\ngoto loop\r\n', + ); + const child = spawn('cmd.exe', ['/c', benignPath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + unrelatedPid = child.pid!; + const spawned = await waitFor(() => pidAlive(unrelatedPid!), 3000); + expect(spawned).toBe(true); + + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-daemon.js')).href; + const driverSource = ` + import { killAllStaleWatchdogs } from ${JSON.stringify(moduleUrl)}; + killAllStaleWatchdogs(); + `; + runInChildProcess(driverSource); + + // Give the kill enough time to propagate + await new Promise((r) => setTimeout(r, 1500)); + + // Unrelated cmd.exe must still be alive + expect(pidAlive(unrelatedPid!)).toBe(true); + } finally { + if (unrelatedPid) { + spawnSync('taskkill', ['/f', '/t', '/pid', String(unrelatedPid)], { windowsHide: true }); + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60_000); + + // ── killOrphanDaemonProcesses() — guards bind/restart/repair/upgrade ──── + // Verifies the helper that handles the "orphan daemon holding the named + // pipe" edge case. Spawns a real node.exe at a path that matches the + // production filter (`*node_modules\imcodes\dist*`) — this is the EXACT + // path layout npm produces for the global imcodes install. + // + // CRITICAL: the production filter must be SPECIFIC. An earlier loose + // `*imcodes*` filter killed the test runner itself (because the repo + // working directory is C:\Users\\imcodes-src — which contains + // "imcodes"). This test verifies the precise filter targeting. + + it.skipIf(!isWindows)('killOrphanDaemonProcesses() kills a node.exe at the production daemon path', async () => { + const dir = mkdtempSync(join(tmpdir(), 'imcodes-orphan-1-')); + let pid: number | null = null; + try { + // Build the EXACT path layout production uses: + // /node_modules/imcodes/dist/src/index.js + const fakeDir = join(dir, 'node_modules', 'imcodes', 'dist', 'src'); + const { mkdirSync } = await import('node:fs'); + mkdirSync(fakeDir, { recursive: true }); + const fakePath = join(fakeDir, 'index.js'); + writeFileSync(fakePath, 'setInterval(() => {}, 60_000);'); + + const child = spawn(process.execPath, [fakePath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + pid = child.pid!; + const spawned = await waitFor(() => pidAlive(pid!), 5000); + expect(spawned, `fake orphan PID ${pid} did not appear`).toBe(true); + + // Run killOrphanDaemonProcesses() in a child node so vitest doesn't + // pollute the production module. + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-daemon.js')).href; + const driverSource = + `import { killOrphanDaemonProcesses } from ${JSON.stringify(moduleUrl)};\n` + + `const k = killOrphanDaemonProcesses();\n` + + `console.error('[driver] killed=' + k);\n`; + const result = runInChildProcess(driverSource); + expect(result.status, `driver: ${result.stderr}`).toBe(0); + + // PowerShell startup is slow on CI runners → 15s grace + const dead = await waitFor(() => !pidAlive(pid!), 15_000); + expect(dead, `fake orphan ${pid} should be killed.\nDriver: ${result.stderr}`).toBe(true); + } finally { + if (pid && pidAlive(pid)) { + spawnSync('taskkill', ['/f', '/pid', String(pid)], { windowsHide: true }); + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60_000); + + it.skipIf(!isWindows)('killOrphanDaemonProcesses() does NOT kill an unrelated node.exe (precise filter)', async () => { + // Spawn a node.exe whose path does NOT match the production filter + // even though it's in a tmp dir. Production filter is + // `*node_modules\imcodes\dist*` — must NOT match "unrelated.js". + const dir = mkdtempSync(join(tmpdir(), 'imcodes-orphan-2-')); + let benignPid: number | null = null; + try { + const benignPath = join(dir, 'unrelated-script.js'); + writeFileSync(benignPath, 'setInterval(() => {}, 60_000);'); + const child = spawn(process.execPath, [benignPath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + benignPid = child.pid!; + const spawned = await waitFor(() => pidAlive(benignPid!), 5000); + expect(spawned).toBe(true); + + const moduleUrl = pathToFileURL(join(repoRoot, 'dist/src/util/windows-daemon.js')).href; + const driverSource = + `import { killOrphanDaemonProcesses } from ${JSON.stringify(moduleUrl)};\n` + + `killOrphanDaemonProcesses();\n`; + runInChildProcess(driverSource); + + await new Promise((r) => setTimeout(r, 3000)); + expect(pidAlive(benignPid!), `unrelated node.exe ${benignPid} must NOT be killed`).toBe(true); + } finally { + if (benignPid && pidAlive(benignPid)) { + spawnSync('taskkill', ['/f', '/pid', String(benignPid)], { windowsHide: true }); + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60_000); + +}); diff --git a/test/util/windows-upgrade-script.test.ts b/test/util/windows-upgrade-script.test.ts index 3fb3ed60..115305db 100644 --- a/test/util/windows-upgrade-script.test.ts +++ b/test/util/windows-upgrade-script.test.ts @@ -38,8 +38,8 @@ describe('buildWindowsCleanupVbs', () => { }); it('handles non-ASCII paths in the cleanup target', () => { - const vbs = buildWindowsCleanupVbs('C:\\Users\\云科I\\Temp\\cleanup.cmd'); - expect(vbs).toContain('云科I'); + const vbs = buildWindowsCleanupVbs('C:\\Users\\测试用户A\\Temp\\cleanup.cmd'); + expect(vbs).toContain('测试用户A'); }); }); @@ -100,24 +100,51 @@ describe('buildWindowsUpgradeBatch', () => { // ── Daemon + old watchdog kill ── - it('kills old watchdog tree before npm install', () => { - const killTreeIdx = batch.indexOf('taskkill /f /t /pid !WD_PID!'); + it('finds and tree-kills ALL daemon-watchdog cmd.exe processes by command-line pattern', () => { + // REGRESSION GUARD — when an old watchdog is in a crash-loop because the + // OLD watchdog.cmd had a UTF-8 BOM (cmd.exe parses [BOM]@echo as the + // unknown command "[BOM]@echo"), there is no daemon.pid to walk back + // from. The upgrade script must enumerate watchdogs by their cmd line. + expect(batch).toContain('taskkill /f /t /pid !STALE_WD!'); + // Must run BEFORE npm install + const killByPatternIdx = batch.indexOf('taskkill /f /t /pid !STALE_WD!'); const installIdx = batch.indexOf(`call "${INPUT.npmCmd}" install`); - expect(killTreeIdx).toBeGreaterThan(-1); - expect(killTreeIdx).toBeLessThan(installIdx); + expect(killByPatternIdx).toBeGreaterThan(-1); + expect(killByPatternIdx).toBeLessThan(installIdx); }); - it('kills daemon directly as belt-and-suspenders', () => { - expect(batch).toContain('taskkill /f /pid !OLD_PID!'); + it('uses BOTH PowerShell and wmic so it works on every Windows version', () => { + // PowerShell works on Windows 7+; wmic is being removed from Windows 11 + // and Server 2025 images. Try PowerShell first, fall back to wmic. + expect(batch).toContain('powershell -NoProfile -NonInteractive'); + expect(batch).toContain('Get-CimInstance Win32_Process'); + expect(batch).toContain('daemon-watchdog'); + expect(batch).toContain('wmic process where'); + // Both branches should resolve to the same taskkill + const psIdx = batch.indexOf('powershell -NoProfile'); + const wmicIdx = batch.indexOf('wmic process where'); + // PowerShell branch must come first (preferred) + expect(psIdx).toBeGreaterThan(-1); + expect(wmicIdx).toBeGreaterThan(psIdx); }); - it('finds watchdog parent via wmic', () => { - expect(batch).toContain('wmic process where'); - expect(batch).toContain('ParentProcessId'); + it('kills daemon directly via PIDFILE as belt-and-suspenders', () => { + expect(batch).toContain('taskkill /f /pid !OLD_PID!'); }); // ── npm install ── + it('raises NODE_OPTIONS heap limit before npm install while preserving existing flags', () => { + const nodeOptionsIdx = batch.indexOf('set "NODE_OPTIONS=%NODE_OPTIONS% --max-old-space-size=16384"'); + const fallbackIdx = batch.indexOf('set "NODE_OPTIONS=--max-old-space-size=16384"'); + const installIdx = batch.indexOf(`call "${INPUT.npmCmd}" install -g ${INPUT.pkgSpec}`); + expect(nodeOptionsIdx).toBeGreaterThan(-1); + expect(fallbackIdx).toBeGreaterThan(-1); + expect(nodeOptionsIdx).toBeLessThan(installIdx); + expect(fallbackIdx).toBeLessThan(installIdx); + expect(batch).toContain('echo Using NODE_OPTIONS=%NODE_OPTIONS% >> "%LOG_FILE%"'); + }); + it('installs with quoted npm path', () => { expect(batch).toContain(`call "${INPUT.npmCmd}" install -g ${INPUT.pkgSpec}`); }); @@ -194,12 +221,12 @@ describe('buildWindowsUpgradeBatch', () => { it('avoids embedding non-ASCII user paths directly in the batch body', () => { const nonAscii = buildWindowsUpgradeBatch({ ...INPUT, - logFile: 'C:\\Users\\云科1\\AppData\\Local\\Temp\\imcodes-upgrade-123\\upgrade.log', - cleanupVbsPath: 'C:\\Users\\云科1\\AppData\\Local\\Temp\\imcodes-upgrade-123\\cleanup.vbs', - vbsLauncherPath: 'C:\\Users\\云科1\\.imcodes\\daemon-launcher.vbs', - upgradeLockFile: 'C:\\Users\\云科1\\.imcodes\\upgrade.lock', + logFile: 'C:\\Users\\测试用户B\\AppData\\Local\\Temp\\imcodes-upgrade-123\\upgrade.log', + cleanupVbsPath: 'C:\\Users\\测试用户B\\AppData\\Local\\Temp\\imcodes-upgrade-123\\cleanup.vbs', + vbsLauncherPath: 'C:\\Users\\测试用户B\\.imcodes\\daemon-launcher.vbs', + upgradeLockFile: 'C:\\Users\\测试用户B\\.imcodes\\upgrade.lock', }); - expect(nonAscii).not.toContain('云科1'); + expect(nonAscii).not.toContain('测试用户B'); expect(nonAscii).toContain('%USERPROFILE%\\.imcodes\\daemon-launcher.vbs'); expect(nonAscii).toContain('%SCRIPT_DIR%\\cleanup.vbs'); }); diff --git a/web/src/app.tsx b/web/src/app.tsx index 9eacd124..6ff06dd7 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -5,66 +5,8 @@ import { type FileBrowserPreviewState, type FileBrowserPreviewUpdate, } from './components/FileBrowser.js'; -import { mapP2pStatusToUiState, type P2pActivePhase, type P2pProgressNodeStatus } from '@shared/p2p-status.js'; import { DAEMON_MSG } from '@shared/daemon-events.js'; - -function mapP2pRunToDiscussion(r: Record) { - const rawSnapshot = r.progress_snapshot; - const snapshot = typeof rawSnapshot === 'string' - ? (() => { try { return JSON.parse(rawSnapshot) as Record; } catch { return {}; } })() - : (rawSnapshot ?? {}); - const source = { ...r, ...snapshot } as Record; - const id = `p2p_${source.id}`; - const status = String(source.status ?? ''); - const state = mapP2pStatusToUiState(status); - const mode = source.mode_key ?? 'discuss'; - const currentRoundMode = source.current_round_mode ?? mode; - const initiatorLabel = source.initiator_label ?? 'brain'; - const currentTarget = source.current_target_label ?? (source.current_target_session ? String(source.current_target_session).split('_').pop() : undefined); - const totalCount = source.total_count ?? 3; - const totalHops = source.total_hops ?? Math.max(0, totalCount - 2); - const nodes = Array.isArray(source.all_nodes) ? source.all_nodes.map((n: any) => ({ - session: typeof n.session === 'string' ? n.session : undefined, - label: String(n.label ?? ''), - displayLabel: String(n.displayLabel ?? n.display_label ?? n.label ?? ''), - agentType: String(n.agentType ?? ''), - ccPreset: n.ccPreset ?? n.cc_preset ?? null, - mode: typeof n.mode === 'string' ? n.mode : undefined, - phase: typeof n.phase === 'string' ? n.phase as 'initial' | 'hop' | 'summary' : undefined, - status: String(n.status ?? 'pending') as P2pProgressNodeStatus, - })) : undefined; - const hopStates = Array.isArray(source.hop_states) ? source.hop_states.map((hop: any) => ({ - hopIndex: Number(hop.hop_index ?? 0), - roundIndex: Number(hop.round_index ?? 0), - session: typeof hop.session === 'string' ? hop.session : undefined, - mode: typeof hop.mode === 'string' ? hop.mode : undefined, - status: String(hop.status ?? 'queued') as 'queued' | 'dispatched' | 'running' | 'completed' | 'timed_out' | 'failed' | 'cancelled', - })) : undefined; - return { - id, - topic: `P2P ${mode} · ${initiatorLabel}`, - state, - modeKey: currentRoundMode, - currentRound: source.current_round ?? 1, - maxRounds: source.total_rounds ?? 1, - completedHops: source.completed_hops_count ?? 0, - completedRoundHops: typeof source.completed_round_hops_count === 'number' ? source.completed_round_hops_count : undefined, - totalHops, - activeHop: source.active_hop_number ?? null, - activeRoundHop: source.active_round_hop_number ?? null, - activePhase: (typeof source.active_phase === 'string' ? source.active_phase : 'queued') as P2pActivePhase, - initiatorLabel, - currentSpeaker: currentTarget, - conclusion: state === 'done' ? (source.result_summary ?? undefined) : undefined, - error: state === 'failed' ? (source.error ?? undefined) : undefined, - filePath: undefined, - fileId: source.discussion_id ? String(source.discussion_id) : source.id, - startedAt: source.created_at ? new Date(source.created_at).getTime() : undefined, - hopStartedAt: typeof source.hop_started_at === 'number' ? source.hop_started_at : undefined, - nodes, - hopStates, - }; -} +import { mapP2pRunToDiscussion, mergeP2pDiscussionUpdate } from './p2p-run-mapping.js'; import { useTranslation } from 'react-i18next'; import { ErrorBoundary } from './components/ErrorBoundary.js'; import { LanguageSwitcher } from './components/LanguageSwitcher.js'; @@ -109,7 +51,7 @@ import { WsClient } from './ws-client.js'; import { configure as configureApi, apiFetch, onAuthExpired, getUserPref, startProactiveRefresh, stopProactiveRefresh, refreshSessionIfStale, ApiError, configureApiKey, clearApiKey, fetchMe, getApiKey, normalizeLocalWebPreviewPath, listP2pRuns } from './api.js'; import { isNative, getServerUrl, clearServerUrl } from './native.js'; import { getAuthKey, clearAuthKey } from './biometric-auth.js'; -import { initPushNotifications } from './push-notifications.js'; +import { initPushNotifications, resetPushBadge } from './push-notifications.js'; import { ServerSetupPage } from './pages/ServerSetupPage.js'; import { NativeAuthBridge } from './pages/NativeAuthBridge.js'; import type { SessionInfo, TerminalDiff } from './types.js'; @@ -122,6 +64,7 @@ import { extractTransportPendingMessages } from './transport-queue.js'; import { ingestTimelineEventForCache } from './hooks/useTimeline.js'; import { getMobileKeyboardState } from './mobile-keyboard.js'; import { pickReadableSessionDisplay } from '@shared/session-display.js'; +import { getSelectedServerName } from './server-selection.js'; // On web: if opened by the native app for passkey auth, render the bridge page. const nativeCallback = typeof window !== 'undefined' @@ -253,6 +196,30 @@ export function App() { watchProjectionStore.beginServerSwitch(selectedServerId); }, [selectedServerId]); + useEffect(() => { + if (selectedServerId) { + localStorage.setItem('rcc_server', selectedServerId); + return; + } + localStorage.removeItem('rcc_server'); + }, [selectedServerId]); + + const resolvedSelectedServerName = useMemo( + () => getSelectedServerName(selectedServerId, servers, selectedServerName), + [selectedServerId, selectedServerName, servers], + ); + + useEffect(() => { + if (!selectedServerId || servers.length === 0) return; + if (resolvedSelectedServerName === selectedServerName) return; + setSelectedServerName(resolvedSelectedServerName); + if (resolvedSelectedServerName) { + localStorage.setItem('rcc_server_name', resolvedSelectedServerName); + return; + } + localStorage.removeItem('rcc_server_name'); + }, [resolvedSelectedServerName, selectedServerId, selectedServerName, servers.length]); + useEffect(() => { let cleanup = () => {}; void onWatchCommand((command) => { @@ -409,6 +376,21 @@ export function App() { }); }, [auth]); + // Native: clear server-side push badge whenever the app becomes visible with + // a valid auth context. AppDelegate also tries via JS bridge, but that can + // fire before the web bundle is ready, leaving badge_count stale. + useEffect(() => { + if (!auth || !isNative()) return; + void resetPushBadge(); + const onVisible = () => { + if (document.visibilityState === 'visible') void resetPushBadge(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => { + document.removeEventListener('visibilitychange', onVisible); + }; + }, [auth]); + // When session expires mid-session (refresh failed), clear auth and show login. // Registered once so any apiFetch 401 after refresh failure lands here. useEffect(() => { @@ -522,16 +504,8 @@ export function App() { try { const data = await apiFetch<{ servers: ServerInfo[] }>('/api/server'); setServers(data.servers); - // Populate selected server name if missing - if (selectedServerId) { - const found = data.servers.find((s) => s.id === selectedServerId); - if (found && !selectedServerName) { - localStorage.setItem('rcc_server_name', found.name); - setSelectedServerName(found.name); - } - } } catch { /* ignore */ } - }, [auth, selectedServerId, selectedServerName]); + }, [auth]); useEffect(() => { loadServers(); }, [loadServers]); @@ -1158,8 +1132,7 @@ export function App() { } } if (msg.type === 'session_list') { - const watchServerName = servers.find((server) => server.id === selectedServerId)?.name - ?? selectedServerName + const watchServerName = resolvedSelectedServerName ?? selectedServerId; // Build sub-session inputs from app state (daemon filters them from session_list) // Use ref to avoid stale closure — subSessions state may not be in useEffect deps @@ -1472,7 +1445,7 @@ export function App() { setDiscussions((prev) => { const existing = prev.find((d) => d.id === entry.id); return existing - ? prev.map((d) => d.id === entry.id ? { ...d, ...entry } : d) + ? prev.map((d) => d.id === entry.id ? mergeP2pDiscussionUpdate(d, entry) : d) : [...prev, entry]; }); @@ -1502,7 +1475,7 @@ export function App() { const merged = [...retained]; for (const entry of mapped) { const idx = merged.findIndex((d) => d.id === entry.id); - if (idx >= 0) merged[idx] = { ...merged[idx], ...entry }; + if (idx >= 0) merged[idx] = mergeP2pDiscussionUpdate(merged[idx], entry); else merged.push(entry); } return merged; @@ -2337,7 +2310,7 @@ export function App() { serverId: selectedServerId ?? '', subSessions, inputRefsMap, - onPreviewFile: (request) => handlePreviewFileRequest({ ...request, sourcePreviewLive: true }), + onPreviewFile: (request) => handlePreviewFileRequest({ ...request, sourcePreviewLive: false }), onPreviewStateChange: handlePreviewStateChange, activeSession, activeProjectDir: activeSessionInfo?.projectDir, @@ -2465,7 +2438,7 @@ export function App() { class="mobile-server-btn" onClick={() => setShowMobileServerMenu((o) => !o)} > - {selectedServerName ?? 'Server'} ▾ + {resolvedSelectedServerName ?? 'Server'} ▾ {showMobileServerMenu && ( <> diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 9d9e7c8e..a68046d6 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -55,6 +55,14 @@ interface ViewItem { lastTs?: number; } +interface AssistantBlockProps { + text: string; + ts: number; + onPathClick?: (p: string) => void; + onUrlClick?: (url: string) => void; + onDownload?: (path: string) => void; +} + const TOOL_INPUT_SUMMARY_KEYS = [ 'query', 'command', @@ -398,6 +406,33 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde document.addEventListener('mouseup', onUp); }, [sessionId]); + const handlePathClick = useCallback((path: string) => { + setFileBrowserPath(path.replace(/^`+|`+$/g, '')); + }, []); + + const handleUrlClick = useCallback((url: string) => { + setPendingUrl(url); + }, []); + + const handleDownload = useCallback((path: string) => { + if (!serverId || !ws) return; + const reqId = ws.fsReadFile(path); + const unsub = ws.onMessage((msg) => { + if (msg.type !== 'fs.read_response' || msg.requestId !== reqId) return; + unsub(); + if (msg.downloadId) { + import('../api.js').then(({ downloadAttachment }) => { + downloadAttachment(serverId, msg.downloadId as string).catch(() => {}); + }); + } + }); + setTimeout(unsub, 30_000); + }, [serverId, ws]); + + const pathClickHandler = ws && !preview ? handlePathClick : undefined; + const urlClickHandler = !preview ? handleUrlClick : undefined; + const downloadHandler = serverId && ws ? handleDownload : undefined; + const viewItems = useMemo(() => buildViewItems(events), [events]); const scrollToBottom = () => { @@ -763,31 +798,19 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde {!loading && viewItems.map((item, idx) => { const nextItem = viewItems[idx + 1]; const nextTs = nextItem?.ts ?? nextItem?.event?.ts; - const onPathClick = ws && !preview ? (p: string) => setFileBrowserPath(p.replace(/^`+|`+$/g, '')) : undefined; - const onUrlClick = !preview ? (url: string) => setPendingUrl(url) : undefined; - const onDownload = serverId && ws ? (p: string) => { - const reqId = ws.fsReadFile(p); - const unsub = ws.onMessage((msg) => { - if (msg.type !== 'fs.read_response' || msg.requestId !== reqId) return; - unsub(); - if (msg.downloadId) { - import('../api.js').then(({ downloadAttachment }) => { - downloadAttachment(serverId!, msg.downloadId as string).catch(() => {}); - }); - } - }); - // Auto-cleanup after 30s - setTimeout(unsub, 30_000); - } : undefined; return item.type === 'assistant-block' ? ( -
- - -
+ ) : item.type === 'tool-group' ? ( - + ) : ( - + ); })} {!loading &&
} @@ -1022,6 +1045,21 @@ function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPat // ToolInputFold removed — replaced by unified ToolBlockFold (CSS max-height based) +const AssistantBlock = memo(function AssistantBlock({ + text, + ts, + onPathClick, + onUrlClick, + onDownload, +}: AssistantBlockProps) { + return ( +
+ + +
+ ); +}); + function AttachmentDownloadButton({ att, serverId, onPathClick }: { att: { id: string; originalName?: string; size?: number; daemonPath?: string }; serverId: string; onPathClick?: (p: string) => void }) { const { t } = useTranslation(); const [error, setError] = useState(null); diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx index bb64a8dc..ef1638dd 100644 --- a/web/src/components/FileBrowser.tsx +++ b/web/src/components/FileBrowser.tsx @@ -177,6 +177,27 @@ export interface FileBrowserPreviewUpdate { preview: FileBrowserPreviewState; } +export function mergePreviewState( + current: FileBrowserPreviewState, + incoming: FileBrowserPreviewState, +): FileBrowserPreviewState { + if (current.status === 'idle') return incoming; + const currentPath = 'path' in current ? current.path : null; + const incomingPath = 'path' in incoming ? incoming.path : null; + if (!currentPath || !incomingPath || currentPath !== incomingPath) return incoming; + if (incoming.status === 'loading') return current; + if (current.status === 'ok' && incoming.status === 'ok') { + return { + ...current, + ...incoming, + diff: incoming.diff ?? current.diff, + diffHtml: incoming.diffHtml ?? current.diffHtml, + downloadId: incoming.downloadId ?? current.downloadId, + }; + } + return incoming; +} + /** File extensions that can be previewed with office document libraries. */ const OFFICE_EXTENSIONS: Record = { '.pdf': 'application/pdf', @@ -199,6 +220,108 @@ function updateNode(nodes: FsNode[], targetId: string, patch: Partial): }); } +type ChangeFile = { path: string; code: string; additions?: number; deletions?: number }; +type SharedChangesListener = (files: ChangeFile[]) => void; +type PendingPreviewRequest = { path: string; cycleId: number }; + +interface SharedChangesEntry { + repoPath: string; + files: ChangeFile[]; + updatedAt: number; + inFlightRequestId: string | null; + queued: boolean; + listeners: Set; + ws: WsClient | null; +} + +const SHARED_CHANGES_TTL_MS = 5_000; +const sharedChangesByKey = new Map(); +const sharedChangesRequestKey = new Map(); +const wsIds = new WeakMap(); +let nextWsId = 1; + +export function __resetFileBrowserSharedChangesForTests(): void { + sharedChangesByKey.clear(); + sharedChangesRequestKey.clear(); + nextWsId = 1; +} + +function getWsId(ws: WsClient): number { + let id = wsIds.get(ws); + if (!id) { + id = nextWsId++; + wsIds.set(ws, id); + } + return id; +} + +function getSharedChangesKey(ws: WsClient, repoPath: string): string { + return `${getWsId(ws)}::${repoPath}`; +} + +function getSharedChangesEntry(key: string): SharedChangesEntry { + let entry = sharedChangesByKey.get(key); + if (!entry) { + entry = { repoPath: '', files: [], updatedAt: 0, inFlightRequestId: null, queued: false, listeners: new Set(), ws: null }; + sharedChangesByKey.set(key, entry); + } + return entry; +} + +function subscribeSharedChanges(key: string, listener: SharedChangesListener): () => void { + const entry = getSharedChangesEntry(key); + entry.listeners.add(listener); + if (entry.updatedAt > 0) listener(entry.files); + return () => { + const current = sharedChangesByKey.get(key); + if (!current) return; + current.listeners.delete(listener); + if (current.listeners.size === 0 && !current.inFlightRequestId) { + sharedChangesByKey.delete(key); + } + }; +} + +function publishSharedChanges(key: string, files: ChangeFile[]): void { + const entry = getSharedChangesEntry(key); + entry.files = files; + entry.updatedAt = Date.now(); + for (const listener of entry.listeners) listener(files); +} + +function requestSharedChanges(key: string, ws: WsClient, repoPath: string, force = false): void { + const entry = getSharedChangesEntry(key); + entry.ws = ws; + entry.repoPath = repoPath; + const fresh = entry.updatedAt > 0 && (Date.now() - entry.updatedAt) < SHARED_CHANGES_TTL_MS; + if (!force && fresh) { + publishSharedChanges(key, entry.files); + return; + } + if (entry.inFlightRequestId) { + entry.queued = true; + return; + } + const requestId = ws.fsGitStatus(repoPath, { includeStats: true }); + entry.inFlightRequestId = requestId; + sharedChangesRequestKey.set(requestId, key); +} + +function settleSharedChangesRequest(requestId: string, files: ChangeFile[] | null): boolean { + const key = sharedChangesRequestKey.get(requestId); + if (!key) return false; + sharedChangesRequestKey.delete(requestId); + const entry = sharedChangesByKey.get(key); + if (!entry) return true; + entry.inFlightRequestId = null; + if (files) publishSharedChanges(key, files); + if (entry.queued && entry.ws) { + entry.queued = false; + requestSharedChanges(key, entry.ws, entry.repoPath, true); + } + return true; +} + export function FileBrowser({ ws, @@ -281,18 +404,19 @@ export function FileBrowser({ setPanelViewRaw(v); try { localStorage.setItem('rcc_fb_tab', v); } catch { /* ignore */ } }; - const [changesFiles, setChangesFiles] = useState>([]); - const pendingChangesRef = useRef(new Set()); // all in-flight changesRootPath git status requestIds + const [changesFiles, setChangesFiles] = useState([]); const loadedRef = useRef(new Set()); const pendingRef = useRef(new Map()); // requestId → nodeId const timersRef = useRef(new Map>()); - const pendingReadRef = useRef(new Map()); // requestId → filePath + const pendingReadRef = useRef(new Map()); const pendingGitStatusRef = useRef(new Map()); // requestId → dirPath - const pendingGitDiffRef = useRef(new Map()); // requestId → filePath + const pendingGitDiffRef = useRef(new Map()); const pendingMkdirRef = useRef(new Map()); const mountedRef = useRef(true); const dismissedAutoPreviewPathRef = useRef(null); + const nextPreviewCycleIdRef = useRef(1); + const activePreviewCycleRef = useRef(null); // History navigation const historyRef = useRef([startPath]); @@ -308,7 +432,6 @@ export function FileBrowser({ pendingGitStatusRef.current.clear(); pendingGitDiffRef.current.clear(); pendingMkdirRef.current.clear(); - pendingChangesRef.current.clear(); editorMsgHandlers.current.clear(); if (pendingChangesTimerRef.current) clearTimeout(pendingChangesTimerRef.current); for (const timer of timersRef.current.values()) clearTimeout(timer); @@ -316,6 +439,55 @@ export function FileBrowser({ }; }, []); + const getActivePreviewCycle = useCallback((path?: string): PendingPreviewRequest | null => { + const active = activePreviewCycleRef.current; + if (!active) return null; + if (path && active.path !== path) return null; + return active; + }, []); + + const hasPendingPreviewWork = useCallback((kind: 'read' | 'diff', path: string, cycleId?: number): boolean => { + const active = cycleId !== undefined ? { path, cycleId } : getActivePreviewCycle(path); + if (!active) return false; + const pending = kind === 'read' ? pendingReadRef.current : pendingGitDiffRef.current; + for (const request of pending.values()) { + if (request.path === active.path && request.cycleId === active.cycleId) return true; + } + return false; + }, [getActivePreviewCycle]); + + const fetchDir = useCallback((nodePath: string) => { + if (loadedRef.current.has(nodePath)) return; + const inFlight = [...pendingRef.current.values()].includes(nodePath); + if (inFlight) return; + + setData((prev) => updateNode(prev, nodePath, { isLoading: true })); + let requestId: string; + try { + requestId = ws.fsListDir(nodePath, includeFiles, !!serverId); + } catch { + setData((prev) => updateNode(prev, nodePath, { isLoading: false })); + return; + } + pendingRef.current.set(requestId, nodePath); + // Tree/subtree refreshes only need lightweight status without includeStats. + try { + const gitId = ws.fsGitStatus(nodePath); + pendingGitStatusRef.current.set(gitId, nodePath); + } catch { /* ws disconnected — skip git status */ } + + const timer = setTimeout(() => { + if (!mountedRef.current) return; + if (pendingRef.current.has(requestId)) { + pendingRef.current.delete(requestId); + timersRef.current.delete(requestId); + setData((prev) => updateNode(prev, nodePath, { isLoading: false })); + setError(t('file_browser.timeout_detail', { defaultValue: t('file_browser.timeout') })); + } + }, REQUEST_TIMEOUT_MS); + timersRef.current.set(requestId, timer); + }, [includeFiles, serverId, t, ws]); + // Listen for fs.ls_response and fs.read_response // IMPORTANT: Every setState call is guarded by mountedRef to prevent crashes // when responses arrive after component unmount (race condition with FloatingPanel close). @@ -327,7 +499,9 @@ export function FileBrowser({ if (msg.type === DAEMON_MSG.RECONNECTED || (msg.type === 'session.event' && (msg as any).event === 'connected')) { loadedRef.current.clear(); pendingRef.current.clear(); - pendingChangesRef.current.clear(); + pendingReadRef.current.clear(); + pendingGitDiffRef.current.clear(); + activePreviewCycleRef.current = null; // Re-fetch root and changes if (mountedRef.current) fetchDir(startPath); return; @@ -395,9 +569,12 @@ export function FileBrowser({ } if (msg.type === 'fs.read_response') { - const filePath = pendingReadRef.current.get(msg.requestId); - if (!filePath) return; + const pending = pendingReadRef.current.get(msg.requestId); + if (!pending) return; pendingReadRef.current.delete(msg.requestId); + const active = getActivePreviewCycle(); + if (!active || active.path !== pending.path || active.cycleId !== pending.cycleId) return; + const filePath = pending.path; if (!mountedRef.current) return; @@ -452,18 +629,8 @@ export function FileBrowser({ } if (msg.type === 'fs.git_status_response') { - // Check if this is a changesRootPath request - if (pendingChangesRef.current.has(msg.requestId)) { - pendingChangesRef.current.delete(msg.requestId); - if (!mountedRef.current) return; - if (msg.status === 'ok' && msg.files) { - setChangesFiles(msg.files as Array<{ path: string; code: string; additions?: number; deletions?: number }>); - setModifiedFiles((prev) => { - const next = new Map(prev); - for (const f of msg.files!) next.set(f.path, f.code); - return next; - }); - } + const sharedFiles = msg.status === 'ok' ? ((msg.files as ChangeFile[] | undefined) ?? []) : []; + if (settleSharedChangesRequest(msg.requestId, sharedFiles)) { return; } const dirPath = pendingGitStatusRef.current.get(msg.requestId); @@ -486,9 +653,12 @@ export function FileBrowser({ } if (msg.type === 'fs.git_diff_response') { - const filePath = pendingGitDiffRef.current.get(msg.requestId); - if (!filePath) return; + const pending = pendingGitDiffRef.current.get(msg.requestId); + if (!pending) return; pendingGitDiffRef.current.delete(msg.requestId); + const active = getActivePreviewCycle(); + if (!active || active.path !== pending.path || active.cycleId !== pending.cycleId) return; + const filePath = pending.path; if (!mountedRef.current) return; if (msg.status === 'ok') { const diff = msg.diff ?? ''; @@ -520,58 +690,38 @@ export function FileBrowser({ return; } }); - }, [ws, showHidden, highlightPath, t]); - - const fetchDir = useCallback((nodePath: string) => { - if (loadedRef.current.has(nodePath)) return; - const inFlight = [...pendingRef.current.values()].includes(nodePath); - if (inFlight) return; - - setData((prev) => updateNode(prev, nodePath, { isLoading: true })); - let requestId: string; - try { - requestId = ws.fsListDir(nodePath, includeFiles, !!serverId); - } catch { - setData((prev) => updateNode(prev, nodePath, { isLoading: false })); - return; - } - pendingRef.current.set(requestId, nodePath); - // Fetch git status for this directory - try { - const gitId = ws.fsGitStatus(nodePath); - pendingGitStatusRef.current.set(gitId, nodePath); - } catch { /* ws disconnected — skip git status */ } - - const timer = setTimeout(() => { - if (!mountedRef.current) return; - if (pendingRef.current.has(requestId)) { - pendingRef.current.delete(requestId); - timersRef.current.delete(requestId); - setData((prev) => updateNode(prev, nodePath, { isLoading: false })); - setError(t('file_browser.timeout_detail', { defaultValue: t('file_browser.timeout') })); - } - }, REQUEST_TIMEOUT_MS); - timersRef.current.set(requestId, timer); - }, [ws, includeFiles, t]); + }, [fetchDir, getActivePreviewCycle, startPath, showHidden, highlightPath, t, ws]); const fetchPreview = useCallback((filePath: string, preferDiff = false) => { if (editDirtyRef.current) { if (!window.confirm(t('fileBrowser.unsavedChanges'))) return; } + if (onPreviewFile) { + onPreviewFile({ path: filePath, preferDiff, preview: { status: 'loading', path: filePath } }); + return; + } dismissedAutoPreviewPathRef.current = null; setEditDirty(false); setEditContent(''); setOriginalMtime(undefined); setIsEditing(() => { try { return localStorage.getItem(PREF_KEY) === '1'; } catch { return false; } }); + const active = getActivePreviewCycle(filePath); + const cycleId = active && (hasPendingPreviewWork('read', filePath, active.cycleId) || hasPendingPreviewWork('diff', filePath, active.cycleId)) + ? active.cycleId + : nextPreviewCycleIdRef.current++; + activePreviewCycleRef.current = { path: filePath, cycleId }; const loadingPreview: FileBrowserPreviewState = { status: 'loading', path: filePath }; setPreview(loadingPreview); setShowDiff(preferDiff); - if (onPreviewFile) onPreviewFile({ path: filePath, preferDiff, preview: loadingPreview }); - const requestId = ws.fsReadFile(filePath); - pendingReadRef.current.set(requestId, filePath); - const diffId = ws.fsGitDiff(filePath); - pendingGitDiffRef.current.set(diffId, filePath); - }, [ws, t, onPreviewFile]); + if (!hasPendingPreviewWork('read', filePath, cycleId)) { + const requestId = ws.fsReadFile(filePath); + pendingReadRef.current.set(requestId, { path: filePath, cycleId }); + } + if (!hasPendingPreviewWork('diff', filePath, cycleId)) { + const diffId = ws.fsGitDiff(filePath); + pendingGitDiffRef.current.set(diffId, { path: filePath, cycleId }); + } + }, [getActivePreviewCycle, hasPendingPreviewWork, onPreviewFile, t, ws]); const [expandedPaths, setExpandedPaths] = useState>(() => new Set([startPath])); @@ -634,7 +784,9 @@ export function FileBrowser({ // Clear stale state from previous ws instance loadedRef.current.clear(); pendingRef.current.clear(); - pendingChangesRef.current.clear(); + pendingReadRef.current.clear(); + pendingGitDiffRef.current.clear(); + activePreviewCycleRef.current = null; for (const timer of timersRef.current.values()) clearTimeout(timer); timersRef.current.clear(); setData([{ id: startPath, name: startPath, isDir: true, children: [] }]); @@ -643,9 +795,26 @@ export function FileBrowser({ fetchDir(startPath); }, [startPath, fetchDir]); + useEffect(() => { + if (!changesRootPath) return; + const cacheKey = getSharedChangesKey(ws, changesRootPath); + return subscribeSharedChanges(cacheKey, (files) => { + if (!mountedRef.current) return; + setChangesFiles(files); + setModifiedFiles((prev) => { + const next = new Map(prev); + for (const [k] of next) { + if (k.startsWith(changesRootPath + '/')) next.delete(k); + } + for (const file of files) next.set(file.path, file.code); + return next; + }); + }); + }, [changesRootPath, ws]); + useEffect(() => { if (!initialPreview || initialPreview.status === 'idle') return; - setPreview(initialPreview); + setPreview((prev) => mergePreviewState(prev, initialPreview)); }, [initialPreview]); useEffect(() => { @@ -676,16 +845,18 @@ export function FileBrowser({ if (currentPreviewPath === autoPreviewPath && preview.status !== 'idle') { setShowDiff(autoPreviewPreferDiff); if (preview.status === 'loading' && initialPreview?.status === 'loading' && !skipAutoPreviewIfLoading) { - fetchPreview(autoPreviewPath, autoPreviewPreferDiff); + const hasPendingRead = hasPendingPreviewWork('read', autoPreviewPath); + if (!hasPendingRead) fetchPreview(autoPreviewPath, autoPreviewPreferDiff); } return; } fetchPreview(autoPreviewPath, autoPreviewPreferDiff); - }, [autoPreviewPath, autoPreviewPreferDiff, fetchPreview, initialPreview, preview, skipAutoPreviewIfLoading]); + }, [autoPreviewPath, autoPreviewPreferDiff, fetchPreview, hasPendingPreviewWork, initialPreview, preview, skipAutoPreviewIfLoading]); const dismissPreview = useCallback(() => { if (editDirty && !window.confirm(t('fileBrowser.unsavedChanges'))) return; if (autoPreviewPath) dismissedAutoPreviewPathRef.current = autoPreviewPath; + activePreviewCycleRef.current = null; setIsEditing(false); setEditDirty(false); setPreview({ status: 'idle' }); @@ -703,10 +874,12 @@ export function FileBrowser({ const timer = setInterval(() => { if (!mountedRef.current) return; try { + const cycleId = nextPreviewCycleIdRef.current++; + activePreviewCycleRef.current = { path, cycleId }; const reqId = ws.fsReadFile(path); - pendingReadRef.current.set(reqId, path); + pendingReadRef.current.set(reqId, { path, cycleId }); const diffId = ws.fsGitDiff(path); - pendingGitDiffRef.current.set(diffId, path); + pendingGitDiffRef.current.set(diffId, { path, cycleId }); } catch { /* ws disconnected */ } }, 5000); return () => clearInterval(timer); @@ -717,50 +890,48 @@ export function FileBrowser({ const lastChangesRefreshRef = useRef(0); const pendingChangesTimerRef = useRef | null>(null); + const changesVisible = !!changesRootPath && panelView === 'changes'; + const refreshChanges = useCallback(() => { if (!changesRootPath) return; + const cacheKey = getSharedChangesKey(ws, changesRootPath); const now = Date.now(); const elapsed = now - lastChangesRefreshRef.current; if (elapsed >= CHANGES_RATE_LIMIT_MS) { lastChangesRefreshRef.current = now; - try { - const requestId = ws.fsGitStatus(changesRootPath); - pendingChangesRef.current.add(requestId); - } catch { return; } + requestSharedChanges(cacheKey, ws, changesRootPath); } else { // Schedule for when rate limit clears if (pendingChangesTimerRef.current) clearTimeout(pendingChangesTimerRef.current); pendingChangesTimerRef.current = setTimeout(() => { if (!mountedRef.current) return; lastChangesRefreshRef.current = Date.now(); - try { - const requestId = ws.fsGitStatus(changesRootPath); - pendingChangesRef.current.add(requestId); - } catch { /* ws disconnected */ } + requestSharedChanges(cacheKey, ws, changesRootPath, true); }, CHANGES_RATE_LIMIT_MS - elapsed); } }, [changesRootPath, ws]); // Initial fetch on mount useEffect(() => { - if (!changesRootPath) return; + if (!changesVisible) return; refreshChanges(); - }, [changesRootPath, ws]); // eslint-disable-line react-hooks/exhaustive-deps + }, [changesVisible, changesRootPath, ws]); // eslint-disable-line react-hooks/exhaustive-deps // 30s polling useEffect(() => { - if (!changesRootPath) return; + if (!changesVisible) return; const id = setInterval(() => { if (mountedRef.current) refreshChanges(); }, 30_000); return () => clearInterval(id); - }, [changesRootPath, refreshChanges]); + }, [changesVisible, refreshChanges]); // External refresh trigger (e.g. from tool.call events in ChatView) useEffect(() => { + if (!changesVisible) return; if (refreshTrigger === undefined || refreshTrigger === 0) return; refreshChanges(); - }, [refreshTrigger, refreshChanges]); + }, [changesVisible, refreshTrigger, refreshChanges]); // Reload tree when showHidden changes useEffect(() => { @@ -821,8 +992,8 @@ export function FileBrowser({ const alreadySet = new Set(alreadyInserted); const usesExternalPreview = !!onPreviewFile; - const hasPreview = mode !== 'dir-only' && preview.status !== 'idle'; - const hasInlinePreview = hasPreview && !usesExternalPreview; + const hasInlinePreview = mode !== 'dir-only' && preview.status !== 'idle' && !usesExternalPreview; + const hasPreview = hasInlinePreview; const previewPath = preview.status !== 'idle' ? (preview as { path: string }).path : null; @@ -1016,8 +1187,7 @@ export function FileBrowser({ {t('file_browser.changes_title', { count: changesFiles.length })} {changesRootPath && ( )}
@@ -1177,8 +1347,6 @@ export function FileBrowser({ ) : null; - const showEmbeddedChangesSection = !!changesSection && !usesExternalPreview; - if (layout === 'panel') { const tabs = changesRootPath ? (
@@ -1216,10 +1384,9 @@ export function FileBrowser({ {tabs} {breadcrumb} {newFolderDialog} -
+
{tree} - {showEmbeddedChangesSection ? changesSection : null}
{hasPreview && (
= {}; + if (ccPreset && (agentType === 'claude-code' || agentType === 'qwen')) extra.ccPreset = ccPreset; + if (ccInitPrompt.trim() && agentType === 'claude-code') extra.ccInitPrompt = ccInitPrompt.trim(); ws.sendSessionCommand('start', { project: project.trim(), dir: dir.trim(), agentType, - ...(ccPreset ? { ccPreset } : {}), - ...(ccInitPrompt.trim() ? { ccInitPrompt: ccInitPrompt.trim() } : {}), + ...extra, ...((agentType === 'claude-code-sdk' || agentType === 'codex-sdk' || agentType === 'qwen') ? { thinking } : {}), }); } @@ -192,7 +194,7 @@ export function NewSessionDialog({ ws, onClose, onSessionStarted, isProviderConn : agentType === 'openclaw' ? OPENCLAW_THINKING_LEVELS : []; - const supportsCcPreset = agentType === 'claude-code' || agentType === 'claude-code-sdk'; + const supportsCcPreset = agentType === 'claude-code' || agentType === 'qwen'; useEffect(() => { setThinking('high'); diff --git a/web/src/components/P2pConfigPanel.tsx b/web/src/components/P2pConfigPanel.tsx index 61734c1c..4cea1c9f 100644 --- a/web/src/components/P2pConfigPanel.tsx +++ b/web/src/components/P2pConfigPanel.tsx @@ -2,12 +2,19 @@ * P2pConfigPanel — modal settings panel for P2P config mode. * Lets the user configure per-session participation and modes, plus round count. */ -import { useState, useEffect } from 'preact/hooks'; +import { useState, useEffect, useMemo } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; import { getUserPref, saveUserPref } from '../api.js'; import { P2pComboManager } from './P2pComboManager.js'; import { useP2pCustomCombos } from './p2p-combos.js'; import type { P2pSavedConfig, P2pSessionConfig } from '@shared/p2p-modes.js'; +import { BUILT_IN_ADVANCED_PRESETS } from '@shared/p2p-advanced.js'; +import type { + P2pAdvancedPresetKey, + P2pAdvancedRound, + P2pContextReducerConfig, + P2pContextReducerMode, +} from '@shared/p2p-advanced.js'; interface SessionRow { name: string; @@ -156,6 +163,25 @@ const sectionCardStyle: Record = { padding: 14, }; +const fieldLabelStyle: Record = { + display: 'flex', + flexDirection: 'column', + gap: 6, + fontSize: 12, + color: '#cbd5e1', +}; + +const fieldInputStyle: Record = { + width: '100%', + background: '#0f172a', + border: '1px solid #334155', + borderRadius: 6, + color: '#e2e8f0', + fontSize: 13, + padding: '7px 9px', + outline: 'none', +}; + const roundsBtnStyle = (active: boolean): Record => ({ padding: '4px 12px', borderRadius: 6, @@ -253,8 +279,24 @@ export function P2pConfigPanel({ const [rounds, setRounds] = useState(3); const [hopTimeoutMinutes, setHopTimeoutMinutes] = useState(8); const [extraPrompt, setExtraPrompt] = useState(''); + const [advancedExpanded, setAdvancedExpanded] = useState(false); + const [advancedPresetKey, setAdvancedPresetKey] = useState(''); + const [advancedRounds, setAdvancedRounds] = useState(undefined); + const [advancedRunTimeoutMinutes, setAdvancedRunTimeoutMinutes] = useState(30); + const [contextReducerMode, setContextReducerMode] = useState(''); + const [contextReducerSession, setContextReducerSession] = useState(''); + const [contextReducerTemplate, setContextReducerTemplate] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const showAdvancedWorkflowSettings = false; + + const enabledSdkParticipants = useMemo( + () => allEligible.filter((entry) => entry.flavor === 'sdk').filter((entry) => { + const cfg = sessionCfg[entry.key]; + return !!cfg?.enabled && cfg.mode !== 'skip'; + }), + [allEligible, sessionCfg], + ); // Config key uses the main session (sub-sessions follow parent config) const configKey = scopeSession ? `p2p_session_config:${scopeSession}` : null; @@ -271,6 +313,13 @@ export function P2pConfigPanel({ setRounds(parsed.rounds ?? 3); setHopTimeoutMinutes(parsed.hopTimeoutMinutes ?? 8); setExtraPrompt(parsed.extraPrompt ?? ''); + setAdvancedPresetKey(parsed.advancedPresetKey ?? ''); + setAdvancedRounds(parsed.advancedRounds); + setAdvancedRunTimeoutMinutes(parsed.advancedRunTimeoutMinutes ?? 30); + setContextReducerMode(parsed.contextReducer?.mode ?? ''); + setContextReducerSession(parsed.contextReducer?.sessionName ?? ''); + setContextReducerTemplate(parsed.contextReducer?.templateSession ?? ''); + setAdvancedExpanded(Boolean(parsed.advancedPresetKey || parsed.contextReducer || parsed.advancedRunTimeoutMinutes != null)); } catch { /* start fresh */ } } setLoading(false); @@ -287,6 +336,21 @@ export function P2pConfigPanel({ }); }, [configKey]); + useEffect(() => { + if (contextReducerMode === 'reuse_existing_session') { + const stillEligible = enabledSdkParticipants.some((entry) => entry.key === contextReducerSession); + if (!stillEligible) setContextReducerSession(enabledSdkParticipants[0]?.key ?? ''); + return; + } + if (contextReducerMode === 'clone_sdk_session') { + const stillEligible = enabledSdkParticipants.some((entry) => entry.key === contextReducerTemplate); + if (!stillEligible) setContextReducerTemplate(enabledSdkParticipants[0]?.key ?? ''); + return; + } + setContextReducerSession(''); + setContextReducerTemplate(''); + }, [contextReducerMode, contextReducerSession, contextReducerTemplate, enabledSdkParticipants]); + useEffect(() => { if (typeof window === 'undefined') return; const handleResize = () => setIsMobile(window.innerWidth < 768); @@ -317,7 +381,25 @@ export function P2pConfigPanel({ for (const e of allEligible) { merged[e.key] = sessionCfg[e.key] ?? { enabled: false, mode: 'audit' }; } - const cfg: P2pSavedConfig = { sessions: merged, rounds, hopTimeoutMinutes, extraPrompt: extraPrompt.trim() || undefined }; + let contextReducer: P2pContextReducerConfig | undefined; + if (advancedPresetKey && contextReducerMode === 'reuse_existing_session' && contextReducerSession) { + contextReducer = { mode: 'reuse_existing_session', sessionName: contextReducerSession }; + } else if (advancedPresetKey && contextReducerMode === 'clone_sdk_session' && contextReducerTemplate) { + contextReducer = { mode: 'clone_sdk_session', templateSession: contextReducerTemplate }; + } + const resolvedAdvancedRounds = advancedPresetKey + ? (advancedRounds ? JSON.parse(JSON.stringify(advancedRounds)) as P2pAdvancedRound[] : JSON.parse(JSON.stringify(BUILT_IN_ADVANCED_PRESETS[advancedPresetKey])) as P2pAdvancedRound[]) + : undefined; + const cfg: P2pSavedConfig = { + sessions: merged, + rounds, + hopTimeoutMinutes, + extraPrompt: extraPrompt.trim() || undefined, + advancedPresetKey: advancedPresetKey || undefined, + advancedRounds: resolvedAdvancedRounds, + advancedRunTimeoutMinutes: advancedPresetKey ? advancedRunTimeoutMinutes : undefined, + contextReducer, + }; try { if (configKey) await saveUserPref(configKey, JSON.stringify(cfg)); onSave(cfg); @@ -327,6 +409,26 @@ export function P2pConfigPanel({ }; const getEntry = (key: string) => sessionCfg[key] ?? { enabled: false, mode: 'audit' }; + const handleAdvancedPresetChange = (value: string) => { + const nextPreset = value as P2pAdvancedPresetKey | ''; + setAdvancedPresetKey(nextPreset); + if (!nextPreset) { + setAdvancedRounds(undefined); + setContextReducerMode(''); + setContextReducerSession(''); + setContextReducerTemplate(''); + return; + } + setAdvancedRounds((prev) => prev ?? (JSON.parse(JSON.stringify(BUILT_IN_ADVANCED_PRESETS[nextPreset])) as P2pAdvancedRound[])); + setAdvancedRunTimeoutMinutes((prev) => (prev > 0 ? prev : 30)); + }; + + const updateAdvancedRound = (roundId: string, updater: (round: P2pAdvancedRound) => P2pAdvancedRound) => { + setAdvancedRounds((prev) => { + if (!prev) return prev; + return prev.map((round) => (round.id === roundId ? updater(round) : round)); + }); + }; const overlayStyle: Record = { position: 'fixed', inset: 0, @@ -505,6 +607,317 @@ export function P2pConfigPanel({ }} />
+ + {showAdvancedWorkflowSettings &&
+ + {advancedExpanded && ( +
+ + + {advancedPresetKey && ( + <> + + + + + {contextReducerMode === 'reuse_existing_session' && ( + + )} + + {contextReducerMode === 'clone_sdk_session' && ( + + )} + + {advancedRounds && advancedRounds.length > 0 && ( +
+
+ {t('p2p.settings_advanced_rounds', 'Advanced rounds')} +
+ {advancedRounds.map((round) => ( +
+
+
+
{round.title}
+
{round.id} · {round.preset}
+
+ +
+ +
+ + +
+ + {(round.verdictPolicy === 'smart_gate' || round.verdictPolicy === 'forced_rework' || round.jumpRule) && ( +
+ + + + +
+ )} + + {(round.permissionScope === 'artifact_generation' || (round.artifactOutputs?.length ?? 0) > 0) && ( + + )} + +