wave-agent-sdk 0.14.1 → 0.14.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 (78) hide show
  1. package/builtin/skills/settings/HOOKS.md +69 -0
  2. package/builtin/skills/settings/PLUGINS.md +171 -0
  3. package/builtin/skills/settings/SKILL.md +8 -3
  4. package/dist/agent.d.ts +2 -2
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +12 -3
  7. package/dist/managers/aiManager.d.ts +6 -6
  8. package/dist/managers/aiManager.d.ts.map +1 -1
  9. package/dist/managers/aiManager.js +122 -59
  10. package/dist/managers/backgroundTaskManager.d.ts.map +1 -1
  11. package/dist/managers/backgroundTaskManager.js +28 -18
  12. package/dist/managers/hookManager.d.ts +16 -1
  13. package/dist/managers/hookManager.d.ts.map +1 -1
  14. package/dist/managers/hookManager.js +97 -8
  15. package/dist/managers/messageManager.d.ts +19 -4
  16. package/dist/managers/messageManager.d.ts.map +1 -1
  17. package/dist/managers/messageManager.js +63 -18
  18. package/dist/prompts/index.d.ts +1 -1
  19. package/dist/prompts/index.d.ts.map +1 -1
  20. package/dist/prompts/index.js +1 -1
  21. package/dist/services/MarketplaceService.d.ts +0 -11
  22. package/dist/services/MarketplaceService.d.ts.map +1 -1
  23. package/dist/services/MarketplaceService.js +21 -89
  24. package/dist/services/aiService.d.ts +3 -3
  25. package/dist/services/aiService.d.ts.map +1 -1
  26. package/dist/services/aiService.js +7 -7
  27. package/dist/services/hook.d.ts.map +1 -1
  28. package/dist/services/hook.js +15 -0
  29. package/dist/services/initializationService.d.ts.map +1 -1
  30. package/dist/services/initializationService.js +24 -1
  31. package/dist/services/interactionService.js +1 -1
  32. package/dist/services/pluginLoader.d.ts +0 -6
  33. package/dist/services/pluginLoader.d.ts.map +1 -1
  34. package/dist/services/pluginLoader.js +14 -53
  35. package/dist/services/session.d.ts +1 -1
  36. package/dist/services/session.js +7 -7
  37. package/dist/services/taskManager.d.ts +1 -1
  38. package/dist/services/taskManager.js +1 -1
  39. package/dist/types/core.d.ts +1 -1
  40. package/dist/types/core.d.ts.map +1 -1
  41. package/dist/types/hooks.d.ts +9 -1
  42. package/dist/types/hooks.d.ts.map +1 -1
  43. package/dist/types/hooks.js +2 -0
  44. package/dist/types/marketplace.d.ts +1 -26
  45. package/dist/types/marketplace.d.ts.map +1 -1
  46. package/dist/types/messaging.d.ts +3 -3
  47. package/dist/types/messaging.d.ts.map +1 -1
  48. package/dist/types/plugins.d.ts +1 -13
  49. package/dist/types/plugins.d.ts.map +1 -1
  50. package/dist/utils/convertMessagesForAPI.d.ts +1 -1
  51. package/dist/utils/convertMessagesForAPI.js +7 -7
  52. package/dist/utils/groupMessagesByApiRound.d.ts +1 -1
  53. package/dist/utils/groupMessagesByApiRound.js +6 -6
  54. package/dist/utils/messageOperations.d.ts.map +1 -1
  55. package/dist/utils/messageOperations.js +3 -3
  56. package/package.json +1 -1
  57. package/src/agent.ts +16 -3
  58. package/src/managers/aiManager.ts +142 -63
  59. package/src/managers/backgroundTaskManager.ts +32 -22
  60. package/src/managers/hookManager.ts +125 -10
  61. package/src/managers/messageManager.ts +76 -22
  62. package/src/prompts/index.ts +1 -1
  63. package/src/services/MarketplaceService.ts +26 -127
  64. package/src/services/aiService.ts +11 -11
  65. package/src/services/hook.ts +17 -0
  66. package/src/services/initializationService.ts +33 -1
  67. package/src/services/interactionService.ts +1 -1
  68. package/src/services/pluginLoader.ts +14 -67
  69. package/src/services/session.ts +7 -7
  70. package/src/services/taskManager.ts +1 -1
  71. package/src/types/core.ts +1 -1
  72. package/src/types/hooks.ts +16 -2
  73. package/src/types/marketplace.ts +1 -24
  74. package/src/types/messaging.ts +3 -3
  75. package/src/types/plugins.ts +1 -13
  76. package/src/utils/convertMessagesForAPI.ts +8 -8
  77. package/src/utils/groupMessagesByApiRound.ts +6 -6
  78. package/src/utils/messageOperations.ts +3 -5
