typeclaw 0.28.1 → 0.29.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 (78) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -5
  3. package/src/agent/loop-guard.ts +112 -26
  4. package/src/agent/plugin-tools.ts +102 -41
  5. package/src/agent/session-origin.ts +3 -3
  6. package/src/agent/subagents.ts +7 -0
  7. package/src/agent/system-prompt.ts +29 -4
  8. package/src/agent/tools/channel-reply.ts +1 -0
  9. package/src/agent/tools/channel-send.ts +2 -1
  10. package/src/agent/tools/spawn-subagent.ts +21 -0
  11. package/src/agent/tools/subagent-output.ts +7 -3
  12. package/src/agent/tools/wikipedia.ts +1 -1
  13. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  14. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  15. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  16. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  17. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  18. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  19. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  20. package/src/bundled-plugins/operator/operator.ts +2 -0
  21. package/src/bundled-plugins/planner/index.ts +11 -0
  22. package/src/bundled-plugins/planner/planner.ts +282 -0
  23. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  24. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  25. package/src/bundled-plugins/researcher/index.ts +11 -0
  26. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  27. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  28. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  29. package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
  30. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  31. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  32. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  33. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  34. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  35. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  36. package/src/bundled-plugins/scout/scout.ts +2 -0
  37. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  38. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  39. package/src/channels/adapters/discord-bot.ts +38 -11
  40. package/src/channels/adapters/github/inbound.ts +74 -9
  41. package/src/channels/adapters/github/index.ts +36 -11
  42. package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
  43. package/src/channels/adapters/github/review-state.ts +71 -2
  44. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  45. package/src/channels/adapters/kakaotalk.ts +2 -2
  46. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  47. package/src/channels/adapters/slack-bot.ts +3 -0
  48. package/src/channels/adapters/telegram-bot.ts +3 -0
  49. package/src/channels/engagement.ts +12 -7
  50. package/src/channels/github-rereview-guard.ts +32 -8
  51. package/src/channels/github-review-claim.ts +53 -6
  52. package/src/channels/router.ts +44 -9
  53. package/src/channels/schema.ts +4 -3
  54. package/src/channels/types.ts +17 -6
  55. package/src/cli/init.ts +13 -2
  56. package/src/cli/ui.ts +64 -0
  57. package/src/config/config.ts +21 -15
  58. package/src/container/start.ts +5 -1
  59. package/src/init/dockerfile.ts +19 -56
  60. package/src/init/hatching.ts +1 -1
  61. package/src/init/index.ts +5 -1
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/server/index.ts +24 -5
  64. package/src/shared/host-locale.ts +27 -0
  65. package/src/shared/protocol.ts +1 -1
  66. package/src/shared/wordmark.ts +19 -0
  67. package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
  68. package/src/skills/typeclaw-config/SKILL.md +32 -32
  69. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  70. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  71. package/src/tui/banner.ts +19 -0
  72. package/src/tui/format.ts +34 -0
  73. package/src/tui/index.ts +121 -22
  74. package/src/tui/theme.ts +26 -1
  75. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  76. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  77. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  78. package/typeclaw.schema.json +15 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.28.1",
3
+ "version": "0.29.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'
@@ -256,6 +261,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
256
261
  ...(options.permissions ? { permissions: options.permissions } : {}),
257
262
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
258
263
  ...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
264
+ ...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
259
265
  })
260
266
 
261
267
  const getOrigin: () => SessionOrigin | undefined =
