wave-agent-sdk 1.0.10 → 1.1.0

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 (36) hide show
  1. package/dist/agent.d.ts +9 -6
  2. package/dist/agent.js +35 -33
  3. package/dist/builtin/skills/settings.js +31 -6
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/managers/aiManager.d.ts +10 -0
  7. package/dist/managers/aiManager.js +31 -4
  8. package/dist/managers/subagentManager.d.ts +1 -0
  9. package/dist/managers/subagentManager.js +5 -1
  10. package/dist/services/hook.js +41 -7
  11. package/dist/services/initializationService.js +0 -21
  12. package/dist/services/jsonlHandler.d.ts +37 -2
  13. package/dist/services/jsonlHandler.js +55 -6
  14. package/dist/services/session.d.ts +35 -4
  15. package/dist/services/session.js +233 -36
  16. package/dist/services/worktreeHooks.d.ts +45 -0
  17. package/dist/services/worktreeHooks.js +133 -0
  18. package/dist/tools/agentTool.js +36 -1
  19. package/dist/tools/bashTool.js +120 -57
  20. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  21. package/dist/tools/enterWorktreeTool.js +44 -31
  22. package/dist/tools/exitWorktreeTool.js +21 -26
  23. package/dist/types/agent.d.ts +5 -0
  24. package/dist/types/hooks.d.ts +3 -2
  25. package/dist/types/skills.d.ts +0 -1
  26. package/dist/types/skills.js +0 -1
  27. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  28. package/dist/utils/asyncWorkRegistry.js +81 -0
  29. package/dist/utils/containerSetup.js +5 -0
  30. package/dist/utils/skillParser.js +3 -6
  31. package/dist/utils/windowsPaths.d.ts +28 -0
  32. package/dist/utils/windowsPaths.js +47 -0
  33. package/dist/utils/worktreeSession.d.ts +6 -0
  34. package/dist/utils/worktreeUtils.d.ts +7 -0
  35. package/dist/utils/worktreeUtils.js +88 -40
  36. package/package.json +5 -3
package/dist/agent.d.ts CHANGED
@@ -23,7 +23,9 @@ export declare class Agent {
23
23
  private messageQueue;
24
24
  private dispatchPromise;
25
25
  private isAborting;
26
+ private isDestroyed;
26
27
  private dispatchAborted;
28
+ private asyncWorkRegistry;
27
29
  private memoryRuleManager;
28
30
  private liveConfigManager;
29
31
  private taskManager;
@@ -122,6 +124,13 @@ export declare class Agent {
122
124
  }>;
123
125
  type?: "message" | "bang";
124
126
  }): boolean;
127
+ /**
128
+ * Terminal-lifecycle guard for public APIs. destroy() is terminal: after it
129
+ * returns, no new work may start on this agent. Silent drops hid misuse until
130
+ * a ghost async side effect surfaced later (issue #1808); throwing surfaces
131
+ * the contract violation immediately at the call site.
132
+ */
133
+ private assertNotDestroyed;
125
134
  /**
126
135
  * Unified dispatch trigger — checks state machine before processing.
127
136
  * Handles user messages, bang commands, and background task notifications
@@ -244,12 +253,6 @@ export declare class Agent {
244
253
  * Background the current foreground task
245
254
  */
246
255
  backgroundCurrentTask(): Promise<void>;
247
- /**
248
- * Trigger WorktreeRemove hook before agent destruction.
249
- * Called from CLI exit dialog when user chooses to remove the worktree.
250
- * Non-blocking: errors logged but don't prevent removal.
251
- */
252
- triggerWorktreeRemoveHook(worktreePath: string): Promise<void>;
253
256
  /** Destroy managers, clean up resources */
254
257
  destroy(): Promise<void>;
