@namzu/sdk 63.0.0 → 64.0.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 (123) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -1
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/compaction/hooks.d.ts +57 -0
  6. package/dist/compaction/hooks.d.ts.map +1 -0
  7. package/dist/compaction/hooks.js +44 -0
  8. package/dist/compaction/hooks.js.map +1 -0
  9. package/dist/compaction/manual.d.ts +9 -0
  10. package/dist/compaction/manual.d.ts.map +1 -1
  11. package/dist/compaction/manual.js +74 -5
  12. package/dist/compaction/manual.js.map +1 -1
  13. package/dist/compaction/model-summary.d.ts +6 -0
  14. package/dist/compaction/model-summary.d.ts.map +1 -1
  15. package/dist/compaction/model-summary.js +13 -3
  16. package/dist/compaction/model-summary.js.map +1 -1
  17. package/dist/compaction/verifier.d.ts +8 -2
  18. package/dist/compaction/verifier.d.ts.map +1 -1
  19. package/dist/compaction/verifier.js +23 -7
  20. package/dist/compaction/verifier.js.map +1 -1
  21. package/dist/plugin/hook-context.d.ts +16 -0
  22. package/dist/plugin/hook-context.d.ts.map +1 -1
  23. package/dist/plugin/hook-context.js +40 -0
  24. package/dist/plugin/hook-context.js.map +1 -1
  25. package/dist/plugin/index.d.ts +4 -3
  26. package/dist/plugin/index.d.ts.map +1 -1
  27. package/dist/plugin/index.js +3 -2
  28. package/dist/plugin/index.js.map +1 -1
  29. package/dist/plugin/shell-hook-command.d.ts +25 -0
  30. package/dist/plugin/shell-hook-command.d.ts.map +1 -0
  31. package/dist/plugin/shell-hook-command.js +218 -0
  32. package/dist/plugin/shell-hook-command.js.map +1 -0
  33. package/dist/plugin/shell-hook-tool-names.d.ts +20 -0
  34. package/dist/plugin/shell-hook-tool-names.d.ts.map +1 -0
  35. package/dist/plugin/shell-hook-tool-names.js +43 -0
  36. package/dist/plugin/shell-hook-tool-names.js.map +1 -0
  37. package/dist/plugin/shell-hook.d.ts +62 -20
  38. package/dist/plugin/shell-hook.d.ts.map +1 -1
  39. package/dist/plugin/shell-hook.js +372 -85
  40. package/dist/plugin/shell-hook.js.map +1 -1
  41. package/dist/public-runtime.d.ts +3 -1
  42. package/dist/public-runtime.d.ts.map +1 -1
  43. package/dist/public-runtime.js +2 -1
  44. package/dist/public-runtime.js.map +1 -1
  45. package/dist/public-types.d.ts +2 -1
  46. package/dist/public-types.d.ts.map +1 -1
  47. package/dist/runtime/query/compact-session.d.ts +7 -1
  48. package/dist/runtime/query/compact-session.d.ts.map +1 -1
  49. package/dist/runtime/query/compact-session.js +8 -3
  50. package/dist/runtime/query/compact-session.js.map +1 -1
  51. package/dist/runtime/query/executor/tool-call-admission.d.ts +1 -1
  52. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  53. package/dist/runtime/query/executor/tool-call-admission.js +4 -3
  54. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  55. package/dist/runtime/query/executor.d.ts +8 -0
  56. package/dist/runtime/query/executor.d.ts.map +1 -1
  57. package/dist/runtime/query/executor.js +17 -7
  58. package/dist/runtime/query/executor.js.map +1 -1
  59. package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
  60. package/dist/runtime/query/finalize-turn.js +9 -2
  61. package/dist/runtime/query/finalize-turn.js.map +1 -1
  62. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/index.js +4 -1
  64. package/dist/runtime/query/iteration/index.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/compaction.js +24 -12
  67. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  68. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/tool-review.js +2 -0
  70. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  71. package/dist/runtime/query/plugin-hooks.d.ts +13 -0
  72. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  73. package/dist/runtime/query/plugin-hooks.js +29 -2
  74. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  75. package/dist/runtime/query/turn/outcome-ledger.d.ts +4 -0
  76. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  77. package/dist/runtime/query/turn/outcome-ledger.js +2 -1
  78. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  79. package/dist/tools/builtins/bash.d.ts.map +1 -1
  80. package/dist/tools/builtins/bash.js +10 -2
  81. package/dist/tools/builtins/bash.js.map +1 -1
  82. package/dist/tools/coordinator/index.d.ts.map +1 -1
  83. package/dist/tools/coordinator/index.js +1 -0
  84. package/dist/tools/coordinator/index.js.map +1 -1
  85. package/dist/types/agent/query.d.ts +8 -0
  86. package/dist/types/agent/query.d.ts.map +1 -1
  87. package/dist/types/plugin/index.d.ts +55 -11
  88. package/dist/types/plugin/index.d.ts.map +1 -1
  89. package/dist/types/plugin/index.js.map +1 -1
  90. package/dist/types/session/events.d.ts +2 -0
  91. package/dist/types/session/events.d.ts.map +1 -1
  92. package/dist/types/session/events.js.map +1 -1
  93. package/dist/types/tool/index.d.ts +13 -1
  94. package/dist/types/tool/index.d.ts.map +1 -1
  95. package/dist/types/tool/index.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/agents/QueryAgent.ts +2 -1
  98. package/src/compaction/hooks.ts +104 -0
  99. package/src/compaction/manual.ts +117 -4
  100. package/src/compaction/model-summary.ts +19 -3
  101. package/src/compaction/verifier.ts +33 -3
  102. package/src/plugin/hook-context.ts +46 -0
  103. package/src/plugin/index.ts +14 -0
  104. package/src/plugin/shell-hook-command.ts +215 -0
  105. package/src/plugin/shell-hook-tool-names.ts +46 -0
  106. package/src/plugin/shell-hook.ts +405 -79
  107. package/src/public-runtime.ts +12 -0
  108. package/src/public-types.ts +2 -0
  109. package/src/runtime/query/compact-session.ts +16 -4
  110. package/src/runtime/query/executor/tool-call-admission.ts +10 -2
  111. package/src/runtime/query/executor.ts +33 -8
  112. package/src/runtime/query/finalize-turn.ts +9 -2
  113. package/src/runtime/query/iteration/index.ts +4 -1
  114. package/src/runtime/query/iteration/phases/compaction.ts +31 -12
  115. package/src/runtime/query/iteration/phases/tool-review.ts +2 -0
  116. package/src/runtime/query/plugin-hooks.ts +32 -2
  117. package/src/runtime/query/turn/outcome-ledger.ts +6 -1
  118. package/src/tools/builtins/bash.ts +16 -3
  119. package/src/tools/coordinator/index.ts +1 -0
  120. package/src/types/agent/query.ts +9 -0
  121. package/src/types/plugin/index.ts +66 -22
  122. package/src/types/session/events.ts +2 -0
  123. package/src/types/tool/index.ts +14 -1