@@ -30,7 +30,7 @@ import type { NotificationQueue } from "./notificationQueue.js";
30
30
  import { logger } from "../utils/globalLogger.js";
31
31
 
32
32
  export interface AIManagerCallbacks {
33
- onCompressionStateChange?: (isCompressing: boolean) => void;
33
+ onCompactionStateChange?: (isCompacting: boolean) => void;
34
34
  onUsageAdded?: (usage: Usage) => void;
35
35
  onCwdChange?: (newCwd: string) => void;
36
36
  }
@@ -58,7 +58,7 @@ export class AIManager {
58
58
  private stream: boolean; // Streaming mode flag
59
59
  private modelOverride?: string;
60
60
  private _onCwdChange?: (newCwd: string) => void; // Store callback for CWD changes
61
- private consecutiveCompressionFailures: number = 0;
61
+ private consecutiveCompactionFailures: number = 0;
62
62
 
63
63
  // Service overrides
64
64
  constructor(
@@ -177,7 +177,7 @@ export class AIManager {
177
177
  this._onCwdChange = callback;
178
178
  }
179
179
 
180
- private isCompressing: boolean = false;
180
+ private isCompacting: boolean = false;
181
181
  private callbacks: AIManagerCallbacks;
182
182
 
183
183
  /**
@@ -253,8 +253,8 @@ export class AIManager {
253
253
  return "";
254
254
  }
255
255
 
256
- // Private method to handle token statistics and message compression
257
- private async handleTokenUsageAndCompression(
256
+ // Private method to handle token statistics and message compaction
257
+ private async handleTokenUsageAndCompaction(
258
258
  usage: Usage | undefined,
259
259
  abortController: AbortController,
260
260
  ): Promise<void> {
@@ -272,30 +272,30 @@ export class AIManager {
272
272
  this.getMaxInputTokens()
273
273
  ) {
274
274
  logger?.debug(
275
- `Token usage exceeded ${this.getMaxInputTokens()}, compressing messages...`,
275
+ `Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`,
276
276
  );
277
277
 
278
- // Check if messages need compression
279
- const messagesToCompress = this.messageManager.getMessages();
278
+ // Check if messages need compaction
279
+ const messagesToCompact = this.messageManager.getMessages();
280
280
 
281
- // If there are messages to compress, perform compression
282
- if (messagesToCompress.length > 0) {
283
- // Circuit breaker: skip compression after 3 consecutive failures
284
- if (this.consecutiveCompressionFailures >= 3) {
281
+ // If there are messages to compact, perform compaction
282
+ if (messagesToCompact.length > 0) {
283
+ // Circuit breaker: skip compaction after 3 consecutive failures
284
+ if (this.consecutiveCompactionFailures >= 3) {
285
285
  logger?.warn(
286
- `Skipping compression: ${this.consecutiveCompressionFailures} consecutive failures`,
286
+ `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`,
287
287
  );
288
288
  return;
289
289
  }
290
290
 
291
- const recentChatMessages = convertMessagesForAPI(messagesToCompress);
291
+ const recentChatMessages = convertMessagesForAPI(messagesToCompact);
292
292
 
293
- // Save session before compression to preserve original messages
293
+ // Save session before compaction to preserve original messages
294
294
  await this.messageManager.saveSession();
295
295
 
296
- this.setIsCompressing(true);
296
+ this.setIsCompacting(true);
297
297
  try {
298
- const compressionResult = await aiService.compressMessages({
298
+ const compactResult = await aiService.compactMessages({
299
299
  gatewayConfig: this.getGatewayConfig(),
300
300
  modelConfig: this.getModelConfig(),
301
301
  messages: recentChatMessages,
@@ -303,15 +303,15 @@ export class AIManager {
303
303
  model: this.getModelConfig().fastModel,
304
304
  });
305
305
 
306
- // Handle usage tracking for compression operations
307
- let compressionUsage: Usage | undefined;
308
- if (compressionResult.usage) {
309
- compressionUsage = {
310
- prompt_tokens: compressionResult.usage.prompt_tokens,
311
- completion_tokens: compressionResult.usage.completion_tokens,
312
- total_tokens: compressionResult.usage.total_tokens,
306
+ // Handle usage tracking for compaction operations
307
+ let compactUsage: Usage | undefined;
308
+ if (compactResult.usage) {
309
+ compactUsage = {
310
+ prompt_tokens: compactResult.usage.prompt_tokens,
311
+ completion_tokens: compactResult.usage.completion_tokens,
312
+ total_tokens: compactResult.usage.total_tokens,
313
313
  model: this.getModelConfig().fastModel,
314
- operation_type: "compress",
314
+ operation_type: "compact",
315
315
  };
316
316
  }
317
317
 
@@ -364,73 +364,152 @@ export class AIManager {
364
364
  }
365
365
  }
366
366
 
367
- // 4. Skills context
368
- const skills =
369
- this.skillManager
370
- ?.getAvailableSkills()
371
- .filter((s) => !s.disableModelInvocation) || [];
372
- if (skills.length > 0) {
373
- const skillList = skills
374
- .map((s) => `- ${s.name}: ${s.description || ""}`)
375
- .join("\n");
376
- contextParts.push(`\n\n[Available Skills]\n${skillList}`);
367
+ // 4. Invoked skills context (with token budget, matching Claude Code)
368
+ const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
369
+ const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
370
+ const invokedSkillNames =
371
+ this.messageManager.getInvokedSkillNames(10);
372
+ if (invokedSkillNames.length > 0 && this.skillManager) {
373
+ const invokedSkillParts: string[] = [];
374
+ let skillsUsedTokens = 0;
375
+ for (const skillName of invokedSkillNames) {
376
+ try {
377
+ const skill = await this.skillManager.loadSkill(skillName);
378
+ if (!skill) continue;
379
+
380
+ // Extract content after frontmatter (matching prepareSkillContent pattern)
381
+ const contentMatch = skill.content.match(
382
+ /^---\n[\s\S]*?\n---\n([\s\S]*)$/,
383
+ );
384
+ let skillContent = contentMatch
385
+ ? contentMatch[1].trim()
386
+ : skill.content;
387
+
388
+ // Per-skill token budget enforcement (~4 chars per token)
389
+ const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4;
390
+ if (skillContent.length > maxSkillChars) {
391
+ skillContent =
392
+ skillContent.slice(0, maxSkillChars) +
393
+ "\n\n...[truncated]...";
394
+ }
395
+
396
+ const skillTokens = Math.ceil(skillContent.length / 4);
397
+ if (
398
+ skillsUsedTokens + skillTokens >
399
+ POST_COMPACT_SKILLS_TOKEN_BUDGET
400
+ )
401
+ break;
402
+ skillsUsedTokens += skillTokens;
403
+
404
+ invokedSkillParts.push(
405
+ `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``,
406
+ );
407
+ } catch {
408
+ // Skip skills that can't be loaded
409
+ }
410
+ }
411
+ if (invokedSkillParts.length > 0) {
412
+ contextParts.push(
413
+ `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`,
414
+ );
415
+ }
377
416
  }
378
417
 
379
- // 5. Background agents status
380
- const agents = this.backgroundTaskManager?.getAllTasks() || [];
418
+ // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded)
419
+ const agents =
420
+ this.backgroundTaskManager
421
+ ?.getAllTasks()
422
+ .filter((a) => a.type === "subagent") || [];
381
423
  if (agents.length > 0) {
382
- const agentList = agents
383
- .map((a) => `- Agent "${a.description}": ${a.status}`)
384
- .join("\n");
385
- contextParts.push(`\n\n[Background Tasks]\n${agentList}`);
424
+ const agentParts: string[] = [];
425
+ for (const a of agents) {
426
+ if (a.status === "killed") {
427
+ agentParts.push(
428
+ `Task "${a.description}" (${a.id}) was stopped by the user.`,
429
+ );
430
+ } else if (a.status === "running") {
431
+ const parts = [
432
+ `Background agent "${a.description}" (${a.id}) is still running.`,
433
+ `Do NOT spawn a duplicate. You will be notified when it completes.`,
434
+ ];
435
+ if (a.outputPath) {
436
+ parts.push(`You can read partial output at ${a.outputPath}.`);
437
+ }
438
+ agentParts.push(parts.join(" "));
439
+ } else {
440
+ // completed or failed
441
+ const parts = [
442
+ `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`,
443
+ ];
444
+ const deltaText = a.status === "failed" ? a.stderr : a.stdout;
445
+ if (deltaText && deltaText.length > 0) {
446
+ const summary =
447
+ deltaText.length > 500
448
+ ? deltaText.slice(0, 500) + "..."
449
+ : deltaText;
450
+ parts.push(`Delta: ${summary}`);
451
+ }
452
+ if (a.outputPath) {
453
+ parts.push(
454
+ `Read the output file to retrieve the result: ${a.outputPath}.`,
455
+ );
456
+ }
457
+ agentParts.push(parts.join(" "));
458
+ }
459
+ }
460
+ if (agentParts.length > 0) {
461
+ contextParts.push(
462
+ `\n\n[Background Tasks]\n${agentParts.join("\n")}`,
463
+ );
464
+ }
386
465
  }
387
466
 
388
467
  // Merge context restoration into summary
389
468
  const enhancedSummary =
390
- compressionResult.content +
469
+ compactResult.content +
391
470
  (contextParts.length > 0
392
471
  ? `\n\n[Context Restoration]` + contextParts.join("")
393
472
  : "");
394
473
 
395
- // Execute message reconstruction and sessionId update after compression
396
- this.messageManager.compressMessagesAndUpdateSession(
474
+ // Execute message reconstruction and sessionId update after compaction
475
+ this.messageManager.compactMessagesAndUpdateSession(
397
476
  enhancedSummary,
398
- compressionUsage,
477
+ compactUsage,
399
478
  );
400
479
 
401
480
  // Notify Agent to add to usage tracking
402
- if (compressionUsage && this.callbacks?.onUsageAdded) {
403
- this.callbacks.onUsageAdded(compressionUsage);
481
+ if (compactUsage && this.callbacks?.onUsageAdded) {
482
+ this.callbacks.onUsageAdded(compactUsage);
404
483
  }
405
484
 
406
485
  logger?.debug(
407
- `Successfully compressed ${messagesToCompress.length} messages and updated session`,
486
+ `Successfully compacted ${messagesToCompact.length} messages and updated session`,
408
487
  );
409
- this.consecutiveCompressionFailures = 0;
410
- } catch (compressError) {
411
- this.consecutiveCompressionFailures++;
488
+ this.consecutiveCompactionFailures = 0;
489
+ } catch (compactError) {
490
+ this.consecutiveCompactionFailures++;
412
491
  logger?.error(
413
- `Failed to compress messages (${this.consecutiveCompressionFailures} consecutive):`,
414
- compressError,
492
+ `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`,
493
+ compactError,
415
494
  );
416
495
  this.messageManager.addErrorBlock(
417
- `Failed to compress conversation history: ${compressError instanceof Error ? compressError.message : String(compressError)}. You may encounter context limit issues.`,
496
+ `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
418
497
  );
419
498
  } finally {
420
- this.setIsCompressing(false);
499
+ this.setIsCompacting(false);
421
500
  }
422
501
  }
423
502
  }
424
503
  }
425
504
 
426
- public getIsCompressing(): boolean {
427
- return this.isCompressing;
505
+ public getIsCompacting(): boolean {
506
+ return this.isCompacting;
428
507
  }
429
508
 
430
- public setIsCompressing(isCompressing: boolean): void {
431
- if (this.isCompressing !== isCompressing) {
432
- this.isCompressing = isCompressing;
433
- this.callbacks.onCompressionStateChange?.(isCompressing);
509
+ public setIsCompacting(isCompacting: boolean): void {
510
+ if (this.isCompacting !== isCompacting) {
511
+ this.isCompacting = isCompacting;
512
+ this.callbacks.onCompactionStateChange?.(isCompacting);
434
513
  }
435
514
  }
436
515
 
@@ -914,8 +993,8 @@ export class AIManager {
914
993
  await Promise.all(toolExecutionPromises);
915
994
  }
916
995
 
917
- // Handle token statistics and message compression
918
- await this.handleTokenUsageAndCompression(result.usage, abortController);
996
+ // Handle token statistics and message compaction
997
+ await this.handleTokenUsageAndCompaction(result.usage, abortController);
919
998
 
920
999
  // Finalize text/reasoning blocks for the final response (no tools)
921
1000
  this.messageManager.finalizeStreamingBlocks();
@@ -153,22 +153,27 @@ export class BackgroundTaskManager {
153
153
  if (logStream.writable) {
154
154
  logStream.end();
155
155
  }
156
- shell.status = code === 0 ? "completed" : "failed";
156
+ const wasKilled = shell.status === "killed";
157
+ if (!wasKilled) {
158
+ shell.status = code === 0 ? "completed" : "failed";
159
+ }
157
160
  shell.exitCode = code ?? 0;
158
161
  shell.endTime = Date.now();
159
162
  shell.runtime = shell.endTime - startTime;
160
163
  this.notifyTasksChange();
161
164
 
162
- // Enqueue completion notification
163
- const notificationQueue = this.container.has("NotificationQueue")
164
- ? this.container.get<NotificationQueue>("NotificationQueue")
165
- : undefined;
166
- if (notificationQueue) {
167
- const statusStr = shell.status;
168
- const summary = `Command "${command}" ${statusStr} with exit code ${code ?? 0}`;
169
- notificationQueue.enqueue(
170
- `<task-notification>\n<task-id>${id}</task-id>\n<task-type>shell</task-type>\n<output-file>${logPath}</output-file>\n<status>${statusStr}</status>\n<summary>${summary}</summary>\n</task-notification>`,
171
- );
165
+ // Skip notification if task was manually killed (user/agent-initiated stop)
166
+ if (!wasKilled) {
167
+ const notificationQueue = this.container.has("NotificationQueue")
168
+ ? this.container.get<NotificationQueue>("NotificationQueue")
169
+ : undefined;
170
+ if (notificationQueue) {
171
+ const statusStr = shell.status;
172
+ const summary = `Command "${command}" ${statusStr} with exit code ${code ?? 0}`;
173
+ notificationQueue.enqueue(
174
+ `<task-notification>\n<task-id>${id}</task-id>\n<task-type>shell</task-type>\n<output-file>${logPath}</output-file>\n<status>${statusStr}</status>\n<summary>${summary}</summary>\n</task-notification>`,
175
+ );
176
+ }
172
177
  }
173
178
  };
174
179
 
@@ -301,22 +306,27 @@ export class BackgroundTaskManager {
301
306
  if (logStream.writable) {
302
307
  logStream.end();
303
308
  }
304
- shell.status = code === 0 ? "completed" : "failed";
309
+ const wasKilled = shell.status === "killed";
310
+ if (!wasKilled) {
311
+ shell.status = code === 0 ? "completed" : "failed";
312
+ }
305
313
  shell.exitCode = code ?? 0;
306
314
  shell.endTime = Date.now();
307
315
  shell.runtime = shell.endTime - startTime;
308
316
  this.notifyTasksChange();
309
317
 
310
- // Enqueue completion notification
311
- const notificationQueue = this.container.has("NotificationQueue")
312
- ? this.container.get<NotificationQueue>("NotificationQueue")
313
- : undefined;
314
- if (notificationQueue) {
315
- const statusStr = shell.status;
316
- const summary = `Command "${command}" ${statusStr} with exit code ${code ?? 0}`;
317
- notificationQueue.enqueue(
318
- `<task-notification>\n<task-id>${id}</task-id>\n<task-type>shell</task-type>\n<output-file>${logPath}</output-file>\n<status>${statusStr}</status>\n<summary>${summary}</summary>\n</task-notification>`,
319
- );
318
+ // Skip notification if task was manually killed (user/agent-initiated stop)
319
+ if (!wasKilled) {
320
+ const notificationQueue = this.container.has("NotificationQueue")
321
+ ? this.container.get<NotificationQueue>("NotificationQueue")
322
+ : undefined;
323
+ if (notificationQueue) {
324
+ const statusStr = shell.status;
325
+ const summary = `Command "${command}" ${statusStr} with exit code ${code ?? 0}`;
326
+ notificationQueue.enqueue(
327
+ `<task-notification>\n<task-id>${id}</task-id>\n<task-type>shell</task-type>\n<output-file>${logPath}</output-file>\n<status>${statusStr}</status>\n<summary>${summary}</summary>\n</task-notification>`,
328
+ );
329
+ }
320
330
  }
321
331
  });
322
332
 
@@ -12,6 +12,7 @@ import {
12
12
  type ExtendedHookExecutionContext,
13
13
  type HookExecutionResult,
14
14
  type HookValidationResult,
15
+ type SessionEndSource,
15
16
  HookConfigurationError,
16
17
  isValidHookEvent,
17
18
  isValidHookEventConfig,
@@ -80,9 +81,8 @@ export class HookManager {
80
81
  */
81
82
  loadConfigurationFromWaveConfig(waveConfig: WaveConfiguration | null): void {
82
83
  try {
83
- this.configuration = waveConfig?.hooks || undefined;
84
-
85
- // Validate the loaded configuration if it exists
84
+ // Merge Wave configuration hooks with existing plugin hooks
85
+ // (plugin hooks were registered earlier via registerPluginHooks)
86
86
  if (waveConfig?.hooks) {
87
87
  const validation = this.validatePartialConfiguration(waveConfig.hooks);
88
88
  if (!validation.valid) {
@@ -91,17 +91,18 @@ export class HookManager {
91
91
  validation.errors,
92
92
  );
93
93
  }
94
+ if (!this.configuration) {
95
+ this.configuration = {};
96
+ }
97
+ this.mergeHooksConfiguration(this.configuration, waveConfig.hooks);
94
98
  }
95
99
  } catch (error) {
96
- // If loading fails, start with undefined configuration (no hooks)
97
- this.configuration = undefined;
98
-
99
100
  // Re-throw configuration errors, but handle other errors gracefully
100
101
  if (error instanceof HookConfigurationError) {
101
102
  throw error;
102
103
  } else {
103
104
  logger?.warn(
104
- `[HookManager] Failed to load configuration, continuing with no hooks: ${(error as Error).message}`,
105
+ `[HookManager] Failed to load configuration, continuing with existing hooks: ${(error as Error).message}`,
105
106
  );
106
107
  }
107
108
  }
@@ -298,6 +299,8 @@ export class HookManager {
298
299
  source: MessageSource.HOOK,
299
300
  });
300
301
  }
302
+ // For SessionStart, stdout is processed separately in executeSessionStartHooks
303
+ // For SessionEnd, stdout is ignored (fire-and-forget cleanup)
301
304
  // For other hook types (PreToolUse, PostToolUse, Stop, PermissionRequest), ignore stdout
302
305
  }
