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.
- package/dist/agent.d.ts +9 -6
- package/dist/agent.js +35 -33
- package/dist/builtin/skills/settings.js +31 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/managers/aiManager.d.ts +10 -0
- package/dist/managers/aiManager.js +31 -4
- package/dist/managers/subagentManager.d.ts +1 -0
- package/dist/managers/subagentManager.js +5 -1
- package/dist/services/hook.js +41 -7
- package/dist/services/initializationService.js +0 -21
- package/dist/services/jsonlHandler.d.ts +37 -2
- package/dist/services/jsonlHandler.js +55 -6
- package/dist/services/session.d.ts +35 -4
- package/dist/services/session.js +233 -36
- package/dist/services/worktreeHooks.d.ts +45 -0
- package/dist/services/worktreeHooks.js +133 -0
- package/dist/tools/agentTool.js +36 -1
- package/dist/tools/bashTool.js +120 -57
- package/dist/tools/enterWorktreeTool.d.ts +1 -1
- package/dist/tools/enterWorktreeTool.js +44 -31
- package/dist/tools/exitWorktreeTool.js +21 -26
- package/dist/types/agent.d.ts +5 -0
- package/dist/types/hooks.d.ts +3 -2
- package/dist/types/skills.d.ts +0 -1
- package/dist/types/skills.js +0 -1
- package/dist/utils/asyncWorkRegistry.d.ts +32 -0
- package/dist/utils/asyncWorkRegistry.js +81 -0
- package/dist/utils/containerSetup.js +5 -0
- package/dist/utils/skillParser.js +3 -6
- package/dist/utils/windowsPaths.d.ts +28 -0
- package/dist/utils/windowsPaths.js +47 -0
- package/dist/utils/worktreeSession.d.ts +6 -0
- package/dist/utils/worktreeUtils.d.ts +7 -0
- package/dist/utils/worktreeUtils.js +88 -40
- 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; //
|
|
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
|
-
//
|
|
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
|
|
96
|
-
- \`WorktreeRemove\`: Triggered
|
|
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.
|
|
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
|
|
264
|
-
"description": "
|
|
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
|
-
|
|
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);
|
package/dist/services/hook.js
CHANGED
|
@@ -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.
|
|
62
|
-
jsonInput.name = context.
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
*/
|