typeclaw 0.28.2 → 0.30.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 (82) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +43 -5
  3. package/src/agent/live-subagents.ts +5 -0
  4. package/src/agent/loop-guard.ts +112 -26
  5. package/src/agent/plugin-tools.ts +167 -50
  6. package/src/agent/session-origin.ts +3 -3
  7. package/src/agent/subagent-drain.ts +150 -0
  8. package/src/agent/subagents.ts +41 -3
  9. package/src/agent/system-prompt.ts +29 -4
  10. package/src/agent/tools/channel-send.ts +1 -1
  11. package/src/agent/tools/spawn-subagent.ts +34 -1
  12. package/src/agent/tools/subagent-output.ts +7 -3
  13. package/src/agent/tools/wikipedia.ts +1 -1
  14. package/src/bundled-plugins/bun-hygiene/README.md +12 -11
  15. package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
  16. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  17. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
  18. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  19. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  20. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  21. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  22. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  23. package/src/bundled-plugins/operator/operator.ts +2 -0
  24. package/src/bundled-plugins/planner/index.ts +11 -0
  25. package/src/bundled-plugins/planner/planner.ts +283 -0
  26. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  27. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  28. package/src/bundled-plugins/researcher/index.ts +11 -0
  29. package/src/bundled-plugins/researcher/researcher.ts +233 -0
  30. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  31. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  32. package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
  33. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  34. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  35. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  36. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  37. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  38. package/src/bundled-plugins/scout/scout.ts +2 -0
  39. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  40. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  41. package/src/channels/adapters/discord-bot.ts +38 -11
  42. package/src/channels/adapters/github/inbound.ts +68 -4
  43. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  44. package/src/channels/adapters/kakaotalk.ts +2 -2
  45. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  46. package/src/channels/adapters/slack-bot.ts +3 -0
  47. package/src/channels/adapters/telegram-bot.ts +3 -0
  48. package/src/channels/engagement.ts +12 -7
  49. package/src/channels/github-review-claim.ts +15 -3
  50. package/src/channels/router.ts +85 -9
  51. package/src/channels/schema.ts +1 -1
  52. package/src/channels/types.ts +6 -0
  53. package/src/cli/init.ts +13 -2
  54. package/src/cli/ui.ts +64 -0
  55. package/src/config/config.ts +21 -15
  56. package/src/container/start.ts +5 -1
  57. package/src/init/dockerfile.ts +19 -56
  58. package/src/init/hatching.ts +1 -1
  59. package/src/init/index.ts +5 -1
  60. package/src/migrations/index.ts +35 -0
  61. package/src/migrations/secrets-v1-to-v2.ts +344 -0
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/run/index.ts +13 -0
  64. package/src/sandbox/availability.ts +12 -0
  65. package/src/sandbox/build.ts +12 -0
  66. package/src/sandbox/index.ts +1 -1
  67. package/src/sandbox/policy.ts +8 -0
  68. package/src/server/index.ts +24 -5
  69. package/src/shared/host-locale.ts +27 -0
  70. package/src/shared/protocol.ts +1 -1
  71. package/src/shared/wordmark.ts +19 -0
  72. package/src/skills/typeclaw-config/SKILL.md +32 -32
  73. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  74. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  75. package/src/tui/banner.ts +19 -0
  76. package/src/tui/format.ts +34 -0
  77. package/src/tui/index.ts +121 -22
  78. package/src/tui/theme.ts +26 -1
  79. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  80. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  81. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  82. package/typeclaw.schema.json +15 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.28.2",
3
+ "version": "0.30.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -53,7 +53,12 @@ import { loadSelf } from './self'
53
53
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
54
54
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
55
55
  import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
56
- import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
56
+ import {
57
+ buildDefaultSystemPrompt,
58
+ DEFAULT_SUBAGENT_ROSTER,
59
+ renderRuntimeBlock,
60
+ SLIM_SYSTEM_PROMPT,
61
+ } from './system-prompt'
57
62
  import { attachToolNotFoundNudge } from './tool-not-found-nudge'
