wave-agent-sdk 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/agent.d.ts +9 -20
  2. package/dist/agent.js +35 -97
  3. package/dist/managers/aiManager.d.ts +63 -8
  4. package/dist/managers/aiManager.js +274 -80
  5. package/dist/managers/messageManager.d.ts +9 -5
  6. package/dist/managers/messageManager.js +36 -12
  7. package/dist/managers/permissionManager.js +13 -11
  8. package/dist/managers/subagentManager.d.ts +6 -0
  9. package/dist/managers/subagentManager.js +33 -22
  10. package/dist/prompts/index.d.ts +0 -2
  11. package/dist/prompts/index.js +71 -47
  12. package/dist/services/aiService.d.ts +1 -34
  13. package/dist/services/aiService.js +18 -130
  14. package/dist/services/autoMemoryService.d.ts +27 -2
  15. package/dist/services/autoMemoryService.js +124 -36
  16. package/dist/services/configurationService.js +14 -1
  17. package/dist/services/session.d.ts +13 -0
  18. package/dist/services/session.js +64 -0
  19. package/dist/types/agent.d.ts +0 -2
  20. package/dist/types/config.d.ts +7 -0
  21. package/dist/types/core.d.ts +1 -1
  22. package/dist/utils/containerSetup.js +12 -3
  23. package/package.json +1 -1
  24. package/src/agent.ts +50 -110
  25. package/src/managers/aiManager.ts +370 -105
  26. package/src/managers/messageManager.ts +51 -23
  27. package/src/managers/permissionManager.ts +15 -13
  28. package/src/managers/subagentManager.ts +36 -25
  29. package/src/prompts/index.ts +75 -56
  30. package/src/services/aiService.ts +25 -203
  31. package/src/services/autoMemoryService.ts +145 -39
  32. package/src/services/configurationService.ts +16 -1
  33. package/src/services/session.ts +68 -0
  34. package/src/types/agent.ts +0 -6
  35. package/src/types/config.ts +7 -0
  36. package/src/types/core.ts +1 -1
  37. package/src/utils/containerSetup.ts +12 -4
  38. package/dist/constants/goalPrompts.d.ts +0 -1
  39. package/dist/constants/goalPrompts.js +0 -10
  40. package/dist/managers/goalManager.d.ts +0 -42
  41. package/dist/managers/goalManager.js +0 -177
  42. package/src/constants/goalPrompts.ts +0 -10
  43. package/src/managers/goalManager.ts +0 -232
@@ -12,6 +12,10 @@ import { recoverTruncatedJson } from "../utils/stringUtils.js";
12
12
  import { logger } from "../utils/globalLogger.js";
13
13
  import { startInteractionSpan, endInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, resetTracingState, } from "../telemetry/sessionTracing.js";
14
14
  import { logOTelEvent } from "../telemetry/events.js";
15
+ /** Max turns for the compaction fork: the model should summarize, not act. */
16
+ const MAX_FORK_TURNS = 3;
17
+ /** Max turns for the auto-memory extraction fork. */
18
+ const MAX_AUTO_MEMORY_FORK_TURNS = 5;
15
19
  // Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
16
20
  // Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
17
21
  function truncateWithMarker(text, max) {
@@ -453,7 +457,7 @@ export class AIManager {
453
457
  }
454
458
  catch (compactError) {
455
459
  this.consecutiveCompactionFailures++;
456
- logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`, compactError);
460
+ logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`);
457
461
  this.messageManager.addErrorBlock(`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`);
458
462
  }