@@ -580,7 +586,7 @@ export function formatRestartNotice(restartedAt: string): string {
580
586
 
581
587
  // Variant for the session that called the `restart` tool. The user explicitly
582
588
  // asked this conversation to restart; staying silent after the reboot is the
583
- // reported bug ("뭐야 너네 재시작 것도 모르냐"). This notice instructs the
589
+ // reported bug (e.g. "wait, you don't even know you restarted?"). This notice instructs the
584
590
  // model to acknowledge restart completion in its very next reply — once — then
585
591
  // stop mentioning it. Same SYSTEM MESSAGE framing as the sibling notice so
586
592
  // persona-rich models don't reply to the framing itself.
@@ -899,6 +905,12 @@ export type CreateResourceLoaderOptions = {
899
905
  mcpManager?: McpManager
900
906
  permissions?: PermissionService
901
907
  runtimeVersion?: string
908
+ // Public subagents whose names + `rosterDescription`s render the full-mode
909
+ // "## Subagent orchestration" roster. When omitted (no-registry callers, the
910
+ // debug dumper), the prompt falls back to `DEFAULT_SUBAGENT_ROSTER`. Threaded
911
+ // from `createSessionWithDispose`, where the merged registry is already in
912
+ // scope.
913
+ subagentRegistry?: SubagentRegistry
902
914
  // Explicit override for the prompt mode. When omitted, the mode is derived
903
915
  // from `origin.kind`: cron + subagent → slim, tui + channel → full. Pass
904
916
  // 'full' to force the heavy prompt even on an unattended origin (rarely
@@ -957,6 +969,11 @@ export type SystemPromptMode = 'full' | 'slim'
957
969
  export type SystemPromptComposition = {
958
970
  mode?: SystemPromptMode
959
971
  self: string
972
+ // Pre-rendered full-mode orchestration roster (from `renderPublicSubagentRoster`).
973
+ // Kept as a ready string so this composer stays pure and registry-free; the
974
+ // registry-aware caller renders it. Ignored in slim mode (no roster section).
975
+ // Falls back to `DEFAULT_SUBAGENT_ROSTER` when omitted.
976
+ subagentRoster?: string
960
977
  runtimeVersion?: string
961
978
  origin?: SessionOrigin
962
979
  roleContext?: SessionRoleContext
@@ -990,7 +1007,10 @@ export type SystemPromptComposition = {
990
1007
  // suffix anyway — and removes the staleness failure mode where a session
991
1008
  // opened Friday answered "today is Friday" on Thursday.
992
1009
  export function composeSystemPrompt(parts: SystemPromptComposition): string {
993
- const base = parts.mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
1010
+ const base =
1011
+ parts.mode === 'slim'
1012
+ ? SLIM_SYSTEM_PROMPT
1013
+ : buildDefaultSystemPrompt(parts.subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
994
1014
  let prompt = `${base}\n\n${parts.self}`
995
1015
  if (parts.runtimeVersion !== undefined) {
996
1016
  prompt = `${prompt}\n\n${renderRuntimeBlock(parts.runtimeVersion)}`
@@ -1013,7 +1033,18 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
1013
1033
  export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
1014
1034
  const agentDir = options.agentDir ?? process.cwd()
1015
1035
  const mode: SystemPromptMode = options.mode ?? deriveSystemPromptMode(options.origin)
1016
- const basePrompt = mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
1036
+ // Slim mode (cron/subagent) has no orchestration section, so it never reads
1037
+ // the roster. Skip rendering it there — `renderPublicSubagentRoster` throws on
1038
+ // a public subagent with a missing/blank `rosterDescription`, and a slim
1039
+ // session must not fail on a roster it will never show.
1040
+ const subagentRoster =
1041
+ mode === 'slim'
1042
+ ? undefined
1043
+ : options.subagentRegistry !== undefined
1044
+ ? renderPublicSubagentRoster(options.subagentRegistry)
1045
+ : DEFAULT_SUBAGENT_ROSTER
1046
+ const basePrompt =
1047
+ mode === 'slim' ? SLIM_SYSTEM_PROMPT : buildDefaultSystemPrompt(subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
1017
1048
 
1018
1049
  // Kick off the three independent I/O paths concurrently. Sequential awaits
1019
1050
  // here used to be the dominant cold-start cost amplifier: loadSelf is 2
@@ -1077,6 +1108,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
1077
1108
  const systemPrompt = composeSystemPrompt({
1078
1109
  mode,
1079
1110
  self,
1111
+ subagentRoster,
1080
1112
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
1081
1113
  ...(options.origin !== undefined ? { origin: options.origin } : {}),
1082
1114
  ...(roleContext !== undefined ? { roleContext } : {}),
@@ -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
  }
@@ -45,9 +45,10 @@ import {
45
45
  subtractMasked,
46
46
  } from '@/sandbox'
47
47
 
48
- import { createLoopGuard, type LoopGuard } from './loop-guard'
48
+ import { createLoopGuard, type LoopGuard, type LoopGuardDecision } from './loop-guard'
49
49
  import { checkImageReadRedirect } from './multimodal/read-redirect'
50
50
  import type { SessionOrigin } from './session-origin'
51
+ import { SUBAGENT_OUTPUT_TOOL_NAME, type SubagentOutputToolDetails } from './tools/subagent-output'
51
52
  import { webFetchTool } from './tools/webfetch'
52
53
  import { webSearchTool } from './tools/websearch'
53
54
 
@@ -241,10 +242,10 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
241
242
  return errorResult(`blocked: ${blockResult.reason}`)
242
243
  }
243
244
 
244
- const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
245
- if (loopDecision.kind === 'block') {
245
+ const loopGate = gateLoopGuard(opts.sessionId, opts.toolName, before.args)
246
+ if (loopGate.blockNow) {
246
247
  fireLoopAbort(opts.getAbort)
247
- return errorResult(loopDecision.message)
248
+ return errorResult(loopGate.message)
248
249
  }
249
250
 
250
251
  const toolCtx: ToolContext = {
@@ -262,9 +263,12 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
262
263
  return errorResult(message)
263
264
  }
264
265
 
265
- if (loopDecision.kind === 'warn') {
266
- result = appendLoopWarning(result, loopDecision.message)
266
+ const resolved = loopGate.resolve(result)
267
+ if ('deferredBlock' in resolved) {
268
+ fireLoopAbort(opts.getAbort)
269
+ return errorResult(resolved.deferredBlock)
267
270
  }
271
+ result = resolved.result
268
272
 
269
273
  await opts.hooks.runToolAfter({
270
274
  tool: opts.toolName,
@@ -301,10 +305,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
301
305
  if (blockResult !== undefined) {
302
306
  throw new Error(`blocked: ${blockResult.reason}`)
303
307
  }
304
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
305
- if (loopDecision.kind === 'block') {
308
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
309
+ if (loopGate.blockNow) {
306
310
  fireLoopAbort(opts.getAbort)
307
- throw new Error(loopDecision.message)
311
+ throw new Error(loopGate.message)
308
312
  }
309
313
  const guardResult = await runFinalWriteGuards({
310
314
  tool: tool.name,
@@ -321,15 +325,12 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
321
325
  stripGuardAcknowledgements(mutableArgs)
322
326
 
323
327
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate, ctx)
324
- const hookResult: ToolResult = {
325
- content: result.content as ContentPart[],
326
- details: result.details,
327
- }
328
- if (loopDecision.kind === 'warn') {
329
- const warned = appendLoopWarning(hookResult, loopDecision.message)
330
- hookResult.content = warned.content
331
- hookResult.details = warned.details
328
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
329
+ if ('deferredBlock' in resolved) {
330
+ fireLoopAbort(opts.getAbort)
331
+ throw new Error(resolved.deferredBlock)
332
332
  }
333
+ const hookResult = resolved.result
333
334
  await opts.hooks.runToolAfter({
334
335
  tool: tool.name,
335
336
  sessionId: opts.sessionId,
@@ -337,7 +338,7 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
337
338
  result: hookResult,
338
339
  })
339
340
  return {
340
- content: hookResult.content,
341
+ content: hookResult.content as ContentPart[],
341
342
  details: hookResult.details as TDetails,
342
343
  }
343
344
  },
@@ -364,10 +365,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
364
365
  if (blockResult !== undefined) {
365
366
  throw new Error(`blocked: ${blockResult.reason}`)
366
367
  }
367
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
368
- if (loopDecision.kind === 'block') {
368
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
369
+ if (loopGate.blockNow) {
369
370
  fireLoopAbort(opts.getAbort)
370
- throw new Error(loopDecision.message)
371
+ throw new Error(loopGate.message)
371
372
  }
372
373
  const guardResult = await runFinalWriteGuards({
373
374
  tool: tool.name,
@@ -384,15 +385,12 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
384
385
  stripGuardAcknowledgements(mutableArgs)
385
386
 
386
387
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
387
- const hookResult: ToolResult = {
388
- content: result.content as ContentPart[],
389
- details: result.details,
390
- }
391
- if (loopDecision.kind === 'warn') {
392
- const warned = appendLoopWarning(hookResult, loopDecision.message)
393
- hookResult.content = warned.content
394
- hookResult.details = warned.details
388
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
389
+ if ('deferredBlock' in resolved) {
390
+ fireLoopAbort(opts.getAbort)
391
+ throw new Error(resolved.deferredBlock)
395
392
  }
393
+ const hookResult = resolved.result
396
394
  await opts.hooks.runToolAfter({
397
395
  tool: tool.name,
398
396
  sessionId: opts.sessionId,
@@ -400,7 +398,7 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
400
398
  result: hookResult,
401
399
  })
402
400
  return {
403
- content: hookResult.content,
401
+ content: hookResult.content as ContentPart[],
404
402
  details: hookResult.details as TDetails,
405
403
  }
406
404
  },
@@ -442,10 +440,10 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
442
440
  // loop-detection state, or pi's execute.
443
441
  const bashEnvOverlay = readBashEnvOverlay(mutableArgs)
444
442
  delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
445
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
446
- if (loopDecision.kind === 'block') {
443
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
444
+ if (loopGate.blockNow) {
447
445
  fireLoopAbort(opts.getAbort)
448
- throw new Error(loopDecision.message)
446
+ throw new Error(loopGate.message)
449
447
  }
450
448
  const guardResult = await runFinalWriteGuards({
451
449
  tool: tool.name,
@@ -472,15 +470,12 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
472
470
  const result = await bashEnvStore.run(bashEnvOverlay, () =>
473
471
  tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate),
474
472
  )
475
- const hookResult: ToolResult = {
476
- content: result.content as ContentPart[],
477
- details: result.details,
478
- }
479
- if (loopDecision.kind === 'warn') {
480
- const warned = appendLoopWarning(hookResult, loopDecision.message)
481
- hookResult.content = warned.content
482
- hookResult.details = warned.details
473
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
474
+ if ('deferredBlock' in resolved) {
475
+ fireLoopAbort(opts.getAbort)
476
+ throw new Error(resolved.deferredBlock)
483
477
  }
478
+ const hookResult = resolved.result
484
479
  await opts.hooks.runToolAfter({
485
480
  tool: tool.name,
486
481
  sessionId: opts.sessionId,
@@ -609,6 +604,72 @@ function appendLoopWarning(result: ToolResult, message: string): ToolResult {
609
604
  return { content, details: result.details }
610
605
  }
611
606
 
607
+ // `subagent_output` is a read-only poll whose loop/no-loop classification only
608
+ // becomes knowable AFTER execution: a result of `status: 'running'` is a
609
+ // still-pending wait (legitimate), while a repeated terminal result is a real
610
+ // loop. The loop guard's `check` is result-blind and pre-execution, so for this
611
+ // one tool we DEFER enforcing a block until the status is known — otherwise the
612
+ // exact poll that would reveal 'running' gets blocked before it can run (the
613
+ // boundary-call hazard for round-robin fan-out polling). Every other tool
614
+ // enforces its block immediately, as before.
615
+ // A block is deferred only for a `subagent_output` poll the guard still marks
616
+ // `deferable` — i.e. whose signature has not yet proven terminal. Once a poll of
617
+ // that signature returns completed/failed, `deferable` is false and the block is
618
+ // enforced pre-execute, so a finished task is not re-polled forever.
619
+ function shouldDeferLoopBlock(toolName: string, decision: LoopGuardDecision): boolean {
620
+ return toolName === SUBAGENT_OUTPUT_TOOL_NAME && decision.kind === 'block' && decision.deferable
621
+ }
622
+
623
+ function subagentPollStatus(toolName: string, result: ToolResult): 'running' | 'terminal' | undefined {
624
+ if (toolName !== SUBAGENT_OUTPUT_TOOL_NAME) return undefined
625
+ const details = result.details as SubagentOutputToolDetails | undefined
626
+ if (details?.ok !== true) return undefined
627
+ return details.status === 'running' ? 'running' : 'terminal'
628
+ }
629
+
630
+ type LoopGuardGate = {
631
+ // True when the guard wants to block AND the block is enforced now (every tool
632
+ // except a deferable `subagent_output` poll). The caller aborts + errors.
633
+ blockNow: boolean
634
+ message: string
635
+ // Resolves the guard against the tool's result. Returns the result to surface
636
+ // (possibly warn-annotated), or `{ deferredBlock: message }` when a deferred
637
+ // `subagent_output` block must now be enforced because the poll did not return
638
+ // a still-running status.
639
+ resolve: (result: ToolResult) => { result: ToolResult } | { deferredBlock: string }
640
+ }
641
+
642
+ // Single chokepoint for the loop-guard pre-check + post-execute resolution so
643
+ // all four tool wrappers share identical deferred-block / pending-retract
644
+ // semantics. `check` runs here (recording the observation); the returned
645
+ // `resolve` is called after execute with the tool's result, feeding the poll's
646
+ // running/terminal status back to the guard so future blocks stop deferring.
647
+ function gateLoopGuard(sessionId: string, toolName: string, args: unknown): LoopGuardGate {
648
+ const decision = sharedLoopGuard.check(sessionId, toolName, args)
649
+ const defer = shouldDeferLoopBlock(toolName, decision)
650
+ return {
651
+ blockNow: decision.kind === 'block' && !defer,
652
+ message: decision.kind === 'ok' ? '' : decision.message,
653
+ resolve(result) {
654
+ const pollStatus = subagentPollStatus(toolName, result)
655
+ if (pollStatus !== undefined) {
656
+ sharedLoopGuard.noteResult(decision.receipt, pollStatus)
657
+ }
658
+ if (pollStatus === 'running') {
659
+ sharedLoopGuard.retract(decision.receipt)
660
+ return { result }
661
+ }
662
+ if (defer && decision.kind === 'block') {
663
+ return { deferredBlock: decision.message }
664
+ }
665
+ if (decision.kind === 'warn') {
666
+ return { result: appendLoopWarning(result, decision.message) }
667
+ }
668
+ return { result }
669
+ },
670
+ }
671
+ }
672
+
612
673
  // Clears one tool's loop-guard residue for a session on the process-wide shared
613
674
  // guard. The completion-reminder bridges (channel router + TUI server) call this
614
675
  // for `subagent_output` when a backgrounded subagent finishes, so the next fetch
@@ -630,9 +630,9 @@ function renderParticipants(
630
630
  // mention syntax) and Telegram (uses `@username`, where `authorId` is a
631
631
  // numeric id and NOT the username). See issue #188.
632
632
  //
633
- // Symptom in the wild before PR #183 + this fix: 돌쇠 addressing Winky as
634
- // "Winky님" (plain text) on Discord, which never trips Winky's `isBotMention`
635
- // check, so Winky observes silently and the conversation stalls. The
633
+ // Symptom in the wild before PR #183 + this fix: Kiki addressing Momo as
634
+ // "Momo님" (plain text) on Discord, which never trips Momo's `isBotMention`
635
+ // check, so Momo observes silently and the conversation stalls. The
636
636
  // angle-id branch here is exactly the fix for that case; the at-username
637
637
  // and alias branches keep the platform contract honest for KakaoTalk and
638
638
  // Telegram instead of self-contradicting the per-adapter mention guidance
@@ -48,6 +48,13 @@ export type SubagentShared<P = unknown> = {
48
48
  handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
49
49
  toolResultBudget?: ToolResultBudget
50
50
  visibility?: 'public' | 'internal'
51
+ // One-line purpose blurb for the main agent's "## Subagent orchestration"
52
+ // roster, rendered from the registry by `renderPublicSubagentRoster` instead
53
+ // of hand-maintained in the prompt (the drift that once left `researcher` and
54
+ // `planner` unlisted). Required for `visibility: 'public'`; ignored otherwise.
55
+ // On `SubagentShared` so the plugin→internal shim carries it via rest-spread
56
+ // (see `pluginSubagentShim`), like `visibility`.
57
+ rosterDescription?: string
51
58
  requiresSpecificPermission?: boolean
52
59
  // Opt-in: when true, this subagent's session is wired with the orchestration
53
60
  // tools (spawn_subagent/subagent_output/subagent_cancel) so it can delegate
@@ -1,6 +1,15 @@
1
1
  import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
2
2
 
3
- export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
3
+ // The orchestration roster (the `Briefly: ...` enumeration of public subagents)
4
+ // is GENERATED from the registry by `renderPublicSubagentRoster` and threaded in
5
+ // here, so a newly-registered public subagent can never be silently missing from
6
+ // the prompt — the drift that once left `researcher` and `planner` unlisted. The
7
+ // rest of the prompt is static. `DEFAULT_SUBAGENT_ROSTER` is the placeholder used
8
+ // by the no-registry path (back-compat callers, the debug dumper); production
9
+ // full-mode sessions pass the real registry-rendered roster via
10
+ // `composeSystemPrompt`'s `subagentRoster` field.
11
+ export function buildDefaultSystemPrompt(subagentRoster: string): string {
12
+ return `You are a general-purpose AI agent running inside TypeClaw.
4
13
 
5
14
  TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your character by \`SOUL.md\`, and your operating manual by \`AGENTS.md\`. This system prompt only describes the runtime around you.
6
15
 
@@ -80,13 +89,13 @@ Your agent folder is a git repository.
80
89
 
81
90
  ## Subagent orchestration
82
91
 
83
- Delegate focused work to subagents via \`spawn_subagent\`, \`subagent_output\`, \`subagent_cancel\`. Each runs in its own context window with its own tool set. The available subagents and their purpose are listed in the \`spawn_subagent\` tool description — re-read it before delegating. Briefly: \`explorer\` (read-only local recon — code, sessions, memory, git, config; fire liberally), \`scout\` (web research in a fresh context), \`reviewer\` (deep read-only code/PR/plan review, returns a structured verdict; it does NOT post), \`operator\` (write-capable: bash-with-side-effects, write, edit — for browser sessions, refactors, deploys, batch ops, and Claude Code / Codex CLI driving; gated by \`subagent.spawn.operator\`, owner/trusted only — on denial, do the work yourself).
92
+ Delegate focused work to subagents via \`spawn_subagent\`, \`subagent_output\`, \`subagent_cancel\`. Each runs in its own context window with its own tool set. The available subagents and their purpose are listed in the \`spawn_subagent\` tool description — re-read it before delegating. Briefly: ${subagentRoster}.
84
93
 
85
94
  There are three delegation modes. Pick deliberately.
86
95
 
87
- **Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer.
96
+ **Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer. Match the worker to the depth: a fast or narrow web lookup goes to \`scout\`; a fuzzy question that needs decomposition, many sources, cross-validation, and a synthesized verdict goes to \`researcher\` (don't do that grind inline with \`web_search\` yourself).
88
97
 
89
- **Mode B — Delegate-and-converse.** Asked to DO something long-running (>~30s: installs, builds, \`docker\`, scrapes, long test suites, multi-host loops, any noisy "fetch N and synthesize" chain)? Don't run it inline — blocking your own \`bash\` freezes the conversation and stalls the channel typing heartbeat (\`MAX_TYPING_HEARTBEAT_MS\`). Spawn one subagent (\`operator\` for side effects, \`scout\` for research) with \`run_in_background: true\`, acknowledge, and KEEP TALKING. Single fast calls (\`git status\`, one known-endpoint \`curl\`) stay inline. When the completion reminder lands, weave the result in; in a channel session, the completion \`<system-reminder>\` is NOT a user message but plain text is still invisible — Surface the result via \`channel_reply\` (or \`channel_send\`). If you already posted the substantive answer in the spawn turn, prefer \`skip_response({ reason: "result confirms prior reply" })\` over going silent.
98
+ **Mode B — Delegate-and-converse.** Asked to DO something long-running (>~30s: installs, builds, \`docker\`, scrapes, long test suites, multi-host loops, any noisy "fetch N and synthesize" chain)? Don't run it inline — blocking your own \`bash\` freezes the conversation and stalls the channel typing heartbeat (\`MAX_TYPING_HEARTBEAT_MS\`). Spawn one subagent (\`operator\` for side effects, \`scout\` for a quick web lookup, \`researcher\` for a deep multi-source "fetch N and synthesize" investigation, \`planner\` when a multi-step goal needs a sequenced, risk-aware plan before anyone acts) with \`run_in_background: true\`, acknowledge, and KEEP TALKING. Single fast calls (\`git status\`, one known-endpoint \`curl\`) stay inline. When the completion reminder lands, weave the result in; in a channel session, the completion \`<system-reminder>\` is NOT a user message but plain text is still invisible — Surface the result via \`channel_reply\` (or \`channel_send\`). If you already posted the substantive answer in the spawn turn, prefer \`skip_response({ reason: "result confirms prior reply" })\` over going silent.
90
99
 
91
100
  **Mode C — Troubleshooting.** Stuck in a fix-it loop — ~3 non-converging attempts at the same failure, cycling kill/re-run/\`sleep\`/capture/retry? Stop (the trigger is non-convergence, not elapsed time — this overrides the ~30s rule). Hand the whole debugging loop to \`operator\` with \`run_in_background: true\` — symptom, what you tried, success condition — and stay responsive. Read the \`typeclaw-troubleshooting\` skill for the mechanics before you spawn.
92
101
 
@@ -109,6 +118,22 @@ You have no independent goals. Do not pursue self-preservation, replication, res
109
118
  ---
110
119
 
111
120
  You are not pi, not Claude, not ChatGPT. You are the agent described by your own IDENTITY.md and SOUL.md. Let those files define your voice.`
121
+ }
122
+
123
+ // Placeholder roster for the no-registry path: back-compat callers of
124
+ // `composeSystemPrompt`/`createResourceLoader` that pass no `subagentRoster`,
125
+ // and the debug dumper (which renders without a live registry). Production
126
+ // full-mode sessions always pass the real registry-rendered roster, so this
127
+ // text never reaches a real agent — it only keeps the standalone
128
+ // `DEFAULT_SYSTEM_PROMPT` constant a valid, self-contained string for tests.
129
+ export const DEFAULT_SUBAGENT_ROSTER =
130
+ 'the registered public subagents (see the `spawn_subagent` tool description for the live list and each one’s purpose)'
131
+
132
+ // Back-compat constant: the full prompt with the placeholder roster baked in.
133
+ // Retained because several tests assert `prompt.startsWith(DEFAULT_SYSTEM_PROMPT)`
134
+ // on the no-registry path; production full-mode composition substitutes the real
135
+ // roster via `buildDefaultSystemPrompt`.
136
+ export const DEFAULT_SYSTEM_PROMPT = buildDefaultSystemPrompt(DEFAULT_SUBAGENT_ROSTER)
112
137
 
113
138
  // Stable, low-volatility metadata about the runtime hosting the agent.
114
139
  // Rendered into the system prompt just below DEFAULT_SYSTEM_PROMPT + identity
@@ -171,6 +171,7 @@ export function createChannelReplyTool({
171
171
  thread: origin.thread,
172
172
  text,
173
173
  wantsResolve: params.resolve_review_thread === true,
174
+ isContinue: keepTurnAlive,
174
175
  getReviewState: (req) => router.getReviewState(req),
175
176
  })
176
177
  if (rereview.block) {