255
258
  /**
package/dist/agent.js CHANGED
@@ -53,7 +53,8 @@ export class Agent {
53
53
  constructor(options) {
54
54
  this.bangManager = null;
55
55
  this.dispatchPromise = null; // Track current dispatch for teardown
56
- this.isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
56
+ this.isAborting = false; // Transient guard: prevents tryDispatch from firing during abortMessage (reset when the abort completes)
57
+ this.isDestroyed = false; // Terminal guard: set in destroy(), never reset — no dispatch may ever start after destroy
57
58
  this.dispatchAborted = false; // Set on abort while a dispatch is running: suppress the .finally re-check so preserved notifications don't get dispatched after abort
58
59
  this.sessionStartTime = Date.now();
59
60
  const { logger, workdir, systemPrompt, stream = true } = options;
@@ -113,6 +114,7 @@ export class Agent {
113
114
  this.bangManager = this.container.get("BangManager");
114
115
  this.cronManager = this.container.get("CronManager");
115
116
  this.messageQueue = this.container.get("MessageQueue");
117
+ this.asyncWorkRegistry = this.container.get("AsyncWorkRegistry");
116
118
  // Wire up CWD change callback from AIManager to sync Agent's workdir
117
119
  this.aiManager.setOnCwdChange((newCwd) => {
118
120
  this.workdir = newCwd;
@@ -256,6 +258,17 @@ export class Agent {
256
258
  }
257
259
  return updated;
258
260
  }
261
+ /**
262
+ * Terminal-lifecycle guard for public APIs. destroy() is terminal: after it
263
+ * returns, no new work may start on this agent. Silent drops hid misuse until
264
+ * a ghost async side effect surfaced later (issue #1808); throwing surfaces
265
+ * the contract violation immediately at the call site.
266
+ */
267
+ assertNotDestroyed() {
268
+ if (this.isDestroyed) {
269
+ throw new Error("Agent destroyed");
270
+ }
271
+ }
259
272
  /**
260
273
  * Unified dispatch trigger — checks state machine before processing.
261
274
  * Handles user messages, bang commands, and background task notifications
@@ -263,6 +276,8 @@ export class Agent {
263
276
  * onLoadingChange(false), and onCommandRunningChange(false).
264
277
  */
265
278
  tryDispatch() {
279
+ if (this.isDestroyed)
280
+ return; // Terminal: agent destroyed, never dispatch again
266
281
  if (this.isAborting)
267
282
  return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
268
283
  if (this.dispatchAborted)
@@ -274,7 +289,7 @@ export class Agent {
274
289
  if (this.aiManager.isLoading || this.isCommandRunning)
275
290
  return;
276
291
  this.messageQueue.transitionTo("dispatching");
277
- this.dispatchPromise = this.processQueuedMessage()
292
+ this.dispatchPromise = this.asyncWorkRegistry.track(this.processQueuedMessage()
278
293
  .catch((error) => {
279
294
  this.logger?.error("Failed to process queued message:", error);
280
295
  })
@@ -285,7 +300,7 @@ export class Agent {
285
300
  this.tryDispatch(); // Re-check after processing
286
301
  }
287
302
  this.dispatchAborted = false;
288
- });
303
+ }));
289
304
  }
290
305
  /**
291
306
  * Process the next queued item when the agent becomes idle.
@@ -499,6 +514,7 @@ export class Agent {
499
514
  }
500
515
  /** Execute bash command (bang command) */