459
463
  finally {
@@ -490,16 +494,15 @@ export class AIManager {
490
494
  return { toolsConfig, toolNames, filteredToolPlugins };
491
495
  }
492
496
  /**
493
- * Fork-path compaction: run a bounded agent loop over a copy of the
494
- * conversation using the same system prompt, tools, model, and generation
495
- * params as the main loop, so the forked request prefix matches exactly
496
- * and the prompt cache is reused. Tool calls are denied locally (the model
497
- * is told to summarize, not act) and their rejections are fed back for
498
- * another turn. Returns undefined content when the model never produces
499
- * text; the caller treats that as a compaction failure.
497
+ * Fork-path loop: run a bounded agent loop over a copy of the conversation
498
+ * using the same system prompt, tools, model, and generation params as the
499
+ * main loop, so the forked request prefix matches exactly and the prompt
500
+ * cache is reused. A `canUseTool` gate decides whether each tool call
501
+ * executes locally (with a stripped context) or is denied and fed back to
502
+ * the model for another turn. Returns undefined content when the model never
503
+ * produces text; the caller treats that as a failure.
500
504
  */
501
- async runCompactFork(historyMessages, compactPrompt, abortSignal) {
502
- const MAX_FORK_TURNS = 3;
505
+ async runForkLoop(historyMessages, prompt, options, abortSignal) {
503
506
  const modelConfig = this.getModelConfig();
504
507
  const gatewayConfig = this.getGatewayConfig();
505
508
  const sessionId = this.messageManager.getSessionId();
@@ -513,12 +516,15 @@ export class AIManager {
513
516
  content: wrapInSystemReminder(prependContent),
514
517
  });
515
518
  }
516
- forkMessages.push({ role: "user", content: compactPrompt });
519
+ forkMessages.push({ role: "user", content: prompt });
517
520
  const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
518
521
  const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
522
+ // Fresh read-state map so Read/Edit state built up inside the fork never
523
+ // leaks into the main session's dedup and staleness tracking.
524
+ const forkReadFileState = new Map();
519
525
  let totalUsage;
520
526
  let content;
521
- for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
527
+ for (let turn = 0; turn < options.maxTurns; turn++) {
522
528
  const result = await aiService.callAgent({
523
529
  gatewayConfig,
524
530
  modelConfig,
@@ -531,7 +537,7 @@ export class AIManager {
531
537
  toolChoice: this.toolChoiceOverride,
532
538
  // Stream so a slow reasoning model emits first bytes before the
533
539
  // gateway's idle timeout fires (non-streaming waits for the full
534
- // summary, which exceeds the timeout on large contexts).
540
+ // response, which exceeds the timeout on large contexts).
535
541
  stream: true,
536
542
  });
537
543
  if (result.usage) {
@@ -547,28 +553,259 @@ export class AIManager {
547
553
  break;
548
554
  }
549
555
  if (result.tool_calls && result.tool_calls.length > 0) {
550
- // Deny all tool calls locally and feed the rejections back so the
551
- // model gets another turn to produce the summary text.
556
+ const functionCalls = result.tool_calls.filter((tc) => tc.type === "function");
557
+ if (functionCalls.length === 0)
558
+ break;
552
559
  forkMessages.push({
553
560
  role: "assistant",
554
561
  content: result.content ?? null,
555
- tool_calls: result.tool_calls,
562
+ tool_calls: functionCalls,
556
563
  });
557
- for (const toolCall of result.tool_calls) {
564
+ for (const toolCall of functionCalls) {
565
+ const name = toolCall.function?.name || "";
566
+ const args = this.parseForkToolArgs(toolCall.function?.arguments);
567
+ let toolContent;
568
+ if (options.canUseTool && options.canUseTool(name, args)) {
569
+ toolContent = await this.executeForkTool(name, args, workdir, sessionId, abortSignal, forkReadFileState);
570
+ }
571
+ else {
572
+ toolContent =
573
+ options.deniedToolMessage ??
574
+ "Tool use is not allowed in this context";
575
+ }
558
576
  forkMessages.push({
559
577
  role: "tool",
560
578
  tool_call_id: toolCall.id,
561
- content: "Tool use is not allowed during compaction",
579
+ content: toolContent,
562
580
  });
563
581
  }
564
582
  continue;
565
583
  }
566
584
  // Neither text nor tool calls: retrying the identical request is
567
- // pointless, bail out and let the caller fail the compaction.
585
+ // pointless, bail out and let the caller fail.
568
586
  break;
569
587
  }
570
588
  return { content, usage: totalUsage };
571
589
  }
590
+ /**
591
+ * Fork-path compaction: deny all tool calls locally (the model is told to
592
+ * summarize, not act) and feed the rejections back for another turn.
593
+ */
594
+ async runCompactFork(historyMessages, compactPrompt, abortSignal) {
595
+ return this.runForkLoop(historyMessages, compactPrompt, {
596
+ maxTurns: MAX_FORK_TURNS,
597
+ deniedToolMessage: "Tool use is not allowed during compaction",
598
+ }, abortSignal);
599
+ }
600
+ /**
601
+ * Auto-memory extraction via the perfect fork: the extraction prompt is run
602
+ * against the same request prefix as the main conversation (same system
603
+ * prompt, tools, model, and message history) so the prompt cache is reused.
604
+ * Gate-approved tools execute locally in a stripped context; everything else
605
+ * is denied. Usage is reported with operation_type "agent" so extraction
606
+ * token costs stay visible in session accounting.
607
+ */
608
+ async runAutoMemoryFork(messages, prompt, options, abortSignal) {
609
+ const modelConfig = this.getModelConfig();
610
+ const historyMessages = convertMessagesForAPI(messages, {
611
+ supportsVision: supportsVision(modelConfig.capabilities),
612
+ });
613
+ // Give the fork a real signal even when the caller has none, so tools
614
+ // (e.g. Bash's foreground path) always receive a well-formed context.
615
+ const signal = abortSignal ?? new AbortController().signal;
616
+ const result = await this.runForkLoop(historyMessages, prompt, {
617
+ maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
618
+ canUseTool: options.canUseTool,
619
+ deniedToolMessage: options.deniedToolMessage,
620
+ }, signal);
621
+ if (result.usage && this.callbacks?.onUsageAdded) {
622
+ this.callbacks.onUsageAdded({
623
+ ...result.usage,
624
+ model: modelConfig.model,
625
+ operation_type: "agent",
626
+ });
627
+ }
628
+ return result;
629
+ }
630
+ /**
631
+ * Parse a fork tool call's JSON arguments, recovering truncated JSON the
632
+ * same way the main loop does. Unparseable arguments fall back to `{}` and
633
+ * are rejected by the gate or the tool's own parameter validation.
634
+ */
635
+ parseForkToolArgs(argsString) {
636
+ if (!argsString?.trim())
637
+ return {};
638
+ try {
639
+ return JSON.parse(argsString);
640
+ }
641
+ catch {
642
+ try {
643
+ return JSON.parse(recoverTruncatedJson(argsString));
644
+ }
645
+ catch {
646
+ return {};
647
+ }
648
+ }
649
+ }
650
+ /**
651
+ * Execute a single tool call inside a fork with a stripped context: no
652
+ * permission manager (never prompts the user), no message manager (no
653
+ * conditional-rule triggering), no messageId (no file-history snapshots),
654
+ * and no background task manager (commands run in the foreground). Only
655
+ * gate-approved tool names reach this path.
656
+ */
657
+ async executeForkTool(name, args, workdir, sessionId, abortSignal, readFileState) {
658
+ const plugin = this.toolManager.getTools().find((t) => t.name === name);
659
+ if (!plugin) {
660
+ return `Tool '${name}' not found`;
661
+ }
662
+ const context = {
663
+ abortSignal,
664
+ workdir,
665
+ originalWorkdir: this.originalWorkdir,
666
+ sessionId,
667
+ taskManager: this.taskManager,
668
+ readFileState,
669
+ onShortResultUpdate: () => { },
670
+ onResultUpdate: () => { },
671
+ onCwdChange: () => { },
672
+ };
673
+ try {
674
+ const result = await plugin.execute(args, context);
675
+ if (result.content)
676
+ return result.content;
677
+ if (result.error)
678
+ return `Error: ${result.error}`;
679
+ return "";
680
+ }
681
+ catch (error) {
682
+ const message = error instanceof Error ? error.message : String(error);
683
+ logger?.error(`Fork tool execution failed for ${name}:`, error);
684
+ return `Tool execution failed: ${message}`;
685
+ }
686
+ }
687
+ /**
688
+ * Fork-path side question ("/btw"): run a single-turn fork of the
689
+ * conversation using the same system prompt, tools, model, and generation
690
+ * params as the main loop, so the forked request prefix matches exactly and
691
+ * the prompt cache is reused. The in-progress assistant message (if any) is
692
+ * stripped so the fork starts from the last completed request prefix. Tools
693
+ * are never executed — the wrapped question instructs the model to answer
694
+ * directly; an attempted tool call is surfaced as an error string.
695
+ */
696
+ async runBtwFork(question, abortSignal, onContent, onReasoning) {
697
+ const modelConfig = this.getModelConfig();
698
+ const gatewayConfig = this.getGatewayConfig();
699
+ const sessionId = this.messageManager.getSessionId();
700
+ const workdir = this.getWorkdir();
701
+ const rawMessages = this.messageManager.getMessages();
702
+ // Strip the in-progress assistant message (a block still in "streaming"
703
+ // stage) so the fork's request prefix matches the last completed
704
+ // main-loop request and the prompt cache is reused.
705
+ const lastMessage = rawMessages[rawMessages.length - 1];
706
+ const hasInProgressMessage = lastMessage?.role === "assistant" &&
707
+ lastMessage.blocks.some((b) => "stage" in b && b.stage === "streaming");
708
+ const forkMessages = convertMessagesForAPI(hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages, { supportsVision: supportsVision(modelConfig.capabilities) });
709
+ // Mirror the main loop's memory injection so the request prefix matches.
710
+ const { prependContent } = await this.messageManager.getMemoryForInjection();
711
+ if (prependContent.trim()) {
712
+ forkMessages.unshift({
713
+ role: "user",
714
+ content: wrapInSystemReminder(prependContent),
715
+ });
716
+ }
717
+ // Wrap the question with the side-question instructions (verbatim
718
+ // Claude Code sideQuestion.ts) so the model answers directly.
719
+ const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
720
+
721
+ IMPORTANT CONTEXT:
722
+ - You are a separate, lightweight agent spawned to answer this one question
723
+ - The main agent is NOT interrupted - it continues working independently in the background
724
+ - You share the conversation context but are a completely separate instance
725
+ - Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
726
+
727
+ CRITICAL CONSTRAINTS:
728
+ - You have NO tools available - you cannot read files, run commands, search, or take any actions
729
+ - This is a one-off response - there will be no follow-up turns
730
+ - You can ONLY provide information based on what you already know from the conversation context
731
+ - NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
732
+ - If you don't know the answer, say so - do not offer to look it up or investigate
733
+
734
+ Simply answer the question with the information you have.</system-reminder>
735
+
736
+ ${question}`;
737
+ forkMessages.push({ role: "user", content: wrappedQuestion });
738
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
739
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
740
+ try {
741
+ // Surface partial output to the caller (e.g. the /btw overlay's
742
+ // streaming display) as it arrives. Reasoning chunks from thinking
743
+ // models stream through a separate channel when the caller supplies
744
+ // one (webview hosts distinguish thinking from content so the panel
745
+ // can drop thinking text once content starts); otherwise they fall
746
+ // back to the content channel (CLI overlay mixes both).
747
+ const streamToOverlay = (text) => {
748
+ if (text.trim()) {
749
+ onContent?.(text);
750
+ }
751
+ };
752
+ const streamReasoning = (text) => {
753
+ if (text.trim()) {
754
+ if (onReasoning) {
755
+ onReasoning(text);
756
+ }
757
+ else {
758
+ onContent?.(text);
759
+ }
760
+ }
761
+ };
762
+ const result = await aiService.callAgent({
763
+ gatewayConfig,
764
+ modelConfig,
765
+ messages: forkMessages,
766
+ sessionId,
767
+ abortSignal,
768
+ workdir,
769
+ tools: toolsConfig,
770
+ systemPrompt,
771
+ toolChoice: this.toolChoiceOverride,
772
+ // Stream so a slow reasoning model emits first bytes before the
773
+ // gateway's idle timeout fires (same rationale as runCompactFork).
774
+ stream: true,
775
+ onContentUpdate: streamToOverlay,
776
+ onReasoningUpdate: streamReasoning,
777
+ });
778
+ if (result.content?.trim()) {
779
+ return { content: result.content };
780
+ }
781
+ // A thinking model may emit only reasoning content (e.g. the stream
782
+ // is truncated before the final answer); surface that instead of
783
+ // falling through to "No response received".
784
+ if (result.reasoning_content?.trim()) {
785
+ return { content: result.reasoning_content };
786
+ }
787
+ if (result.tool_calls && result.tool_calls.length > 0) {
788
+ const firstFunctionCall = result.tool_calls.find((call) => call.type === "function");
789
+ const toolName = firstFunctionCall?.function?.name ?? "a tool";
790
+ return {
791
+ error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
792
+ };
793
+ }
794
+ // Neither text nor tool calls.
795
+ return { error: "No response received" };
796
+ }
797
+ catch (error) {
798
+ // aiService.callAgent converts AbortError into a plain Error, so the
799
+ // abort is detected via the signal itself; rethrow so the UI can
800
+ // silently dismiss instead of showing an error.
801
+ if (abortSignal?.aborted) {
802
+ throw error;
803
+ }
804
+ return {
805
+ error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
806
+ };
807
+ }
808
+ }
572
809
  /**
573
810
  * Build post-compact context restoration content.
574
811
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -805,6 +1042,11 @@ export class AIManager {
805
1042
  systemPrompt: mainSystemPrompt, // Pass custom system prompt
806
1043
  maxTokens: maxTokens, // Pass max tokens override
807
1044
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1045
+ // Fast-model subagents send disable-thinking params only when
1046
+ // explicitly configured (never in the agent loop).
1047
+ disableThinkingOptions: this.modelOverride === "fastModel"
1048
+ ? this.getModelConfig().disableThinkingOptions
1049
+ : undefined,
808
1050
  };
809
1051
  // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
810
1052
  if (prependContent.trim()) {
@@ -1118,67 +1360,15 @@ export class AIManager {
1118
1360
  this.messageManager.addFileHistoryBlock(snapshots);
1119
1361
  }
1120
1362
  }
1121
- // Goal evaluation — supersedes Stop hooks when active
1122
- const goalManager = this.container.has("GoalManager")
1123
- ? this.container.get("GoalManager")
1124
- : undefined;
1125
- let goalContinuing = false;
1126
- if (goalManager?.isGoalActive() && !this.subagentType) {
1127
- // 1. Increment turn count and check circuit breakers
1128
- goalManager.incrementTurnCount();
1129
- const circuitBreaker = goalManager.checkCircuitBreakers();
1130
- if (circuitBreaker) {
1131
- goalManager.clearGoal();
1132
- logger?.info(`[Goal] ${circuitBreaker}`);
1133
- this.messageManager.addUserMessage({
1134
- content: `<system-reminder>${circuitBreaker}</system-reminder>`,
1135
- isMeta: true,
1136
- });
1137
- // Fall through to normal Stop hooks on the final turn
1138
- }
1139
- else {
1140
- // 2. Evaluate goal
1141
- const evaluation = await goalManager.evaluateGoal(abortController.signal);
1142
- if (evaluation.isMet) {
1143
- goalManager.clearGoal();
1144
- logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
1145
- this.messageManager.addUserMessage({
1146
- content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
1147
- isMeta: true,
1148
- });
1149
- // Fall through to normal Stop hooks on the final turn
1150
- }
1151
- else {
1152
- const goal = goalManager.getGoal();
1153
- goal.lastReason = evaluation.reason;
1154
- logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
1155
- this.messageManager.addUserMessage({
1156
- content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
1157
- isMeta: true,
1158
- });
1159
- // Keep loading state active to prevent UI flicker
1160
- this.setIsLoading(true);
1161
- goalContinuing = true;
1162
- // Restart outer loop to continue goal pursuit
1163
- shouldRestart = true;
1164
- turnOffset = 0;
1165
- }
1166
- }
1167
- }
1168
- // Skip Stop hooks when goal evaluator is continuing the conversation
1169
- if (goalContinuing) {
1170
- // Goal evaluator supersedes Stop hooks
1171
- }
1172
- else {
1173
- const shouldContinue = await this.executeStopHooks();
1174
- // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1175
- // restart the AI conversation cycle
1176
- if (shouldContinue) {
1177
- logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
1178
- // Restart the conversation to let AI fix the issues
1179
- shouldRestart = true;
1180
- turnOffset = 0;
1181
- }
1363
+ // Execute Stop hooks
1364
+ const shouldContinue = await this.executeStopHooks();
1365
+ // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1366
+ // restart the AI conversation cycle
1367
+ if (shouldContinue) {
1368
+ logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
1369
+ // Restart the conversation to let AI fix the issues
1370
+ shouldRestart = true;
1371
+ turnOffset = 0;
1182
1372
  }
1183
1373
  }
1184
1374
  // Inject pending notifications from background tasks (after Stop hooks,
@@ -1416,6 +1606,8 @@ export class AIManager {
1416
1606
  id: toolId,
1417
1607
  shortResult,
1418
1608
  stage: "running",
1609
+ compactParams,
1610
+ name: toolName,
1419
1611
  });
1420
1612
  },
1421
1613
  onResultUpdate: (result) => {
@@ -1423,6 +1615,8 @@ export class AIManager {
1423
1615
  id: toolId,
1424
1616
  result,
1425
1617
  stage: "running",
1618
+ compactParams,
1619
+ name: toolName,
1426
1620
  });
1427
1621
  },
1428
1622
  onCwdChange: async (newCwd) => {
@@ -5,7 +5,6 @@ import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
5
5
  import type { MemoryRule } from "../types/memoryRule.js";
6
6
  import { Container } from "../utils/container.js";
7
7
  export interface MessageManagerCallbacks {
8
- onMessagesChange?: (messages: Message[]) => void;
9
8
  onSessionIdChange?: (sessionId: string) => void;
10
9
  onLatestTotalTokensChange?: (latestTotalTokens: number) => void;
11
10
  onUsagesChange?: (usages: Usage[]) => void;
@@ -27,9 +26,9 @@ export interface MessageManagerCallbacks {
27
26
  onErrorBlockAdded?: (error: string) => void;
28
27
  onCompactBlockAdded?: (content: string) => void;
29
28
  onCompactionStateChange?: (isCompacting: boolean) => void;
30
- onAddBangMessage?: (command: string) => void;
31
- onUpdateBangMessage?: (command: string, output: string) => void;
32
- onCompleteBangMessage?: (command: string, exitCode: number) => void;
29
+ onAddBangMessage?: (command: string, messageId: string) => void;
30
+ onUpdateBangMessage?: (command: string, output: string, messageId: string) => void;
31
+ onCompleteBangMessage?: (command: string, exitCode: number, messageId: string) => void;
33
32
  onInfoBlockAdded?: (content: string) => void;
34
33
  onShowRewind?: () => void;
35
34
  onFileHistoryBlockAdded?: (snapshots: import("../types/reversion.js").FileSnapshot[]) => void;
@@ -151,6 +150,12 @@ export declare class MessageManager {
151
150
  addBangMessage(command: string): void;
152
151
  updateBangMessage(command: string, output: string): void;
153
152
  completeBangMessage(command: string, exitCode: number, output?: string): void;
153
+ /**
154
+ * Find the message ID of the most recent message containing a bang block
155
+ * for the given command. Bang callbacks do not carry a block ID, so the
156
+ * message is located by matching the command against bang blocks.
157
+ */
158
+ private findBangMessageId;
154
159
  addNotificationMessage(params: Omit<AddNotificationMessageParams, "messages">): void;
155
160
  /**
156
161
  * Rebuild usage array from messages containing usage metadata
@@ -169,7 +174,6 @@ export declare class MessageManager {
169
174
  /**
170
175
  * Finalize a streaming block of the given type by setting its stage to "end".
171
176
  * Fires the corresponding incremental callback with chunk="" to signal finalization.
172
- * Does NOT call onMessagesChange — the caller is responsible for that.
173
177
  * Returns true if a block was finalized.
174
178
  */
175
179
  private finalizeStreamingBlock;
@@ -181,7 +181,6 @@ export class MessageManager {
181
181
  this.extractFileReadsFromMessage(messages[messages.length - 1]);
182
182
  this.extractSkillInvocationsFromMessage(messages[messages.length - 1]);
183
183
  }
184
- this.callbacks.onMessagesChange?.([...messages]);
185
184
  }
186
185
  /**
187
186
  * Save current session
@@ -194,6 +193,17 @@ export class MessageManager {
194
193
  // No new messages to save
195
194
  return;
196
195
  }
196
+ // CC-aligned lazy materialization: when the session file has not been
197
+ // materialized yet (no saved messages) and every unsaved message is a
198
+ // meta message (isMeta: true, e.g. SessionStart hook context), skip
199
+ // persistence. Persisting meta-only sessions would create "0 tokens /
200
+ // No content" ghost entries in the resume list. The meta messages stay
201
+ // in memory and are flushed together with the first real user/assistant
202
+ // message (matches CC's pendingEntries buffering).
203
+ if (this.savedMessageCount === 0 &&
204
+ unsavedMessages.every((m) => m.isMeta)) {
205
+ return;
206
+ }
197
207
  // Create session if needed (only when we have messages to save)
198
208
  if (this.savedMessageCount === 0) {
199
209
  // This is the first time saving messages, so create the session
@@ -403,7 +413,9 @@ export class MessageManager {
403
413
  command,
404
414
  });
405
415
  this.setMessages(updatedMessages);
406
- this.callbacks.onAddBangMessage?.(command);
416
+ // The bang message is appended as the last message
417
+ const messageId = this.messages[this.messages.length - 1]?.id ?? "";
418
+ this.callbacks.onAddBangMessage?.(command, messageId);
407
419
  }
408
420
  updateBangMessage(command, output) {
409
421
  const updatedMessages = updateBangInMessage({
@@ -412,7 +424,8 @@ export class MessageManager {
412
424
  output,
413
425
  });
414
426
  this.setMessages(updatedMessages);
415
- this.callbacks.onUpdateBangMessage?.(command, output);
427
+ const messageId = this.findBangMessageId(command) ?? "";
428
+ this.callbacks.onUpdateBangMessage?.(command, output, messageId);
416
429
  }
417
430
  completeBangMessage(command, exitCode, output) {
418
431
  const updatedMessages = completeBangInMessage({
@@ -422,7 +435,24 @@ export class MessageManager {
422
435
  output,
423
436
  });
424
437
  this.setMessages(updatedMessages);
425
- this.callbacks.onCompleteBangMessage?.(command, exitCode);
438
+ const messageId = this.findBangMessageId(command) ?? "";
439
+ this.callbacks.onCompleteBangMessage?.(command, exitCode, messageId);
440
+ }
441
+ /**
442
+ * Find the message ID of the most recent message containing a bang block
443
+ * for the given command. Bang callbacks do not carry a block ID, so the
444
+ * message is located by matching the command against bang blocks.
445
+ */
446
+ findBangMessageId(command) {
447
+ for (let i = this.messages.length - 1; i >= 0; i--) {
448
+ const message = this.messages[i];
449
+ if (message.role !== "user")
450
+ continue;
451
+ const hasBangBlock = message.blocks?.some((block) => block.type === "bang" && block.command === command);
452
+ if (hasBangBlock)
453
+ return message.id;
454
+ }
455
+ return undefined;
426
456
  }
427
457
  addNotificationMessage(params) {
428
458
  const newMessages = addNotificationMessageToMessages({
@@ -468,7 +498,6 @@ export class MessageManager {
468
498
  /**
469
499
  * Finalize a streaming block of the given type by setting its stage to "end".
470
500
  * Fires the corresponding incremental callback with chunk="" to signal finalization.
471
- * Does NOT call onMessagesChange — the caller is responsible for that.
472
501
  * Returns true if a block was finalized.
473
502
  */
474
503
  finalizeStreamingBlock(lastMessage, type) {
@@ -552,7 +581,6 @@ export class MessageManager {
552
581
  stage: "streaming",
553
582
  });
554
583
  // Note: Subagent-specific callbacks are now handled by SubagentManager
555
- this.callbacks.onMessagesChange?.([...this.messages]); // Still need to notify of changes
556
584
  }
557
585
  /**
558
586
  * Update the current assistant message reasoning during streaming
@@ -599,7 +627,6 @@ export class MessageManager {
599
627
  accumulated: newAccumulatedReasoning,
600
628
  stage: "streaming",
601
629
  });
602
- this.callbacks.onMessagesChange?.([...this.messages]); // Still need to notify of changes
603
630
  }
604
631
  /**
605
632
  * Finalize streaming text/reasoning blocks by setting their stage to "end".
@@ -612,11 +639,8 @@ export class MessageManager {
612
639
  const lastMessage = this.messages[this.messages.length - 1];
613
640
  if (lastMessage.role !== "assistant")
614
641
  return;
615
- const textFinalized = this.finalizeStreamingBlock(lastMessage, "text");
616
- const reasoningFinalized = this.finalizeStreamingBlock(lastMessage, "reasoning");
617
- if (textFinalized || reasoningFinalized) {
618
- this.callbacks.onMessagesChange?.([...this.messages]);
619
- }
642
+ this.finalizeStreamingBlock(lastMessage, "text");
643
+ this.finalizeStreamingBlock(lastMessage, "reasoning");
620
644
  }
621
645
  /**
622
646
  * Finalize any tool blocks still in a non-terminal stage (start/streaming/running)
@@ -310,16 +310,8 @@ export class PermissionManager {
310
310
  };
311
311
  }
312
312
  }
313
- // 1. If bypassPermissions mode, always allow
314
- // Exception: tools that require user interaction (e.g. AskUserQuestion)
315
- // must still prompt the user, matching Claude Code's requiresUserInteraction behavior.
316
- if (context.permissionMode === "bypassPermissions") {
317
- const requiresUserInteraction = context.toolName === ASK_USER_QUESTION_TOOL_NAME;
318
- if (!requiresUserInteraction) {
319
- return { behavior: "allow" };
320
- }
321
- }
322
- // 1.0 Check worktree safety for Write and Edit tools
313
+ // Check worktree safety for Write and Edit tools — unconditional safety
314
+ // check, applied regardless of permission mode (same as read-before-edit).
323
315
  // Support both CLI -w sessions (container-registered) and EnterWorktree mid-session
324
316
  // (per-agent WorktreeSession stored in this session's container)
325
317
  const worktreeSession = this.container.get("WorktreeSession");
@@ -371,7 +363,17 @@ export class PermissionManager {
371
363
  }
372
364
  }
373
365
  }
374
- // 1.1 If acceptEdits mode, allow Edit, Write, and mkdir in safe zone
366
+ // If bypassPermissions mode, always allow
367
+ // Exception: tools that require user interaction (e.g. AskUserQuestion)
368
+ // must still prompt the user, matching Claude Code's requiresUserInteraction behavior.
369
+ // Worktree safety check above runs unconditionally, so bypass never skips it.
370
+ if (context.permissionMode === "bypassPermissions") {
371
+ const requiresUserInteraction = context.toolName === ASK_USER_QUESTION_TOOL_NAME;
372
+ if (!requiresUserInteraction) {
373
+ return { behavior: "allow" };
374
+ }
375
+ }
376
+ // If acceptEdits mode, allow Edit, Write, and mkdir in safe zone
375
377
  if (context.permissionMode === "acceptEdits") {
376
378
  const autoAcceptedTools = [EDIT_TOOL_NAME, WRITE_TOOL_NAME];
377
379
  if (autoAcceptedTools.includes(context.toolName)) {
@@ -153,4 +153,10 @@ export declare class SubagentManager {
153
153
  * Extracted to reuse in both create and restore flows
154
154
  */
155
155
  private createSubagentCallbacks;
156
+ /**
157
+ * Pull the latest messages from the subagent instance's MessageManager and
158
+ * refresh the instance's cached messages, usedTools, onUpdate and the
159
+ * onSubagentMessagesChange forwarding. Triggered by incremental callbacks.
160
+ */
161
+ private refreshSubagentState;
156
162
  }