@@ -697,8 +697,13 @@ export {
697
697
 
698
698
  export {
699
699
  attachShellHooks,
700
+ boundCompactionSummary,
700
701
  boundHookContext,
701
702
  boundHookReplacement,
703
+ boundLastAssistantMessage,
704
+ compactionHookInstructions,
705
+ MAX_COMPACTION_SUMMARY_HOOK_CHARS,
706
+ MAX_LAST_MESSAGE_HOOK_CHARS,
702
707
  createShellHook,
703
708
  DEFAULT_SHELL_HOOK_TIMEOUT_MS,
704
709
  definePlugin,
@@ -716,8 +721,13 @@ export {
716
721
  PluginResolver,
717
722
  runShellHook,
718
723
  SHELL_HOOK_EVENTS,
724
+ SHELL_HOOK_PASCAL_EVENT_NAMES,
725
+ SHELL_HOOK_WRITTEN_TOOL_NAMES,
719
726
  SHELL_HOOKS_PLUGIN_ID,
727
+ shellHookToolName,
720
728
  shellHookIfMatches,
729
+ shellHookIfProblem,
730
+ shellHookMatcherProblem,
721
731
  shellHookMatches,
722
732
  shellHookVerdict,
723
733
  } from './plugin/index.js'
@@ -1343,6 +1353,7 @@ export { assertTaskStatus, isTerminalTaskStatus } from './types/task/index.js'
1343
1353
  // offer "compact this conversation", could not shrink an idle session
1344
1354
  // between turns, and could not collapse a span it had chosen.
1345
1355
  export { compactNow, compactRegion } from './compaction/manual.js'
1356
+ export { CompactionRefusedError } from './compaction/hooks.js'
1346
1357
  // A host persisting manual compaction has to recognise the system message on
1347
1358
  // resume. Export the identity predicate rather than making every host copy the
1348
1359
  // marker string and eventually disagree with the writer.
@@ -1380,6 +1391,7 @@ export { isWorkingMemoryMessage } from './runtime/query/iteration/phases/working
1380
1391
  // `any` — and the first host to try (`@namzu/cli`'s `/compact`) did exactly
1381
1392
  // that before this line existed.
1382
1393
  export type { CompactNowInput, CompactionResult } from './compaction/manual.js'
1394
+ export type { CompactionHookHost, ManualCompactionHooks } from './compaction/hooks.js'
1383
1395
  export type {
1384
1396
  CompactionSummaryRoute,
1385
1397
  CompactionSummaryRouter,
@@ -242,12 +242,14 @@ export type {
242
242
  ShellHookHandler,
243
243
  ShellHookJudge,
244
244
  ShellHookOnFailure,
245
+ ShellHookCall,
245
246
  ShellHookInput,
246
247
  ShellHookOptions,
247
248
  ShellHookPathFilter,
248
249
  ShellHookOutcome,
249
250
  ShellHooksConfig,
250
251
  } from './plugin/shell-hook.js'
252
+ export type { ShellHookToolNames } from './plugin/shell-hook-tool-names.js'
251
253
 
252
254
  export type {
253
255
  PlanApprovalHandler,
@@ -1,3 +1,4 @@
1
+ import type { ManualCompactionHooks } from '../../compaction/hooks.js'
1
2
  import {
2
3
  type CompactNowInput,
3
4
  type CompactionResult,
@@ -13,8 +14,14 @@ import { generateMessageId } from '../../utils/id.js'
13
14
  import { type SessionLocatorOptions, locateSessionLog, withSessionLease } from './abandon-turn.js'
14
15
 
15
16
  /** A compaction a host asks for between turns. */
16
- export interface CompactSessionParams extends Omit<CompactNowInput, 'messages' | 'onShed'> {
17
+ export interface CompactSessionParams
18
+ extends Omit<CompactNowInput, 'messages' | 'onShed' | 'hooks'> {
17
19
  readonly sessionId: SessionId
20
+ /**
21
+ * Fire `pre_compact` and `post_compact` around the pass. The session and
22
+ * the trigger are this call's own. See {@link CompactNowInput.hooks}.
23
+ */
24
+ readonly hooks?: Omit<ManualCompactionHooks, 'sessionId' | 'trigger'>
18
25
  /**
19
26
  * Who asked. `'manual'` (the default) is a person's request; `'idle'` is a
20
27
  * host compacting a conversation nobody is using before its prompt cache
@@ -37,7 +44,7 @@ export interface CompactSessionParams extends Omit<CompactNowInput, 'messages' |
37
44
  export async function compactSession(
38
45
  params: CompactSessionParams,
39
46
  ): Promise<CompactionResult | null> {
40
- const { sessionId, locator = {}, trigger = 'manual', ...input } = params
47
+ const { sessionId, locator = {}, trigger = 'manual', hooks, ...input } = params
41
48
  const log = await locateSessionLog(sessionId, locator)
42
49
  return withSessionLease(log, locator.lease, async (lease) => {
43
50
  const active = await log.activeTurn({ lease })
@@ -47,7 +54,11 @@ export async function compactSession(
47
54
  const history = await readFoldedHistory(log)
48
55
  const ids = new Map<Message, MessageId>()
49
56
  for (const entry of history) if (entry.messageId) ids.set(entry.message, entry.messageId)
50
- const result = await compactNow({ ...input, messages: history.map((entry) => entry.message) })
57
+ const result = await compactNow({
58
+ ...input,
59
+ messages: history.map((entry) => entry.message),
60
+ ...(hooks ? { hooks: { ...hooks, sessionId, trigger } } : {}),
61
+ })
51
62
  if (!result) return null
52
63
  const head = await log.head()
53
64
  if (!head) {
@@ -105,7 +116,7 @@ export interface CompactSessionRangeParams extends CompactSessionParams {
105
116
  export async function compactSessionRange(
106
117
  params: CompactSessionRangeParams,
107
118
  ): Promise<CompactionResult | null> {
108
- const { sessionId, locator = {}, from, until, ...input } = params
119
+ const { sessionId, locator = {}, from, until, hooks, ...input } = params
109
120
  const log = await locateSessionLog(sessionId, locator)
110
121
  return withSessionLease(log, locator.lease, async (lease) => {
111
122
  const active = await log.activeTurn({ lease })
@@ -134,6 +145,7 @@ export async function compactSessionRange(
134
145
  messages: history.map((entry) => entry.message),
135
146
  start,
136
147
  end,
148
+ ...(hooks ? { hooks: { ...hooks, sessionId, trigger: 'manual' as const } } : {}),
137
149
  })
138
150
  if (!result) return null
139
151
  const head = await log.head()
@@ -78,6 +78,7 @@ export async function runPreToolHook(
78
78
  toolName: string,
79
79
  input: unknown,
80
80
  signal: AbortSignal = host.config.abortSignal,
81
+ toolCallId?: string,
81
82
  ): Promise<PreToolHookOutcome> {
82
83
  signal.throwIfAborted()
83
84
  if (!host.config.pluginManager) return { kind: 'continue', input, modified: false }
@@ -88,6 +89,7 @@ export async function runPreToolHook(
88
89
  sessionId: host.config.sessionId,
89
90
  turnId: host.config.turnId,
90
91
  toolName,
92
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
91
93
  toolInput: input,
92
94
  signal,
93
95
  },
@@ -155,7 +157,7 @@ export async function prepareDirectCall(
155
157
  : {}),
156
158
  }
157
159
  }
158
- const preOutcome = await runPreToolHook(host, toolName, resolved.input)
160
+ const preOutcome = await runPreToolHook(host, toolName, resolved.input, undefined, toolCall.id)
159
161
  if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
160
162
  return {
161
163
  kind: 'synthetic',
@@ -297,7 +299,13 @@ export async function prepareDirectCall(
297
299
  }
298
300
  }
299
301
 
300
- const preOutcome = await runPreToolHook(host, toolName, preparation.prepared.input)
302
+ const preOutcome = await runPreToolHook(
303
+ host,
304
+ toolName,
305
+ preparation.prepared.input,
306
+ undefined,
307
+ toolCall.id,
308
+ )
301
309
  if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
302
310
  return {
303
311
  kind: 'synthetic',
@@ -63,6 +63,7 @@ import type {
63
63
  ToolDispatchOptions,
64
64
  ToolFacts,
65
65
  ToolHandoff,
66
+ ToolProgressDetails,
66
67
  ToolResult,
67
68
  } from '../../types/tool/index.js'
68
69
  import type { RepairToolCall } from '../../types/tool/repair.js'
@@ -272,7 +273,9 @@ function boundedToolProgress(message: string): string {
272
273
  * complete useful working set; intermediate states may be replaced.
273
274
  */
274
275
  class ToolProgressPublisher {
275
- private pending: { readonly message: string; readonly fraction?: number } | undefined
276
+ private pending:
277
+ | { readonly message: string; readonly fraction?: number; readonly outputLines?: number }
278
+ | undefined
276
279
  private draining: Promise<void> | null = null
277
280
  private accepting = true
278
281
 
@@ -280,15 +283,18 @@ class ToolProgressPublisher {
280
283
  private readonly emitEvent: EmitEvent,
281
284
  private readonly base: Omit<
282
285
  Extract<SessionEventDraft, { type: 'tool_progress' }>,
283
- 'message' | 'fraction'
286
+ 'message' | 'fraction' | 'outputLines'
284
287
  >,
285
288
  ) {}
286
289
 
287
- report(message: string, fraction?: number): void {
290
+ report(message: string, fraction?: number, details?: ToolProgressDetails): void {
288
291
  if (!this.accepting) return
289
292
  this.pending = {
290
293
  message,
291
294
  ...(fraction !== undefined ? { fraction: Math.min(1, Math.max(0, fraction)) } : {}),
295
+ ...(details?.outputLines !== undefined && Number.isFinite(details.outputLines)
296
+ ? { outputLines: Math.max(0, Math.floor(details.outputLines)) }
297
+ : {}),
292
298
  }
293
299
  this.startDrain()
294
300
  }
@@ -323,6 +329,7 @@ class ToolProgressPublisher {
323
329
  ...this.base,
324
330
  message: boundedToolProgress(update.message),
325
331
  ...(update.fraction !== undefined ? { fraction: update.fraction } : {}),
332
+ ...(update.outputLines !== undefined ? { outputLines: update.outputLines } : {}),
326
333
  }).catch(() => {})
327
334
  }
328
335
  }
@@ -605,6 +612,14 @@ export interface ToolCallOutcome {
605
612
  inputFailure?: CompletedToolRecord['inputFailure']
606
613
  /** A pre-tool hook deliberately skipped execution; its receipt is not an output candidate. */
607
614
  skipped?: true
615
+ /**
616
+ * The call never ran: the person declined it, a rule refused it, its input
617
+ * was refused before it started, or the turn ended first. The outcome record
618
+ * does not count it as a call that may have changed files.
619
+ */
620
+ notRun?: true
621
+ /** The call only started work that goes on after it returned; see `ToolResult.launched`. */
622
+ launched?: true
608
623
  /** Opt-in execution evidence, never provider content or accepted output. */
609
624
  structuredResultJson?: string
610
625
  /** The tool asked for a person; see `ToolResult.handoff`. */
@@ -1854,7 +1869,8 @@ export class ToolExecutor {
1854
1869
  // Per-call for the same reason: a pause has to be routed back
1855
1870
  // to the call that raised it, and a batch can raise several.
1856
1871
  ...(this.config.toolPause ? { requestPause: this.config.toolPause(toolCall.id) } : {}),
1857
- report: (message: string, fraction?: number) => progress.report(message, fraction),
1872
+ report: (message: string, fraction?: number, details?: ToolProgressDetails) =>
1873
+ progress.report(message, fraction, details),
1858
1874
  }
1859
1875
  if (preparedCall) {
1860
1876
  const cwd = this.preparedBashDirectories.get(preparedCall)
@@ -2347,7 +2363,8 @@ export class ToolExecutor {
2347
2363
  // ephemeral child id; minting a pause route for `nestedId` makes the
2348
2364
  // answer impossible to match after process restart. `...context`
2349
2365
  // intentionally preserves the ancestor route here.
2350
- report: (message: string, fraction?: number) => progress.report(message, fraction),
2366
+ report: (message: string, fraction?: number, details?: ToolProgressDetails) =>
2367
+ progress.report(message, fraction, details),
2351
2368
  }
2352
2369
 
2353
2370
  const nestedCwd = this.preparedBashDirectories.get(preparedCall)
@@ -2773,7 +2790,7 @@ export class ToolExecutor {
2773
2790
  // block had it discarded here — before the wire mapper that was
2774
2791
  // built to carry it ever saw it.
2775
2792
  let result: ToolResult = await this.runOnce(toolName, input, toolContext, prepared)
2776
- let post = await this.runPostToolHook(toolName, input, result)
2793
+ let post = await this.runPostToolHook(toolName, input, result, toolCall.id)
2777
2794
 
2778
2795
  // In-loop retry. A transient failure used to cost a full model round
2779
2796
  // trip: the error went back as a `tool_result`, the model read it and
@@ -2856,7 +2873,7 @@ export class ToolExecutor {
2856
2873
  break
2857
2874
  }
2858
2875
  result = await this.runOnce(toolName, input, toolContext, prepared)
2859
- post = await this.runPostToolHook(toolName, input, result)
2876
+ post = await this.runPostToolHook(toolName, input, result, toolCall.id)
2860
2877
  }
2861
2878
  const durationMs = Date.now() - startMs
2862
2879
 
@@ -3113,6 +3130,7 @@ export class ToolExecutor {
3113
3130
  ...(result.handoff !== undefined && !this.config.abortSignal.aborted
3114
3131
  ? { handoff: result.handoff }
3115
3132
  : {}),
3133
+ ...(result.launched === true && !effectiveIsError ? { launched: true as const } : {}),
3116
3134
  ...(revealedTools !== undefined ? { revealedTools } : {}),
3117
3135
  ...(result.facts !== undefined && !postOverride ? { facts: result.facts } : {}),
3118
3136
  }
@@ -3497,6 +3515,7 @@ export class ToolExecutor {
3497
3515
  toolName: string,
3498
3516
  input: unknown,
3499
3517
  toolResult: ToolResult,
3518
+ toolCallId?: string,
3500
3519
  ): Promise<{
3501
3520
  override: PostToolOverride | null
3502
3521
  retry: boolean
@@ -3511,6 +3530,7 @@ export class ToolExecutor {
3511
3530
  sessionId: this.config.sessionId,
3512
3531
  turnId: this.config.turnId,
3513
3532
  toolName,
3533
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
3514
3534
  toolInput: input,
3515
3535
  toolResult,
3516
3536
  signal: this.config.abortSignal,
@@ -3529,6 +3549,7 @@ export class ToolExecutor {
3529
3549
  sessionId: this.config.sessionId,
3530
3550
  turnId: this.config.turnId,
3531
3551
  toolName,
3552
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
3532
3553
  toolInput: input,
3533
3554
  toolResult,
3534
3555
  signal: this.config.abortSignal,
@@ -3671,7 +3692,7 @@ export class ToolExecutor {
3671
3692
  isError: true,
3672
3693
  })
3673
3694
 
3674
- return { toolCallId: toolCall.id, toolName, output, isError: true }
3695
+ return { toolCallId: toolCall.id, toolName, output, isError: true, notRun: true }
3675
3696
  }
3676
3697
 
3677
3698
  /**
@@ -3722,6 +3743,7 @@ export class ToolExecutor {
3722
3743
  const reason = abortReasonText(this.config.abortSignal.reason)
3723
3744
  return this.recordSyntheticHookOutcome(toolCallId, toolName, input, {
3724
3745
  kind: 'error',
3746
+ notRun: true,
3725
3747
  output: `Tool "${toolName}" was not started because the turn was cancelled${reason ? `: ${reason}` : '.'}`,
3726
3748
  })
3727
3749
  }
@@ -3735,6 +3757,7 @@ export class ToolExecutor {
3735
3757
  output: string
3736
3758
  inputFailure?: ToolCallOutcome['inputFailure']
3737
3759
  skipped?: true
3760
+ notRun?: true
3738
3761
  facts?: ToolFacts
3739
3762
  },
3740
3763
  ): Promise<ToolCallOutcome> {
@@ -3779,12 +3802,14 @@ export class ToolExecutor {
3779
3802
  ...(outcome.facts ? { facts: outcome.facts } : {}),
3780
3803
  ...(outcome.inputFailure ? { inputFailure: outcome.inputFailure } : {}),
3781
3804
  ...(outcome.skipped ? { skipped: true as const } : {}),
3805
+ ...(outcome.notRun ? { notRun: true as const } : {}),
3782
3806
  }
3783
3807
  }
3784
3808
 
3785
3809
  private recordSyntheticPreparation(call: Extract<PreparedDirectCall, { kind: 'synthetic' }>) {
3786
3810
  return this.recordSyntheticHookOutcome(call.toolCall.id, call.toolName, call.input, {
3787
3811
  kind: call.isError ? 'error' : 'skip',
3812
+ ...(call.isError ? { notRun: true as const } : {}),
3788
3813
  output: call.message,
3789
3814
  ...(call.facts ? { facts: call.facts } : {}),
3790
3815
  ...(call.inputFailure ? { inputFailure: call.inputFailure } : {}),
@@ -8,7 +8,7 @@ import type { TurnContext } from './context.js'
8
8
  import type { EventTranslator } from './events.js'
9
9
  import { runOutputGuardrails } from './guardrails.js'
10
10
  import type { QueryParams } from './index.js'
11
- import { applyLifecycleHookResults } from './plugin-hooks.js'
11
+ import { applyLifecycleHookResults, lastAssistantMessageField } from './plugin-hooks.js'
12
12
  import type { ResultAssembler } from './result.js'
13
13
 
14
14
  /**
@@ -86,7 +86,12 @@ export async function* finalizeTurn(
86
86
  if (params.pluginManager && finalization.turnEndHooksFired?.() !== true) {
87
87
  const hookResults = await params.pluginManager.executeHooks(
88
88
  'turn_end',
89
- { sessionId: ctx.sessionId, turnId: ctx.turnId, signal: ctx.abortController.signal },
89
+ {
90
+ sessionId: ctx.sessionId,
91
+ turnId: ctx.turnId,
92
+ ...lastAssistantMessageField(ctx.recorder.messages),
93
+ signal: ctx.abortController.signal,
94
+ },
90
95
  eventTranslator.emitEvent,
91
96
  )
92
97
  applyLifecycleHookResults('turn_end', hookResults)
@@ -100,8 +105,10 @@ export async function* finalizeTurn(
100
105
  {
101
106
  sessionId: ctx.sessionId,
102
107
  turnId: ctx.turnId,
108
+ ...lastAssistantMessageField(ctx.recorder.messages),
103
109
  parentSessionId: params.parentSessionId,
104
110
  ...(params.parentTurnId !== undefined ? { parentTurnId: params.parentTurnId } : {}),
111
+ agentType: params.agentType ?? params.agentName,
105
112
  signal: ctx.abortController.signal,
106
113
  },
107
114
  eventTranslator.emitEvent,
@@ -70,7 +70,7 @@ import { SandboxAcquisitionStopped } from '../deferred-sandbox.js'
70
70
  import { deliverDurableInbound } from '../durable-inbound.js'
71
71
  import type { ToolCallOutcome } from '../executor.js'
72
72
  import { projectObservationContext } from '../observation-context.js'
73
- import { applyLifecycleHookResults } from '../plugin-hooks.js'
73
+ import { applyLifecycleHookResults, lastAssistantMessageField } from '../plugin-hooks.js'
74
74
  import {
75
75
  type RequestContextSnapshot,
76
76
  diffRequestContext,
@@ -419,6 +419,7 @@ export class IterationOrchestrator {
419
419
  sessionId: recorder.sessionId,
420
420
  turnId: recorder.turnId,
421
421
  ...extra,
422
+ ...lastAssistantMessageField(recorder.messages),
422
423
  ...(this.stopHookActive ? { stopHookActive: true } : {}),
423
424
  signal: this.ctx.abortController.signal,
424
425
  },
@@ -3505,6 +3506,8 @@ export class IterationOrchestrator {
3505
3506
  input,
3506
3507
  isError: result.isError,
3507
3508
  ...(result.skipped ? { skipped: true } : {}),
3509
+ ...(result.notRun ? { notRun: true } : {}),
3510
+ ...(result.launched ? { launched: true } : {}),
3508
3511
  readOnly: this.lookedOnly(result.toolName, input),
3509
3512
  ...(result.facts ? { facts: result.facts } : {}),
3510
3513
  }
@@ -3,6 +3,7 @@ import {
3
3
  resolveTriggerThreshold,
4
4
  } from '../../../../compaction/context-window.js'
5
5
  import { findDanglingMessages } from '../../../../compaction/dangling.js'
6
+ import { askPreCompact, postCompactSummary } from '../../../../compaction/hooks.js'
6
7
  import {
7
8
  LOADED_SKILLS_ENTRY_KEY,
8
9
  type RenderedSkills,
@@ -781,6 +782,10 @@ async function recordShed(
781
782
  interface CompactionPass {
782
783
  attempted: boolean
783
784
  reason: 'threshold' | 'overflow'
785
+ /** What the `pre_compact` hooks said the summary should keep. */
786
+ hookInstructions?: string
787
+ /** The summary the pass installed, for `post_compact`. */
788
+ summary?: string
784
789
  tokensBefore: number
785
790
  contextWindowTokens: number
786
791
  }
@@ -807,9 +812,11 @@ export async function runCompactionCheck(
807
812
  iteration: ctx.recorder.currentIteration,
808
813
  compaction: {
809
814
  reason: pass.reason,
815
+ trigger: 'auto',
810
816
  tokensBefore: pass.tokensBefore,
811
817
  tokensAfter: estimateTokens(ctx),
812
818
  contextWindowTokens: pass.contextWindowTokens,
819
+ ...postCompactSummary(pass.summary),
813
820
  },
814
821
  signal: ctx.abortController.signal,
815
822
  },
@@ -903,24 +910,29 @@ async function runCompactionCheckInner(
903
910
  pass.tokensBefore = estimatedTokens
904
911
  pass.contextWindowTokens = budget
905
912
  if (ctx.pluginManager) {
906
- const results = await ctx.pluginManager.executeHooks(
907
- 'pre_compact',
913
+ const answer = await askPreCompact(
914
+ ctx.pluginManager,
908
915
  {
909
916
  sessionId: ctx.recorder.sessionId,
910
917
  turnId: ctx.recorder.turnId,
911
918
  iteration: ctx.recorder.currentIteration,
912
- compaction: {
913
- reason: pass.reason,
914
- tokensBefore: estimatedTokens,
915
- contextWindowTokens: budget,
916
- },
917
919
  signal: ctx.abortController.signal,
920
+ emitEvent: ctx.emitEvent,
921
+ },
922
+ {
923
+ reason: pass.reason,
924
+ trigger: 'auto',
925
+ instructions: null,
926
+ tokensBefore: estimatedTokens,
927
+ contextWindowTokens: budget,
918
928
  },
919
- ctx.emitEvent,
920
929
  )
921
- applyLifecycleHookResults('pre_compact', results)
922
- const refusal = results.find((result) => result.action === 'skip')
923
- if (refusal?.action === 'skip') {
930
+ // What the hooks said the summary should keep is heard even when a
931
+ // forced pass overrides a refusal. A reducer or the extracted state
932
+ // alone has no place for it (see `docs/sdk/hooks.md`).
933
+ if (answer.instructions !== undefined) pass.hookInstructions = answer.instructions
934
+ const refusal = answer.refusal !== undefined ? { reason: answer.refusal } : undefined
935
+ if (refusal) {
924
936
  // A threshold pass is the context's own housekeeping and a hook may
925
937
  // postpone it. A forced pass runs because the provider rejected the
926
938
  // prompt: a context it will reject is not something a hook may insist
@@ -1130,6 +1142,7 @@ async function runCompactionCheckInner(
1130
1142
  olderMessages,
1131
1143
  previousSummaries: replaceableSummaries(systemMessages),
1132
1144
  config,
1145
+ ...(pass.hookInstructions !== undefined ? { hookInstructions: pass.hookInstructions } : {}),
1133
1146
  resolveRoute: async (signal) => {
1134
1147
  const route = await ctx.compactionSummaryRoute?.(signal)
1135
1148
  if (route) {
@@ -1162,7 +1175,10 @@ async function runCompactionCheckInner(
1162
1175
 
1163
1176
  if (modelWritten !== undefined) {
1164
1177
  compactedContent = modelWritten
1165
- } else if (config.llmVerification && manager.slotCount() < config.richStateThreshold) {
1178
+ } else if (
1179
+ pass.hookInstructions !== undefined ||
1180
+ (config.llmVerification && manager.slotCount() < config.richStateThreshold)
1181
+ ) {
1166
1182
  // Named once and used twice on purpose. The model the summariser is
1167
1183
  // asked for is also the model its tokens are priced at, and computing
1168
1184
  // the two separately is how they drift — a router that sends compaction
@@ -1188,6 +1204,8 @@ async function runCompactionCheckInner(
1188
1204
  // was added, and nothing ever consulted it.
1189
1205
  compactionModel,
1190
1206
  ctx.abortController.signal,
1207
+ undefined,
1208
+ pass.hookInstructions,
1191
1209
  )
1192
1210
  } catch (error) {
1193
1211
  // The model step only ADDS to the extracted state, so a failed one (a
@@ -1308,6 +1326,7 @@ async function runCompactionCheckInner(
1308
1326
  await recordShed(ctx, [...live], newMessages, options?.force ? 'overflow' : 'threshold')
1309
1327
 
1310
1328
  installMessages(live, newMessages)
1329
+ pass.summary = compactedContent
1311
1330
 
1312
1331
  const newEstimate = estimateTokens(ctx)
1313
1332
 
@@ -318,6 +318,7 @@ export async function* runToolReview(
318
318
  sessionId: ctx.recorder.sessionId,
319
319
  turnId: ctx.recorder.turnId,
320
320
  toolName: summary.name,
321
+ toolCallId: summary.id,
321
322
  toolInput: summary.input,
322
323
  reason,
323
324
  signal: ctx.abortController.signal,
@@ -854,6 +855,7 @@ export async function* runToolReview(
854
855
  sessionId: ctx.recorder.sessionId,
855
856
  turnId: ctx.recorder.turnId,
856
857
  toolName: tc.name,
858
+ toolCallId: tc.id,
857
859
  toolInput: tc.input,
858
860
  signal: ctx.abortController.signal,
859
861
  },
@@ -1,5 +1,31 @@
1
+ import { boundLastAssistantMessage } from '../../plugin/hook-context.js'
1
2
  import { NamzuError } from '../../types/errors/index.js'
3
+ import type { Message } from '../../types/message/index.js'
2
4
  import type { PluginHookEvent, PluginHookResult } from '../../types/plugin/index.js'
5
+ import { isOperatorUserMessage } from './steering.js'
6
+
7
+ /**
8
+ * The model's last message of the turn, as a `turn_end` or `subagent_stop`
9
+ * hook receives it: the text of the last assistant message after the
10
+ * operator's latest prompt, bounded. A message that carries only tool calls,
11
+ * a turn that stopped before the model wrote anything, and a turn with no
12
+ * assistant message yet all give `undefined`: the field is then absent, and a
13
+ * hook never gets an earlier turn's words or an earlier step's narration in
14
+ * its place.
15
+ */
16
+ export function lastAssistantMessageField(messages: readonly Message[]): {
17
+ lastAssistantMessage?: string
18
+ } {
19
+ for (let index = messages.length - 1; index >= 0; index--) {
20
+ const message = messages[index]
21
+ if (!message) continue
22
+ if (isOperatorUserMessage(message)) return {}
23
+ if (message.role !== 'assistant') continue
24
+ const text = boundLastAssistantMessage(message.content)
25
+ return text === undefined ? {} : { lastAssistantMessage: text }
26
+ }
27
+ return {}
28
+ }
3
29
 
4
30
  /**
5
31
  * Interpret hook results from non-tool lifecycle events (run_start, run_end,
@@ -19,9 +45,13 @@ export function applyLifecycleHookResults(
19
45
  case 'continue':
20
46
  continue
21
47
  case 'annotate':
22
- if (event !== 'user_prompt_submit' && event !== 'subagent_start') {
48
+ if (
49
+ event !== 'user_prompt_submit' &&
50
+ event !== 'subagent_start' &&
51
+ event !== 'pre_compact'
52
+ ) {
23
53
  throw new Error(
24
- `Plugin hook ${event} returned 'annotate', which only user_prompt_submit and subagent_start accept`,
54
+ `Plugin hook ${event} returned 'annotate', which only user_prompt_submit, subagent_start and pre_compact accept`,
25
55
  )
26
56
  }
27
57
  if (result.text.trim().length > 0) annotations.push(result.text.trim())
@@ -36,6 +36,10 @@ export interface OutcomeCall {
36
36
  readonly isError?: boolean
37
37
  /** A hook stopped it before it ran. */
38
38
  readonly skipped?: boolean
39
+ /** It never ran: declined by the person, refused by a rule or by its own input, or the turn ended first. */
40
+ readonly notRun?: boolean
41
+ /** It only started work that goes on after it returned; the call itself changed nothing. */
42
+ readonly launched?: boolean
39
43
  /** The tool declared this call read-only (or it only keeps the task list). */
40
44
  readonly readOnly?: boolean
41
45
  /** What the call did, as typed fields. Absent when a hook replaced the output. */
@@ -393,7 +397,8 @@ export class OutcomeLedger {
393
397
  // answers for exactly the state they left.
394
398
  let pending: CheckState[] = []
395
399
  for (const call of calls) {
396
- if (call.skipped === true) continue
400
+ // A call that did not run, or only started work elsewhere, changed nothing here.
401
+ if (call.skipped === true || call.notRun === true || call.launched === true) continue
397
402
  const written = WRITING_TOOLS.has(call.toolName)
398
403
  if (written && call.isError !== true) {
399
404
  if (call.facts?.kind === 'write' && call.facts.operation === 'unchanged') continue
@@ -205,14 +205,26 @@ function progressLine(value: string): string {
205
205
  * Reports stay synchronous: the executor owns coalescing and backpressure.
206
206
  * This projection adds neither an output log nor a queue of pending reports.
207
207
  */
208
- function shellProgress(report?: (message: string) => void) {
208
+ function shellProgress(
209
+ report?: (
210
+ message: string,
211
+ fraction?: number,
212
+ details?: { readonly outputLines?: number },
213
+ ) => void,
214
+ ) {
209
215
  if (!report) return undefined
210
216
  const partial = { stdout: '', stderr: '' }
211
217
  let lastMessage = ''
218
+ // Lines of output so far, both streams: every line feed seen. Counted here, on
219
+ // every chunk, because the reports below are coalesced and a viewer counting
220
+ // the messages it received would undercount a fast command.
221
+ let outputLines = 0
222
+ let lastLines = 0
212
223
  return ({ stream, data }: { stream: 'stdout' | 'stderr'; data: string }): void => {
213
224
  let line = partial[stream]
214
225
  let latest = ''
215
226
  let start = 0
227
+ for (let at = data.indexOf('\n'); at >= 0; at = data.indexOf('\n', at + 1)) outputLines++
216
228
  // Scan delimiters without allocating an array for arbitrarily chatty output.
217
229
  for (const match of data.matchAll(/[\r\n]/g)) {
218
230
  line = appendProgressTail(line, data, start, match.index)
@@ -223,10 +235,11 @@ function shellProgress(report?: (message: string) => void) {
223
235
  line = appendProgressTail(line, data, start, data.length)
224
236
  partial[stream] = line
225
237
  const message = progressLine(line) || latest
226
- if (!message || message === lastMessage) return
238
+ if (!message || (message === lastMessage && outputLines === lastLines)) return
227
239
  lastMessage = message
240
+ lastLines = outputLines
228
241
  try {
229
- report(message)
242
+ report(message, undefined, { outputLines })
230
243
  } catch {
231
244
  // A diagnostic observer cannot break the process output reader.
232
245
  }
@@ -791,6 +791,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
791
791
  plan_task_id: resolvedPlanTaskId,
792
792
  background: true,
793
793
  },
794
+ launched: true,
794
795
  }
795
796
  }
796
797
 
@@ -25,6 +25,15 @@ export interface QueryAgentConfig extends BaseAgentConfig {
25
25
 
26
26
  persona?: AgentPersona
27
27
 
28
+ /**
29
+ * The name lifecycle hooks see for this agent (`agentType` on
30
+ * `subagent_start` and `subagent_stop`, `agent_type` for a shell hook).
31
+ * Absent, it is the agent's `type`, which for a host-defined class is the
32
+ * class's marker rather than the agent's name: a host that names its
33
+ * agents passes the name here.
34
+ */
35
+ agentType?: string
36
+
28
37
  /**
29
38
  * Channel a host uses to hand guidance to this turn's current turn.
30
39
  *