303
306
 
@@ -374,6 +377,16 @@ export class HookManager {
374
377
  messageManager.addErrorBlock(errorMessage);
375
378
  return { shouldBlock: false };
376
379
 
380
+ case "SessionStart":
381
+ // Non-blocking for startup, show error in error block
382
+ messageManager.addErrorBlock(errorMessage);
383
+ return { shouldBlock: false };
384
+
385
+ case "SessionEnd":
386
+ // Blocking error (exit code 2): show in error block, don't block shutdown
387
+ messageManager.addErrorBlock(errorMessage);
388
+ return { shouldBlock: false };
389
+
377
390
  default:
378
391
  return { shouldBlock: false };
379
392
  }
@@ -577,7 +590,9 @@ export class HookManager {
577
590
  (event === "UserPromptSubmit" ||
578
591
  event === "Stop" ||
579
592
  event === "SubagentStop" ||
580
- event === "WorktreeCreate") &&
593
+ event === "WorktreeCreate" ||
594
+ event === "SessionStart" ||
595
+ event === "SessionEnd") &&
581
596
  context.toolName !== undefined
582
597
  ) {
583
598
  logger?.warn(
@@ -654,7 +669,9 @@ export class HookManager {
654
669
  event === "Stop" ||
655
670
  event === "SubagentStop" ||
656
671
  event === "WorktreeCreate" ||
657
- event === "CwdChanged"
672
+ event === "CwdChanged" ||
673
+ event === "SessionStart" ||
674
+ event === "SessionEnd"
658
675
  ) {
659
676
  return true;
660
677
  }
@@ -714,7 +731,9 @@ export class HookManager {
714
731
  (event === "UserPromptSubmit" ||
715
732
  event === "Stop" ||
716
733
  event === "SubagentStop" ||
717
- event === "WorktreeCreate") &&
734
+ event === "WorktreeCreate" ||
735
+ event === "SessionStart" ||
736
+ event === "SessionEnd") &&
718
737
  config.matcher
719
738
  ) {
720
739
  errors.push(`${prefix}: Event ${event} should not have a matcher`);
@@ -755,6 +774,8 @@ export class HookManager {
755
774
  PermissionRequest: 0,
756
775
  WorktreeCreate: 0,
757
776
  CwdChanged: 0,
777
+ SessionStart: 0,
778
+ SessionEnd: 0,
758
779
  },
759
780
  };
760
781
  }