501
516
  async bang(command) {
517
+ this.assertNotDestroyed();
502
518
  // If the agent is busy, enqueue the bang command
503
519
  if (this.aiManager.isLoading || this.isCommandRunning) {
504
520
  this.messageQueue.enqueue({ type: "bang", content: command });
@@ -625,37 +641,14 @@ export class Agent {
625
641
  await this.foregroundTaskManager.backgroundCurrentTask();
626
642
  this.options.callbacks?.onBackgroundCurrentTask?.();
627
643
  }
628
- /**
629
- * Trigger WorktreeRemove hook before agent destruction.
630
- * Called from CLI exit dialog when user chooses to remove the worktree.
631
- * Non-blocking: errors logged but don't prevent removal.
632
- */
633
- async triggerWorktreeRemoveHook(worktreePath) {
634
- if (!this.hookManager.hasHooks("WorktreeRemove")) {
635
- return;
636
- }
637
- try {
638
- const sessionId = this.messageManager.getSessionId();
639
- const transcriptPath = this.messageManager.getTranscriptPath();
640
- const hookResults = await this.hookManager.executeHooks("WorktreeRemove", {
641
- event: "WorktreeRemove",
642
- projectDir: this.workdir,
643
- timestamp: new Date(),
644
- sessionId,
645
- transcriptPath,
646
- cwd: this.workdir,
647
- worktreePath,
648
- env: Object.fromEntries(Object.entries(this.configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
649
- });
650
- // Process results via messageManager (may not be visible during shutdown)
651
- this.hookManager.processHookResults("WorktreeRemove", hookResults, this.messageManager);
652
- }
653
- catch (error) {
654
- this.logger?.warn("WorktreeRemove hooks execution failed:", error);
655
- }
656
- }
657
644
  /** Destroy managers, clean up resources */
658
645
  async destroy() {
646
+ // Terminal guard: suppress any dispatch triggered during teardown (e.g. the
647
+ // onLoadingChange(false) fired by abortAIMessage() below, or the dispatch
648
+ // .finally re-check). Without this, leftover queued messages would be
649
+ // dispatched fire-and-forget and outlive the agent. Never reset: destroy()
650
+ // is terminal, no dispatch should ever start afterwards.
651
+ this.isDestroyed = true;
659
652
  // Log session_end event and shutdown telemetry
660
653
  await logOTelEvent("session_end", {
661
654
  duration: String(Math.round((Date.now() - this.sessionStartTime) / 1000)),
@@ -717,7 +710,13 @@ export class Agent {
717
710
  }
718
711
  // Cleanup remote settings polling
719
712
  remoteSettingsService.shutdown();
720
- // Cleanup memory store
713
+ // Drain live async work (dispatch, background subagents, fork subagents):
714
+ // abort steps above make them settle; wait for them so no async side
715
+ // effect outlives the agent. Timeout fallback keeps destroy bounded.
716
+ const drained = await this.asyncWorkRegistry.drain();
717
+ if (!drained) {
718
+ this.logger?.error(`Async work did not drain: ${this.asyncWorkRegistry.size} live work item(s) remain after destroy`);
719
+ }
721
720
  }
722
721
  /**
723
722
  * Trigger the rewind UI callback
@@ -738,6 +737,7 @@ export class Agent {
738
737
  * @returns Promise that resolves to the AI's answer
739
738
  */
740
739
  async askBtw(question, abortSignal, onContent, onReasoning) {
740
+ this.assertNotDestroyed();
741
741
  const result = await this.aiManager.runBtwFork(question, abortSignal, onContent, onReasoning);
742
742
  return result.content ?? result.error ?? "No response received";
743
743
  }
@@ -760,6 +760,7 @@ export class Agent {
760
760
  * @returns Promise that resolves to the background task ID
761
761
  */
762
762
  async forkSubagent(prompt, options, abortSignal) {
763
+ this.assertNotDestroyed();
763
764
  return this.aiManager.runForkSubagent(prompt, options, abortSignal);
764
765
  }
765
766
  /**
@@ -789,6 +790,7 @@ export class Agent {
789
790
  * ```
790
791
  */
791
792
  async sendMessage(content, images) {
793
+ this.assertNotDestroyed();
792
794
  // If the agent is busy, enqueue the message — unless it's an immediate
793
795
  // slash command (e.g., /clear, /compact) that should execute
794
796
  // right away even while AI is processing
@@ -92,8 +92,8 @@ Wave supports the following hook events:
92
92
  - \`PermissionRequest\`: Triggered when Wave requests permission to use a tool.
93
93
  - \`Stop\`: Triggered when Wave finishes its response cycle (no more tool calls).
94
94
  - \`SubagentStop\`: Triggered when a subagent finishes its response cycle.
95
- - \`WorktreeCreate\`: Triggered when a new worktree is created.
96
- - \`WorktreeRemove\`: Triggered before a worktree is removed (e.g., via ExitWorktree with \`action: "remove"\`). Non-blocking. Fires **before** the worktree directory is deleted so hooks can still read files inside it. The hook receives \`worktree_path\` in the JSON input. Useful for cleanup tasks (e.g., \`docker compose -p $(basename "$worktree_path") down\`).
95
+ - \`WorktreeCreate\`: Triggered to create a new worktree, replacing \`git worktree add\`. The hook performs the creation itself (e.g., \`git worktree add\`, or any other VCS/external provisioning) and must output the worktree's absolute path on stdout (path return). Creation is blocked if all hooks fail or produce no output. Receives \`name\` in the JSON input. The resulting session is marked "hook-based".
96
+ - \`WorktreeRemove\`: Triggered when a hook-based worktree (created by a \`WorktreeCreate\` hook) is removed (e.g., via ExitWorktree with \`action: "remove"\`), replacing \`git worktree remove\` for that worktree: the hook performs the removal itself (e.g., \`git worktree remove --force\` plus external resource cleanup). Fires **before** the worktree directory is deleted so hooks can still read files inside it. Receives \`worktree_path\` in the JSON input. Failures are logged but non-blocking. Git-created worktrees are removed by git directly and do not trigger this hook.
97
97
  - \`CwdChanged\`: Triggered when the working directory changes (e.g., entering/exiting a worktree). Non-blocking.
98
98
  - \`SessionStart\`: Triggered during session initialization. Hooks can inject \`additionalContext\` and \`initialUserMessage\` via stdout.
99
99
  - \`SessionEnd\`: Triggered during agent destruction (fire-and-forget, non-blocking). Useful for cleanup, resource teardown, and analytics.
@@ -245,9 +245,34 @@ SessionEnd hooks receive \`end_source\` in the JSON input indicating how the ses
245
245
  }
246
246
  \`\`\`
247
247
 
248
+ ## WorktreeCreate Hooks
249
+
250
+ \`WorktreeCreate\` hooks replace \`git worktree add\`: when configured, Wave does not create the worktree itself — the hook does. The hook must output the worktree's absolute path on **stdout** (the first successful hook's trimmed stdout is used as the path). All hooks failing or producing no output blocks the creation with \`WorktreeCreate hook failed: ...\`. The resulting session is marked "hook-based" and skips Wave's post-creation setup (\`settings.local.json\` / \`.worktreeinclude\` propagation) — the hook is responsible for any initialization.
251
+
252
+ ### Input
253
+ WorktreeCreate hooks receive \`name\` (the worktree name) in the JSON input, alongside the common fields \`session_id\`, \`transcript_path\`, \`cwd\`, \`hook_event_name\`.
254
+
255
+ ### Example Configuration
256
+ \`\`\`json
257
+ {
258
+ "hooks": {
259
+ "WorktreeCreate": [
260
+ {
261
+ "hooks": [
262
+ {
263
+ "command": "worktree_path=\\"$WAVE_PROJECT_DIR/.wave/worktrees/$(jq -r '.name')\\" && mkdir -p \\"$worktree_path\\" && git worktree add \\"$worktree_path\\" 2>/dev/null; echo \\"$worktree_path\\"",
264
+ "description": "Create the worktree and print its path"
265
+ }
266
+ ]
267
+ }
268
+ ]
269
+ }
270
+ }
271
+ \`\`\`
272
+
248
273
  ## WorktreeRemove Hooks
249
274
 
250
- \`WorktreeRemove\` hooks fire **before** the worktree directory is deleted, so they can still read files inside it. They are non-blocking (Notification type): the hook never replaces \`git worktree remove\` itself. Useful for cleaning up external resources that were provisioned for the worktree (databases, containers, etc.).
275
+ \`WorktreeRemove\` hooks replace \`git worktree remove\` for **hook-based** worktrees (those created by a \`WorktreeCreate\` hook). When a hook-based worktree is removed from any entry point (CLI exit, ExitWorktree tool, \`wave -p\`, stdio RPC), Wave calls the hook instead of running \`git worktree remove\` — the hook performs the actual removal, so it can clean up external resources it provisioned (databases, containers, etc.) at the same time. Hooks fire **before** the worktree directory is deleted, so they can still read files inside it. Failures are logged but non-blocking. If no \`WorktreeRemove\` hook is configured for a hook-based worktree, Wave logs a warning and leaves the worktree in place. Git-created worktrees (no \`WorktreeCreate\` hook) are removed by git directly and do **not** trigger this hook.
251
276
 
252
277
  ### Input
253
278
  WorktreeRemove hooks receive \`worktree_path\` in the JSON input (alongside the common fields \`session_id\`, \`transcript_path\`, \`cwd\`, \`hook_event_name\`). The worktree name can be derived via \`basename "$worktree_path"\`.
@@ -260,8 +285,8 @@ WorktreeRemove hooks receive \`worktree_path\` in the JSON input (alongside the
260
285
  {
261
286
  "hooks": [
262
287
  {
263
- "command": "worktree_path=$(jq -r '.worktree_path') && docker compose -p \\"$(basename \\"$worktree_path\\")\\" down || true",
264
- "description": "Tear down the worktree's docker compose project before removal"
288
+ "command": "worktree_path=$(jq -r '.worktree_path') && git worktree remove --force \\"$worktree_path\\" && docker compose -p \\"$(basename \\"$worktree_path\\")\\" down",
289
+ "description": "Remove the worktree and tear down its docker compose project"
265
290
  }
266
291
  ]
267
292
  }
@@ -297,7 +322,7 @@ When hooks are registered via a **plugin**, Wave automatically:
297
322
  }
298
323
  \`\`\`
299
324
 
300
- The shell also receives \`WAVE_PLUGIN_ROOT\` as an env var, so \`$WAVE_PLUGIN_ROOT\` works in the hook script itself.
325
+ The shell also receives \`WAVE_PLUGIN_ROOT\` as an env var, so \`$WAVE_PLUGIN_ROOT\` works in the hook script itself. For \`WorktreeCreate\`, the script must print the created worktree's absolute path to stdout.
301
326
 
302
327
  ## Best Practices
303
328
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./core/session.js";
2
2
  export * from "./services/authService.js";
3
+ export * from "./services/worktreeHooks.js";
3
4
  export * from "./constants/tools.js";
4
5
  export * from "./agent.js";
5
6
  export * from "./core/plugin.js";
@@ -21,6 +22,7 @@ export * from "./utils/hookMatcher.js";
21
22
  export * from "./utils/tokenCalculation.js";
22
23
  export * from "./utils/gitUtils.js";
23
24
  export * from "./utils/nameGenerator.js";
25
+ export * from "./utils/pathEncoder.js";
24
26
  export * from "./utils/worktreeSession.js";
25
27
  export * from "./utils/worktreeUtils.js";
26
28
  export { loadMergedWaveConfig, loadUserConfigEnv, } from "./services/configurationService.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Export all services
2
2
  export * from "./core/session.js";
3
3
  export * from "./services/authService.js";
4
+ export * from "./services/worktreeHooks.js";
4
5
  // Export constants
5
6
  export * from "./constants/tools.js";
6
7
  // Export main agent
@@ -25,6 +26,7 @@ export * from "./utils/hookMatcher.js";
25
26
  export * from "./utils/tokenCalculation.js";
26
27
  export * from "./utils/gitUtils.js";
27
28
  export * from "./utils/nameGenerator.js";
29
+ export * from "./utils/pathEncoder.js";
28
30
  export * from "./utils/worktreeSession.js";
29
31
  export * from "./utils/worktreeUtils.js";
30
32
  export { loadMergedWaveConfig, loadUserConfigEnv, } from "./services/configurationService.js";
@@ -12,6 +12,15 @@ interface ForkLoopResult {
12
12
  }
13
13
  export interface AIManagerCallbacks {
14
14
  onCompactionStateChange?: (isCompacting: boolean) => void;
15
+ /**
16
+ * Streaming content from the compaction fork (accumulated, same semantics
17
+ * as `onContentUpdate` in CallAgentOptions). Reasoning chunks fall back to
18
+ * this channel when no reasoning callback is provided. Consumed by the CLI
19
+ * to show the compaction loading tail.
20
+ */
21
+ onCompactionContentUpdate?: (content: string) => void;
22
+ /** Streaming reasoning from the compaction fork (accumulated). */
23
+ onCompactionReasoningUpdate?: (content: string) => void;
15
24
  onUsageAdded?: (usage: Usage) => void;
16
25
  onCwdChange?: (newCwd: string) => void;
17
26
  }
@@ -59,6 +68,7 @@ export declare class AIManager {
59
68
  private get memoryService();
60
69
  private get taskManager();
61
70
  private get backgroundTaskManager();
71
+ private get asyncWorkRegistry();
62
72
  private get hookManager();
63
73
  private get reversionManager();
64
74
  private get permissionManager();
@@ -103,6 +103,9 @@ export class AIManager {
103
103
  get backgroundTaskManager() {
104
104
  return this.container.get("BackgroundTaskManager");
105
105
  }
106
+ get asyncWorkRegistry() {
107
+ return this.container.get("AsyncWorkRegistry");
108
+ }
106
109
  get hookManager() {
107
110
  return this.container.get("HookManager");
108
111
  }
@@ -431,7 +434,7 @@ export class AIManager {
431
434
  // 4. Fork path: fork the conversation with the same system prompt,
432
435
  // tools, model, and generation params as the main loop so the forked
433
436
  // request prefix matches exactly and the prompt cache is reused.
434
- const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal);
437
+ const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal, this.callbacks?.onCompactionContentUpdate, this.callbacks?.onCompactionReasoningUpdate);
435
438
  const summaryContent = forkResult.content;
436
439
  const compactTokens = forkResult.usage;
437
440
  if (!summaryContent) {
@@ -600,6 +603,25 @@ export class AIManager {
600
603
  // gateway's idle timeout fires (non-streaming waits for the full
601
604
  // response, which exceeds the timeout on large contexts).
602
605
  stream: true,
606
+ // Surface partial output to the caller (e.g. the compaction loading
607
+ // tail) as it arrives. Reasoning chunks from thinking models go to
608
+ // the reasoning channel when the caller supplies one; otherwise they
609
+ // fall back to the content channel (CLI mixes both).
610
+ onContentUpdate: (content) => {
611
+ if (content.trim()) {
612
+ options.onContentUpdate?.(content);
613
+ }
614
+ },
615
+ onReasoningUpdate: (reasoning) => {
616
+ if (reasoning.trim()) {
617
+ if (options.onReasoningUpdate) {
618
+ options.onReasoningUpdate(reasoning);
619
+ }
620
+ else {
621
+ options.onContentUpdate?.(reasoning);
622
+ }
623
+ }
624
+ },
603
625
  });
604
626
  if (result.usage) {
605
627
  totalUsage = {
@@ -652,10 +674,12 @@ export class AIManager {
652
674
  * Fork-path compaction: deny all tool calls locally (the model is told to
653
675
  * summarize, not act) and feed the rejections back for another turn.
654
676
  */
655
- async runCompactFork(historyMessages, compactPrompt, abortSignal) {
677
+ async runCompactFork(historyMessages, compactPrompt, abortSignal, onContentUpdate, onReasoningUpdate) {
656
678
  return this.runForkLoop(historyMessages, compactPrompt, {
657
679
  maxTurns: MAX_FORK_TURNS,
658
680
  deniedToolMessage: "Tool use is not allowed during compaction",
681
+ onContentUpdate,
682
+ onReasoningUpdate,
659
683
  }, abortSignal);
660
684
  }
661
685
  /**
@@ -811,8 +835,10 @@ export class AIManager {
811
835
  }
812
836
  };
813
837
  // Fire-and-forget: the fork runs to completion in the background; the
814
- // caller gets the task ID immediately.
815
- (async () => {
838
+ // caller gets the task ID immediately. Tracked in the async work registry
839
+ // so destroy() drains it before returning (abort via the task's onStop
840
+ // makes the loop settle).
841
+ const forkPromise = (async () => {
816
842
  try {
817
843
  const result = await this.runForkLoop(historyMessages, prompt, {
818
844
  // /subtask aligns with Claude Code's fork subagent
@@ -859,6 +885,7 @@ export class AIManager {
859
885
  abortCleanup?.();
860
886
  }
861
887
  })();
888
+ this.asyncWorkRegistry?.track(forkPromise);
862
889
  return taskId;
863
890
  }
864
891
  /**
@@ -75,6 +75,7 @@ export declare class SubagentManager {
75
75
  private stream;
76
76
  constructor(container: Container, options: SubagentManagerOptions);
77
77
  private get configurationService();
78
+ private get asyncWorkRegistry();
78
79
  /**
79
80
  * Initialize the SubagentManager by loading and caching configurations
80
81
  */
@@ -23,6 +23,9 @@ export class SubagentManager {
23
23
  get configurationService() {
24
24
  return this.container.get("ConfigurationService");
25
25
  }
26
+ get asyncWorkRegistry() {
27
+ return this.container.get("AsyncWorkRegistry");
28
+ }
26
29
  /**
27
30
  * Initialize the SubagentManager by loading and caching configurations
28
31
  */
@@ -331,7 +334,7 @@ export class SubagentManager {
331
334
  instance.backgroundTaskId = taskId;
332
335
  // Execute in background
333
336
  // Note: notification enqueueing is handled by internalExecute when instance.backgroundTaskId is set
334
- (async () => {
337
+ const backgroundPromise = (async () => {
335
338
  try {
336
339
  const result = await this.internalExecute(instance, prompt, abortSignal);
337
340
  const task = backgroundTaskManager?.getTask(taskId);
@@ -360,6 +363,7 @@ export class SubagentManager {
360
363
  this.releaseInstance(instance.subagentId);
361
364
  }
362
365
  })();
366
+ this.asyncWorkRegistry?.track(backgroundPromise);
363
367
  return taskId;
364
368
  }
365
369
  const result = await this.internalExecute(instance, prompt, abortSignal);
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { spawn } from "child_process";
9
9
  import { generateSessionFilePath } from "./session.js";
10
+ import { resolveShellPath } from "../utils/shellResolver.js";
11
+ import { toPosixCommand } from "../utils/windowsPaths.js";
10
12
  // =============================================================================
11
13
  // Hook Execution Functions
12
14
  // =============================================================================
@@ -56,10 +58,10 @@ async function buildHookJsonInput(context) {
56
58
  if (context.subagentType !== undefined) {
57
59
  jsonInput.subagent_type = context.subagentType;
58
60
  }
59
- // Add name field for WorktreeCreate events
61
+ // Add name field for WorktreeCreate events (aligned with Claude Code)
60
62
  if (context.event === "WorktreeCreate") {
61
- if (context.worktreeName !== undefined) {
62
- jsonInput.name = context.worktreeName;
63
+ if (context.name !== undefined) {
64
+ jsonInput.name = context.name;
63
65
  }
64
66
  }
65
67
  // Add worktree_path field for WorktreeRemove events
@@ -133,11 +135,43 @@ export async function executeCommand(command, context, options) {
133
135
  let stdout = "";
134
136
  let stderr = "";
135
137
  let timedOut = false;
136
- // Parse command for shell execution
138
+ // Parse command for shell execution.
139
+ //
140
+ // Windows uses Git Bash (POSIX shell) instead of cmd.exe: cmd.exe's `/c`
141
+ // does not strip quotes from arguments after the first token, so commands
142
+ // like `node "C:\path\script.js"` silently fail (issue #1773). Git Bash
143
+ // parses quotes correctly, and Windows paths in the command are converted
144
+ // to POSIX form first (Git Bash resolves `/c/Users/...` and translates it
145
+ // back to a Windows path when spawning native executables like node.exe).
146
+ // Falls back to cmd.exe when no Git Bash is installed.
137
147
  const isWindows = process.platform === "win32";
138
- const shell = isWindows ? "cmd.exe" : "/bin/sh";
139
- const shellFlag = isWindows ? "/c" : "-c";
140
- const childProcess = spawn(shell, [shellFlag, command], {
148
+ const bashPath = isWindows ? resolveShellPath() : undefined;
149
+ let shell;
150
+ let shellFlag;
151
+ let finalCommand;
152
+ if (bashPath) {
153
+ shell = bashPath;
154
+ shellFlag = "-c";
155
+ finalCommand = toPosixCommand(command);
156
+ // Windows .sh scripts aren't directly executable — run them via bash
157
+ // when the command itself is a .sh file (e.g. `${WAVE_PLUGIN_ROOT}/x.sh`).
158
+ const trimmed = finalCommand.trim();
159
+ const firstToken = trimmed.match(/^("([^"]*)"|'([^']*)'|\S+)/)?.[0] ?? "";
160
+ if (firstToken.endsWith(".sh") && !trimmed.startsWith("bash ")) {
161
+ finalCommand = `bash ${finalCommand}`;
162
+ }
163
+ }
164
+ else if (isWindows) {
165
+ shell = "cmd.exe";
166
+ shellFlag = "/c";
167
+ finalCommand = command;
168
+ }
169
+ else {
170
+ shell = "/bin/sh";
171
+ shellFlag = "-c";
172
+ finalCommand = command;
173
+ }
174
+ const childProcess = spawn(shell, [shellFlag, finalCommand], {
141
175
  stdio: ["pipe", "pipe", "pipe"],
142
176
  cwd: context.projectDir,
143
177
  env: {
@@ -127,27 +127,6 @@ export class InitializationService {
127
127
  catch (error) {
128
128
  logger?.warn("SessionStart hooks execution failed:", error);
129
129
  }
130
- // Trigger WorktreeCreate hook if this is a new worktree
131
- if (agentOptions.isNewWorktree && hookManager) {
132
- try {
133
- logger?.info(`Triggering WorktreeCreate hook for ${agentOptions.worktreeName}...`);
134
- const hookResults = await hookManager.executeHooks("WorktreeCreate", {
135
- event: "WorktreeCreate",
136
- projectDir: workdir,
137
- timestamp: new Date(),
138
- sessionId: messageManager.getSessionId(),
139
- transcriptPath: messageManager.getTranscriptPath(),
140
- cwd: workdir,
141
- worktreeName: agentOptions.worktreeName,
142
- env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
143
- });
144
- // Process hook results
145
- hookManager.processHookResults("WorktreeCreate", hookResults, messageManager);
146
- }
147
- catch (error) {
148
- logger?.warn("WorktreeCreate hooks execution failed:", error);
149
- }
150
- }
151
130
  // Resolve and validate configuration after loading settings.json
152
131
  resolveAndValidateConfig();
153
132
  // Initialize auto-memory directory
@@ -10,6 +10,20 @@ import type { SessionFilename } from "../types/session.js";
10
10
  export interface JsonlWriteOptions {
11
11
  atomic?: boolean;
12
12
  }
13
+ /**
14
+ * Creation-time metadata persisted in the session file's header line
15
+ * (`{"type":"metadata",...}`). The header is append-only — written once on
16
+ * session creation and never rewritten — so only fields that are fixed at
17
+ * creation time belong here.
18
+ */
19
+ export interface SessionMetadataHeader {
20
+ /** Real working directory (the encoded project dir name is lossy for paths containing "-"). */
21
+ workdir?: string;
22
+ /** ISO 8601 creation timestamp. */
23
+ createdAt?: string;
24
+ /** Git branch at creation time (`git branch --show-current`), when the directory is a git repo. */
25
+ gitBranch?: string;
26
+ }
13
27
  /**
14
28
  * JSONL handler class for message persistence operations
15
29
  */
@@ -17,9 +31,20 @@ export declare class JsonlHandler {
17
31
  private readonly defaultWriteOptions;
18
32
  constructor();
19
33
  /**
20
- * Create a new session file (simplified - no metadata header)
34
+ * Create a new session file.
35
+ *
36
+ * When `metadata` is provided, the first line is a metadata header
37
+ * recording creation-time facts about the session:
38
+ * `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`. The
39
+ * encoded project dir name is lossy for paths containing "-", so persisting
40
+ * the real path lets session listing show it without decoding; `createdAt`
41
+ * and `gitBranch` similarly avoid lossy/fabricated reconstruction later.
42
+ * The header carries no `timestamp`, so message readers filter it out
43
+ * naturally.
44
+ *
45
+ * Legacy callers that omit `metadata` still get an empty file.
21
46
  */
22
- createSession(filePath: string): Promise<void>;
47
+ createSession(filePath: string, metadata?: SessionMetadataHeader): Promise<void>;
23
48
  /**
24
49
  * Append a single message to JSONL file
25
50
  */
@@ -40,6 +65,16 @@ export declare class JsonlHandler {
40
65
  * Get the last message from JSONL file using efficient file reading (simplified)
41
66
  */
42
67
  getLastMessage(filePath: string): Promise<Message | null>;
68
+ /**
69
+ * Read the creation-time metadata from the session file's header line.
70
+ *
71
+ * Newer session files start with a `{"type":"metadata",...}` line (see
72
+ * `createSession`). Legacy files have no header.
73
+ *
74
+ * @param filePath - Path to the session JSONL file
75
+ * @returns The persisted metadata, or null when the file has no header
76
+ */
77
+ readMetadata(filePath: string): Promise<SessionMetadataHeader | null>;
43
78
  /**
44
79
  * Validate messages before writing
45
80
  */