58
63
  import {
59
64
  createBudgetState,
@@ -69,7 +74,7 @@ import { createChannelSendTool } from './tools/channel-send'
69
74
  import { createGrantRoleTool } from './tools/grant-role'
70
75
  import { createRestartTool } from './tools/restart'
71
76
  import { createSkipResponseTool } from './tools/skip-response'
72
- import { createSpawnSubagentTool } from './tools/spawn-subagent'
77
+ import { createSpawnSubagentTool, renderPublicSubagentRoster } from './tools/spawn-subagent'
73
78
  import { createStreamSnapshotTool } from './tools/stream-snapshot'
74
79
  import { createSubagentCancelTool } from './tools/subagent-cancel'
75
80
  import { createSubagentOutputTool } from './tools/subagent-output'
@@ -208,6 +213,7 @@ export type CreateSessionOptions = {
208
213
  liveSubagentRegistry?: LiveSubagentRegistry
209
214
  subagentRegistry?: SubagentRegistry
210
215
  createSessionForSubagent?: CreateSessionForSubagent
216
+ allowBackgroundFromSubagent?: boolean
211
217
  }
212
218
 
213
219
  export type CreateSessionResult = {
@@ -256,6 +262,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
256
262
  ...(options.permissions ? { permissions: options.permissions } : {}),
257
263
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
258
264
  ...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
265
+ ...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
259
266
  })
260
267
 
261
268
  const getOrigin: () => SessionOrigin | undefined =
@@ -351,6 +358,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
351
358
  getOrigin,
352
359
  permissions: options.permissions,
353
360
  stream: options.stream,
361
+ allowBackgroundFromSubagent: options.allowBackgroundFromSubagent,
354
362
  }),
355
363
  ]