@@ -768,6 +789,8 @@ export class HookManager {
768
789
  PermissionRequest: 0,
769
790
  WorktreeCreate: 0,
770
791
  CwdChanged: 0,
792
+ SessionStart: 0,
793
+ SessionEnd: 0,
771
794
  };
772
795
 
773
796
  let totalConfigs = 0;
@@ -844,4 +867,96 @@ export class HookManager {
844
867
 
845
868
  this.mergeHooksConfiguration(this.configuration, stampedHooks);
846
869
  }
870
+
871
+ /**
872
+ * Execute SessionStart hooks during initialization.
873
+ * Collects additionalContext and initialUserMessage from hook stdout.
874
+ */
875
+ async executeSessionStartHooks(
876
+ source: "startup" | "resume" | "compact",
877
+ sessionId: string,
878
+ transcriptPath: string,
879
+ agentType?: string,
880
+ ): Promise<{
881
+ results: HookExecutionResult[];
882
+ additionalContext?: string;
883
+ initialUserMessage?: string;
884
+ }> {
885
+ const context: ExtendedHookExecutionContext = {
886
+ event: "SessionStart",
887
+ projectDir: this.workdir,
888
+ timestamp: new Date(),
889
+ sessionId,
890
+ transcriptPath,
891
+ cwd: this.workdir,
892
+ source,
893
+ agentType,
894
+ env: Object.fromEntries(
895
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
896
+ ) as Record<string, string>,
897
+ };
898
+
899
+ const results = await this.executeHooks("SessionStart", context);
900
+
901
+ let additionalContext: string | undefined;
902
+ let initialUserMessage: string | undefined;
903
+
904
+ // Process stdout from successful hooks
905
+ for (const result of results) {
906
+ if (result.success && result.stdout?.trim()) {
907
+ const trimmed = result.stdout.trim();
908
+ // Try to parse as JSON for structured output
909
+ try {
910
+ const parsed = JSON.parse(trimmed);
911
+ if (parsed.hookSpecificOutput?.additionalContext) {
912
+ additionalContext =
913
+ (additionalContext ? additionalContext + "\n" : "") +
914
+ parsed.hookSpecificOutput.additionalContext;
915
+ }
916
+ if (parsed.initialUserMessage) {
917
+ initialUserMessage = parsed.initialUserMessage;
918
+ }
919
+ } catch {
920
+ // Not JSON, treat as additional context
921
+ additionalContext =
922
+ (additionalContext ? additionalContext + "\n" : "") + trimmed;
923
+ }
924
+ }
925
+ }
926
+
927
+ return { results, additionalContext, initialUserMessage };
928
+ }
929
+
930
+ /**
931
+ * Execute SessionEnd hooks during agent destruction.
932
+ * Non-blocking: always continues shutdown even if hooks fail.
933
+ * No stdout processing needed (SessionEnd hooks are fire-and-forget cleanup).
934
+ */
935
+ async executeSessionEndHooks(
936
+ source: SessionEndSource,
937
+ sessionId: string,
938
+ transcriptPath: string,
939
+ ): Promise<HookExecutionResult[]> {
940
+ const context: ExtendedHookExecutionContext = {
941
+ event: "SessionEnd",
942
+ projectDir: this.workdir,
943
+ timestamp: new Date(),
944
+ sessionId,
945
+ transcriptPath,
946
+ cwd: this.workdir,
947
+ endSource: source,
948
+ env: Object.fromEntries(
949
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
950
+ ) as Record<string, string>,
951
+ };
952
+
953
+ const results = await this.executeHooks("SessionEnd", context);
954
+
955
+ // Process results but never block shutdown
956
+ if (results.length > 0) {
957
+ this.processHookResults("SessionEnd", results);
958
+ }
959
+
960
+ return results;
961
+ }
847
962
  }