356
364
  : [
@@ -580,7 +588,7 @@ export function formatRestartNotice(restartedAt: string): string {
580
588
 
581
589
  // Variant for the session that called the `restart` tool. The user explicitly
582
590
  // asked this conversation to restart; staying silent after the reboot is the
583
- // reported bug ("뭐야 너네 재시작 것도 모르냐"). This notice instructs the
591
+ // reported bug (e.g. "wait, you don't even know you restarted?"). This notice instructs the
584
592
  // model to acknowledge restart completion in its very next reply — once — then
585
593
  // stop mentioning it. Same SYSTEM MESSAGE framing as the sibling notice so
586
594
  // persona-rich models don't reply to the framing itself.
@@ -720,6 +728,7 @@ export function buildSubagentOrchestrationTools(opts: {
720
728
  getOrigin: () => SessionOrigin | undefined
721
729
  permissions: PermissionService | undefined
722
730
  stream: Stream | undefined
731
+ allowBackgroundFromSubagent?: boolean
723
732
  }): ToolDefinition[] {
724
733
  if (
725
734
  opts.liveRegistry === undefined ||
@@ -739,6 +748,9 @@ export function buildSubagentOrchestrationTools(opts: {
739
748
  getOrigin: opts.getOrigin,
740
749
  ...(opts.permissions ? { permissions: opts.permissions } : {}),
741
750
  ...(opts.stream ? { stream: opts.stream } : {}),
751
+ ...(opts.allowBackgroundFromSubagent !== undefined
752
+ ? { allowBackgroundFromSubagent: opts.allowBackgroundFromSubagent }
753
+ : {}),
742
754
  }),
743
755
  createSubagentOutputTool({
744
756
  liveRegistry: opts.liveRegistry,
@@ -899,6 +911,12 @@ export type CreateResourceLoaderOptions = {
899
911
  mcpManager?: McpManager
900
912
  permissions?: PermissionService
901
913
  runtimeVersion?: string
914
+ // Public subagents whose names + `rosterDescription`s render the full-mode
915
+ // "## Subagent orchestration" roster. When omitted (no-registry callers, the
916
+ // debug dumper), the prompt falls back to `DEFAULT_SUBAGENT_ROSTER`. Threaded
917
+ // from `createSessionWithDispose`, where the merged registry is already in
918
+ // scope.
919
+ subagentRegistry?: SubagentRegistry
902
920
  // Explicit override for the prompt mode. When omitted, the mode is derived
903
921
  // from `origin.kind`: cron + subagent → slim, tui + channel → full. Pass
904
922
  // 'full' to force the heavy prompt even on an unattended origin (rarely
@@ -957,6 +975,11 @@ export type SystemPromptMode = 'full' | 'slim'
957
975
  export type SystemPromptComposition = {
958
976
  mode?: SystemPromptMode
959
977
  self: string
978
+ // Pre-rendered full-mode orchestration roster (from `renderPublicSubagentRoster`).
979
+ // Kept as a ready string so this composer stays pure and registry-free; the
980
+ // registry-aware caller renders it. Ignored in slim mode (no roster section).
981
+ // Falls back to `DEFAULT_SUBAGENT_ROSTER` when omitted.
982
+ subagentRoster?: string
960
983
  runtimeVersion?: string
961
984
  origin?: SessionOrigin
962
985
  roleContext?: SessionRoleContext
@@ -990,7 +1013,10 @@ export type SystemPromptComposition = {
990
1013
  // suffix anyway — and removes the staleness failure mode where a session
991
1014
  // opened Friday answered "today is Friday" on Thursday.
992
1015
  export function composeSystemPrompt(parts: SystemPromptComposition): string {
993
- const base = parts.mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
1016
+ const base =
1017
+ parts.mode === 'slim'
1018
+ ? SLIM_SYSTEM_PROMPT
1019
+ : buildDefaultSystemPrompt(parts.subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
994
1020
  let prompt = `${base}\n\n${parts.self}`
995
1021
  if (parts.runtimeVersion !== undefined) {
996
1022
  prompt = `${prompt}\n\n${renderRuntimeBlock(parts.runtimeVersion)}`
@@ -1013,7 +1039,18 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
1013
1039
  export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
1014
1040
  const agentDir = options.agentDir ?? process.cwd()
1015
1041
  const mode: SystemPromptMode = options.mode ?? deriveSystemPromptMode(options.origin)
1016
- const basePrompt = mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
1042
+ // Slim mode (cron/subagent) has no orchestration section, so it never reads
1043
+ // the roster. Skip rendering it there — `renderPublicSubagentRoster` throws on
1044
+ // a public subagent with a missing/blank `rosterDescription`, and a slim
1045
+ // session must not fail on a roster it will never show.
1046
+ const subagentRoster =
1047
+ mode === 'slim'
1048
+ ? undefined
1049
+ : options.subagentRegistry !== undefined
1050
+ ? renderPublicSubagentRoster(options.subagentRegistry)
1051
+ : DEFAULT_SUBAGENT_ROSTER
1052
+ const basePrompt =
1053
+ mode === 'slim' ? SLIM_SYSTEM_PROMPT : buildDefaultSystemPrompt(subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
1017
1054
 
1018
1055
  // Kick off the three independent I/O paths concurrently. Sequential awaits
1019
1056
  // here used to be the dominant cold-start cost amplifier: loadSelf is 2
@@ -1077,6 +1114,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
1077
1114
  const systemPrompt = composeSystemPrompt({
1078
1115
  mode,
1079
1116
  self,
1117
+ subagentRoster,
1080
1118
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
1081
1119
  ...(options.origin !== undefined ? { origin: options.origin } : {}),
1082
1120
  ...(roleContext !== undefined ? { roleContext } : {}),
@@ -23,6 +23,11 @@ export type LiveSubagent = {
23
23
  // subagent_output/subagent_cancel. Absent when no permission service was
24
24
  // active at spawn, in which case the cap fails closed.
25
25
  spawnedByRole?: string
26
+ // True when spawned with run_in_background. Only background spawns deliver
27
+ // their result out-of-band (via the subagent.completed broadcast and the
28
+ // parent's drain); synchronous spawns return their result inline as the tool
29
+ // result, so the drain MUST NOT re-prompt for them. See runSubagentDrain.
30
+ background?: boolean
26
31
  startedAt: number
27
32
  status: SubagentStatus
28
33
  completion?: SubagentCompletion
@@ -52,9 +52,40 @@ const DEFAULT_PATH_TARGET = '.'
52
52
 
53
53
  const MAX_SESSIONS = 256
54
54
 
55
+ // The one tool with result-sensitive loop semantics: a poll returning 'running'
56
+ // is a legitimate wait, so its block is deferred until status is known (see
57
+ // `noteResult` / `deferable`). Kept as a local literal rather than importing the
58
+ // tool module to keep this primitive dependency-free; it must match
59
+ // SUBAGENT_OUTPUT_TOOL_NAME in tools/subagent-output.ts.
60
+ const SUBAGENT_OUTPUT_TOOL = 'subagent_output'
61
+
55
62
  export type LoopReason = 'consecutive' | 'windowed'
56
63
 
64
+ // Identifies the single observation a `check` recorded so a caller can retract
65
+ // exactly that one after learning post-execution it was not a loop (e.g. a
66
+ // `subagent_output` poll that returned `status: 'running'`). Narrower than
67
+ // `forgetTool`, which drops the whole tool window: retract undoes one call, so
68
+ // unrelated task_ids and terminal-result polls keep their accumulated signal.
69
+ export type LoopGuardReceipt = {
70
+ sessionId: string
71
+ tool: string
72
+ signature: string
73
+ windowSignature: string
74
+ }
75
+
76
+ // Post-execution classification of a `subagent_output` poll, fed back via
77
+ // `noteResult`. 'running' is a still-pending wait; 'terminal' is completed/failed
78
+ // — a repeated terminal poll is a real loop.
79
+ export type LoopObservedResult = 'running' | 'terminal'
80
+
57
81
  export type LoopGuardDecision =
82
+ | { kind: 'ok'; receipt: LoopGuardReceipt }
83
+ | { kind: 'warn'; count: number; reason: LoopReason; message: string; receipt: LoopGuardReceipt }
84
+ | { kind: 'block'; count: number; reason: LoopReason; message: string; receipt: LoopGuardReceipt; deferable: boolean }
85
+
86
+ // A decision before its receipt is attached. The detector helpers produce these;
87
+ // `check` stamps the receipt on at its single return site.
88
+ type Verdict =
58
89
  | { kind: 'ok' }
59
90
  | { kind: 'warn'; count: number; reason: LoopReason; message: string }
60
91
  | { kind: 'block'; count: number; reason: LoopReason; message: string }
@@ -71,6 +102,21 @@ export type LoopGuard = {
71
102
  // premature polls poisoned the window. Narrower than `forget`, so an
72
103
  // unrelated tool's accumulating loop on the same session is preserved.
73
104
  forgetTool: (sessionId: string, tool: string) => void
105
+ // Undoes the one observation a prior `check` recorded, identified by its
106
+ // receipt. Pops that signature from the windowed history and, when the
107
+ // current consecutive streak is the call this receipt named (it is the most
108
+ // recent `check` on the session, since tool execution within a turn is
109
+ // sequential), rewinds the streak by one. Used post-execution for a
110
+ // `subagent_output` poll that returned `status: 'running'` — a still-pending
111
+ // wait, not a loop — so it never accumulates toward either detector.
112
+ retract: (receipt: LoopGuardReceipt) => void
113
+ // Records the post-execution class of a `subagent_output` poll. Once a
114
+ // signature is seen 'terminal', `check` stops marking its blocks `deferable`,
115
+ // so further identical polls hard-block PRE-execute instead of running again
116
+ // just to re-confirm a completed task. 'running' clears any prior terminal
117
+ // mark for that signature (a task can only move running→terminal, but a
118
+ // signature can be reused across episodes).
119
+ noteResult: (receipt: LoopGuardReceipt, result: LoopObservedResult) => void
74
120
  }
75
121
 
76
122
  type SessionState = {
@@ -84,6 +130,10 @@ type SessionState = {
84
130
  // still present in the window.
85
131
  window: string[]
86
132
  windowWarned: Set<string>
133
+ // Exact signatures whose `subagent_output` poll has been observed terminal.
134
+ // A block on such a signature is enforced pre-execute (not deferred), so a
135
+ // completed task is not re-polled forever just to re-learn it is done.
136
+ termKnown: Set<string>
87
137
  }
88
138
 
89
139
  export type CreateLoopGuardOptions = {
@@ -134,7 +184,7 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
134
184
  }
135
185
  }
136
186
 
137
- function evaluateConsecutive(state: SessionState, tool: string): LoopGuardDecision {
187
+ function evaluateConsecutive(state: SessionState, tool: string): Verdict {
138
188
  if (state.count >= hardBlock) {
139
189
  return {
140
190
  kind: 'block',
@@ -150,32 +200,38 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
150
200
  return { kind: 'ok' }
151
201
  }
152
202
 
153
- function evaluateWindowed(state: SessionState, tool: string, windowSig: string): LoopGuardDecision {
203
+ function evaluateWindowed(state: SessionState, tool: string, windowSig: string): Verdict {
154
204
  const count = state.window.reduce((n, sig) => (sig === windowSig ? n + 1 : n), 0)
155
205
  if (count >= windowHardBlock) {
156
- return {
157
- kind: 'block',
158
- count,
159
- reason: 'windowed',
160
- message: formatWindowedBlockMessage(tool, count),
161
- }
206
+ return { kind: 'block', count, reason: 'windowed', message: formatWindowedBlockMessage(tool, count) }
162
207
  }
163
208
  if (count >= windowSoftWarn && !state.windowWarned.has(windowSig)) {
164
209
  state.windowWarned.add(windowSig)
165
- return {
166
- kind: 'warn',
167
- count,
168
- reason: 'windowed',
169
- message: formatWindowedWarnMessage(tool, count),
170
- }
210
+ return { kind: 'warn', count, reason: 'windowed', message: formatWindowedWarnMessage(tool, count) }
171
211
  }
172
212
  return { kind: 'ok' }
173
213
  }
174
214
 
215
+ function resolveVerdict(state: SessionState, tool: string, windowSig: string): Verdict {
216
+ const consecutive = evaluateConsecutive(state, tool)
217
+ if (consecutive.kind === 'block') return consecutive
218
+
219
+ // Back-to-back identical calls are the consecutive detector's domain; let
220
+ // it own them so a tight streak doesn't also trip the windowed detector.
221
+ // The windowed detector exists for INTERLEAVED cycles, so it only acts
222
+ // when this call breaks the immediate streak (count === 1).
223
+ const windowed = state.count === 1 ? evaluateWindowed(state, tool, windowSig) : { kind: 'ok' as const }
224
+ if (windowed.kind === 'block') return windowed
225
+ if (consecutive.kind === 'warn') return consecutive
226
+ if (windowed.kind === 'warn') return windowed
227
+ return { kind: 'ok' }
228
+ }
229
+
175
230
  return {
176
231
  check(sessionId, tool, args) {
177
232
  const signature = makeCallSignature(tool, args)
178
233
  const windowSig = makeWindowSignature(tool, args)
234
+ const receipt: LoopGuardReceipt = { sessionId, tool, signature, windowSignature: windowSig }
179
235
  const existing = sessions.get(sessionId)
180
236
 
181
237
  const state: SessionState = existing ?? {
@@ -184,6 +240,7 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
184
240
  warned: false,
185
241
  window: [],
186
242
  windowWarned: new Set(),
243
+ termKnown: new Set(),
187
244
  }
188
245
 
189
246
  if (state.signature !== signature) {
@@ -204,18 +261,14 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
204
261
 
205
262
  touch(sessionId, state)
206
263
 
207
- const consecutive = evaluateConsecutive(state, tool)
208
- if (consecutive.kind === 'block') return consecutive
209
-
210
- // Back-to-back identical calls are the consecutive detector's domain; let
211
- // it own them so a tight streak doesn't also trip the windowed detector.
212
- // The windowed detector exists for INTERLEAVED cycles, so it only acts
213
- // when this call breaks the immediate streak (count === 1).
214
- const windowed = state.count === 1 ? evaluateWindowed(state, tool, windowSig) : { kind: 'ok' as const }
215
- if (windowed.kind === 'block') return windowed
216
- if (consecutive.kind === 'warn') return consecutive
217
- if (windowed.kind === 'warn') return windowed
218
- return { kind: 'ok' }
264
+ const verdict = resolveVerdict(state, tool, windowSig)
265
+ if (verdict.kind === 'block') {
266
+ // A `subagent_output` block is deferable (let the boundary call execute
267
+ // to learn its status) only until this signature has proven terminal.
268
+ const deferable = tool === SUBAGENT_OUTPUT_TOOL && !state.termKnown.has(signature)
269
+ return { ...verdict, receipt, deferable }
270
+ }
271
+ return { ...verdict, receipt }
219
272
  },
220
273
  reset(sessionId) {
221
274
  sessions.delete(sessionId)
@@ -240,6 +293,39 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
240
293
  state.count = 0
241
294
  state.warned = false
242
295
  }
296
+ for (const sig of state.termKnown) {
297
+ if (signatureBelongsToTool(sig, tool)) state.termKnown.delete(sig)
298
+ }
299
+ },
300
+ retract(receipt) {
301
+ const state = sessions.get(receipt.sessionId)
302
+ if (state === undefined) return
303
+
304
+ // Pop the receipt's windowed observation. It is the most recent push for
305
+ // this signature (retraction runs immediately after the call's execute,
306
+ // before any other tool runs on the session), so remove the last match.
307
+ const lastIdx = state.window.lastIndexOf(receipt.windowSignature)
308
+ if (lastIdx !== -1) {
309
+ state.window.splice(lastIdx, 1)
310
+ if (!state.window.includes(receipt.windowSignature)) {
311
+ state.windowWarned.delete(receipt.windowSignature)
312
+ }
313
+ }
314
+
315
+ // Rewind the consecutive streak by one only if it is still the call this
316
+ // receipt named. A retracted soft-warned streak re-arms its warning so the
317
+ // next genuine repeat warns as if this call never happened.
318
+ if (state.signature === receipt.signature && state.count > 0) {
319
+ state.count -= 1
320
+ if (state.count < softWarn) state.warned = false
321
+ if (state.count === 0) state.signature = ''
322
+ }
323
+ },
324
+ noteResult(receipt, result) {
325
+ const state = sessions.get(receipt.sessionId)
326
+ if (state === undefined) return
327
+ if (result === 'terminal') state.termKnown.add(receipt.signature)
328
+ else state.termKnown.delete(receipt.signature)
243
329
  },
244
330
  }
245
331
  }