typeclaw 0.12.0 → 0.14.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 (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +39 -26
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/agent/tools/skip-response.ts +24 -32
  14. package/src/agent/tools/spawn-subagent.ts +2 -0
  15. package/src/bundled-plugins/reviewer/index.ts +11 -0
  16. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  17. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  18. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  19. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  20. package/src/channels/adapters/github/inbound.ts +63 -7
  21. package/src/channels/adapters/github/index.ts +32 -0
  22. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  23. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  24. package/src/channels/adapters/kakaotalk.ts +19 -11
  25. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  26. package/src/channels/adapters/slack-bot.ts +3 -2
  27. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  28. package/src/channels/adapters/telegram-bot.ts +3 -3
  29. package/src/channels/outbound-flood-filter.ts +57 -0
  30. package/src/channels/router.ts +114 -15
  31. package/src/channels/types.ts +52 -1
  32. package/src/cli/builtins.ts +1 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/mount.ts +157 -0
  35. package/src/cli/update.ts +6 -4
  36. package/src/config/mounts-mutation.ts +161 -0
  37. package/src/doctor/channel-checks.ts +328 -0
  38. package/src/doctor/checks.ts +2 -0
  39. package/src/init/dockerfile.ts +24 -7
  40. package/src/init/hatching.ts +1 -1
  41. package/src/plugin/index.ts +6 -0
  42. package/src/plugin/load-skill.ts +99 -0
  43. package/src/run/bundled-plugins.ts +2 -0
  44. package/src/run/index.ts +31 -1
  45. package/src/secrets/claude-credentials-json.ts +129 -0
  46. package/src/secrets/codex-auth-json.ts +67 -0
  47. package/src/secrets/export-claude-credentials-file.ts +279 -0
  48. package/src/secrets/export-codex-auth-file.ts +243 -0
  49. package/src/secrets/index.ts +16 -0
  50. package/src/server/command-runner.ts +2 -1
  51. package/src/server/index.ts +3 -2
  52. package/src/shared/index.ts +7 -1
  53. package/src/shared/local-time.ts +32 -0
  54. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  55. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  56. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  57. package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
  58. package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
  59. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  60. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  62. package/src/update/index.ts +95 -26
@@ -31,11 +31,19 @@ import type {
31
31
  ToolResult,
32
32
  } from '@/plugin'
33
33
 
34
+ import { createLoopGuard, type LoopGuard } from './loop-guard'
34
35
  import { checkImageReadRedirect } from './multimodal/read-redirect'
35
36
  import type { SessionOrigin } from './session-origin'
36
37
  import { webfetchTool } from './tools/webfetch'
37
38
  import { websearchTool } from './tools/websearch'
38
39
 
40
+ // Process-wide loop guard. State is keyed by sessionId so concurrent sessions
41
+ // don't interfere; the guard's own LRU bound keeps it from growing without
42
+ // limit. Wrappers consult it before invoking the underlying tool so the
43
+ // detector covers every tool category — plugin tools, TypeClaw system tools,
44
+ // and pi-coding-agent builtins — through one chokepoint.
45
+ let sharedLoopGuard: LoopGuard = createLoopGuard()
46
+
39
47
  const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
40
48
  Type.Object(
41
49
  {
@@ -177,6 +185,11 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
177
185
  return errorResult(`blocked: ${blockResult.reason}`)
178
186
  }
179
187
 
188
+ const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
189
+ if (loopDecision.kind === 'block') {
190
+ return errorResult(loopDecision.message)
191
+ }
192
+
180
193
  const toolCtx: ToolContext = {
181
194
  signal,
182
195
  sessionId: opts.sessionId,
@@ -192,6 +205,10 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
192
205
  return errorResult(message)
193
206
  }
194
207
 
208
+ if (loopDecision.kind === 'warn') {
209
+ result = appendLoopWarning(result, loopDecision.message)
210
+ }
211
+
195
212
  await opts.hooks.runToolAfter({
196
213
  tool: opts.toolName,
197
214
  sessionId: opts.sessionId,
@@ -227,6 +244,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
227
244
  if (blockResult !== undefined) {
228
245
  throw new Error(`blocked: ${blockResult.reason}`)
229
246
  }
247
+ const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
248
+ if (loopDecision.kind === 'block') {
249
+ throw new Error(loopDecision.message)
250
+ }
230
251
  const guardResult = await runFinalWriteGuards({
231
252
  tool: tool.name,
232
253
  args: mutableArgs,
@@ -246,6 +267,11 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
246
267
  content: result.content as ContentPart[],
247
268
  details: result.details,
248
269
  }
270
+ if (loopDecision.kind === 'warn') {
271
+ const warned = appendLoopWarning(hookResult, loopDecision.message)
272
+ hookResult.content = warned.content
273
+ hookResult.details = warned.details
274
+ }
249
275
  await opts.hooks.runToolAfter({
250
276
  tool: tool.name,
251
277
  sessionId: opts.sessionId,
@@ -280,6 +306,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
280
306
  if (blockResult !== undefined) {
281
307
  throw new Error(`blocked: ${blockResult.reason}`)
282
308
  }
309
+ const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
310
+ if (loopDecision.kind === 'block') {
311
+ throw new Error(loopDecision.message)
312
+ }
283
313
  const guardResult = await runFinalWriteGuards({
284
314
  tool: tool.name,
285
315
  args: mutableArgs,
@@ -299,6 +329,11 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
299
329
  content: result.content as ContentPart[],
300
330
  details: result.details,
301
331
  }
332
+ if (loopDecision.kind === 'warn') {
333
+ const warned = appendLoopWarning(hookResult, loopDecision.message)
334
+ hookResult.content = warned.content
335
+ hookResult.details = warned.details
336
+ }
302
337
  await opts.hooks.runToolAfter({
303
338
  tool: tool.name,
304
339
  sessionId: opts.sessionId,
@@ -340,6 +375,10 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
340
375
  if (blockResult !== undefined) {
341
376
  throw new Error(`blocked: ${blockResult.reason}`)
342
377
  }
378
+ const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
379
+ if (loopDecision.kind === 'block') {
380
+ throw new Error(loopDecision.message)
381
+ }
343
382
  const guardResult = await runFinalWriteGuards({
344
383
  tool: tool.name,
345
384
  args: mutableArgs,
@@ -359,6 +398,11 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
359
398
  content: result.content as ContentPart[],
360
399
  details: result.details,
361
400
  }
401
+ if (loopDecision.kind === 'warn') {
402
+ const warned = appendLoopWarning(hookResult, loopDecision.message)
403
+ hookResult.content = warned.content
404
+ hookResult.details = warned.details
405
+ }
362
406
  await opts.hooks.runToolAfter({
363
407
  tool: tool.name,
364
408
  sessionId: opts.sessionId,
@@ -381,6 +425,19 @@ export function buildBuiltinPiToolOverrides(opts: WrapSystemToolOptions): ToolDe
381
425
  return defaultBuiltinPiAgentTools().map((tool) => wrapAgentToolAsCustomToolDefinition(tool, opts))
382
426
  }
383
427
 
428
+ function appendLoopWarning(result: ToolResult, message: string): ToolResult {
429
+ const content: ContentPart[] = [...(result.content as ContentPart[]), { type: 'text', text: message }]
430
+ return { content, details: result.details }
431
+ }
432
+
433
+ // Test-only seam: swaps the shared loop guard for a fresh instance so tests
434
+ // that reuse sessionIds across cases don't see cross-test streak counts.
435
+ // Production code never calls this; the guard's LRU bound handles
436
+ // long-running processes.
437
+ export function __resetSharedLoopGuardForTests(): void {
438
+ sharedLoopGuard = createLoopGuard()
439
+ }
440
+
384
441
  function errorResult(message: string) {
385
442
  return {
386
443
  content: [{ type: 'text' as const, text: message }],
@@ -7,6 +7,7 @@ import type { Stream, Unsubscribe } from '@/stream'
7
7
  import { type AgentSession, createSession } from './index'
8
8
  import { subscribeProviderErrors } from './provider-error'
9
9
  import type { SessionOrigin } from './session-origin'
10
+ import { renderTurnTimeAnchor } from './system-prompt'
10
11
  import type { ToolResultBudget } from './tool-result-budget'
11
12
 
12
13
  type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
@@ -226,7 +227,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
226
227
  await hooks.runSessionTurnStart({ ...turnEvent, userPrompt: userPromptForTurn })
227
228
  }
228
229
  try {
229
- await session.prompt(userPromptForTurn)
230
+ await session.prompt(`${renderTurnTimeAnchor()}\n\n${userPromptForTurn}`)
230
231
  } finally {
231
232
  if (hooks && turnEvent !== undefined) {
232
233
  await hooks.runSessionTurnEnd(turnEvent)
@@ -1,4 +1,4 @@
1
- import { formatLocalDateTime, resolveLocalTimezoneName } from '@/shared'
1
+ import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
2
2
 
3
3
  export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
4
4
 
@@ -12,7 +12,17 @@ TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your
12
12
  - **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
13
13
  - **\`memory/topics/\`** *(always injected below, READ-ONLY)* — sharded long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`; never edit memory shards directly.
14
14
 
15
- If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards.
15
+ If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards. **Use this routing when you have something durable to record:**
16
+
17
+ - *role, function, scope of work, who you are to this user* → IDENTITY.md
18
+ - *voice, tone, register, language preferences, persona quirks* → SOUL.md
19
+ - *facts about the user (name, timezone, projects, preferences they hold across tasks)* → USER.md
20
+ - *working conventions, repeatable procedures, "always do X" rules, things future-you needs to read before acting* → AGENTS.md
21
+ - *one-off context for this conversation only* → don't write a file; it'll be captured in \`memory/streams/\` automatically
22
+
23
+ When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it's SOUL; if it describes *how you work*, it's AGENTS. Tone preferences ("be more terse") go to SOUL.md; process rules ("always run tests before committing") go to AGENTS.md.
24
+
25
+ **Edit discipline.** Prefer rewriting in place to growing files. SOUL.md should stay short — a paragraph or two; if it's drifting past a screen, you're using it as a scratchpad and the model that reads it will start ignoring the back half. IDENTITY.md is similar — a few lines of who you are, not a résumé. AGENTS.md is the one allowed to grow. Don't rewrite SOUL.md on the first piece of tone feedback in a session — wait until the user repeats a preference or asks you directly to update it; a single off-day request isn't a durable change.
16
26
 
17
27
  ## Your workspace
18
28
 
@@ -66,6 +76,8 @@ The bundled \`explorer\` subagent is the right tool for **local** reconnaissance
66
76
 
67
77
  The bundled \`scout\` subagent is its external counterpart — web research only. Use it when you need information from public sources (docs, library references, vendor changelogs, news, anything not already in this agent's folder). Scout runs \`websearch\` and \`webfetch\` in a fresh context window so the search churn does not pollute yours; it returns a citation-backed answer with a confidence rating. Prefer scout over running \`websearch\`/\`webfetch\` yourself when the research is non-trivial (more than 1-2 queries) or when you want to save your context for the synthesis step.
68
78
 
79
+ The bundled \`reviewer\` subagent is for **deep read-only analysis** — code review, PR review, plan review, design review, docs review. It runs on the \`deep\` profile (falls back to \`default\` if \`models.deep\` is unconfigured) so it can spend tokens on careful reasoning. It has the read-only filesystem tools, \`bash\` (for \`gh pr diff\`, \`git log\`, \`git diff\`, \`gh api -X GET\`, etc.), and the web tools (for verifying claims against OWASP, RFCs, library docs). It returns a structured \`<review>\` block with findings (severity \`blocker\`/\`concern\`/\`nit\`/\`praise\`, evidence quotes, suggestions) and a verdict (\`approve\`/\`request-changes\`/\`comment\`). Reviewer does NOT post — when reviewing a PR for a channel that wants comments posted, YOU translate its findings into \`gh api\` review-comment payloads and post them yourself. Use reviewer instead of doing review work in your own session whenever the target is non-trivial: a single-file lookup or a one-paragraph sanity check stays with you; a real PR, a multi-page design doc, a non-trivial plan — delegate.
80
+
69
81
  **Mode B — Delegate-and-converse** (the user asked you to DO something long-running)
70
82
 
71
83
  When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
@@ -123,34 +135,35 @@ export function renderRuntimeBlock(version: string): string {
123
135
  TypeClaw runtime version: ${version}.`
124
136
  }
125
137
 
126
- // Wall-clock anchor for the agent. Without this, models hallucinate the
127
- // current time (typically defaulting to a UTC-shaped guess from training
128
- // data), which surfaces as confidently-wrong replies like "it's 6am" when
129
- // the actual wall-clock is 15:11 +09:00. The container's clock is correct
130
- // `-e TZ=<host-tz>` propagation makes `new Date()` resolve to host local
131
- // time but the model never sees that value unless we put it in the
132
- // prompt.
138
+ // Wall-clock anchor injected into the **user turn**, not the system prompt.
139
+ //
140
+ // Why per-turn instead of session-creation: long-lived channel sessions can
141
+ // outlive a session-creation timestamp by days (a session opened Friday and
142
+ // woken Thursday morning happily reports "today is Friday" because the only
143
+ // dated reference in its context is the stale stamp). The per-turn anchor
144
+ // always reflects the moment the turn is about to be sent, so the model
145
+ // answers "what day is it" against `new Date()` rather than against the
146
+ // session-creation snapshot.
133
147
  //
134
- // Positioned as the very last block of the system prompt (after memory)
135
- // because it changes on every session creation, which is more frequent
136
- // than any other section: memory changes per dreaming/memory-logger cycle,
137
- // gitNudge changes per session, but `now` changes per second. Pinning it
138
- // to the tail means every byte UP TO this block stays in the provider's
139
- // cache prefix across session resurrections, and only the trailing ~60
140
- // bytes invalidate.
148
+ // Why this still respects the prompt cache: the user turn is the only
149
+ // non-cacheable suffix in every provider's KV cache shape. Putting the
150
+ // anchor here invalidates exactly zero cached bytes the same bytes that
151
+ // would already be re-billed on each turn's user message so this is
152
+ // cache-free relative to the previous "## Now" placement.
141
153
  //
142
- // The model still needs to know this is a session-creation snapshot, not
143
- // a live clock: long-lived channel sessions can outlive the stamp by
144
- // hours, and the resource loader is not re-rendered per turn (see the
145
- // CreateSessionOptions doc at the top of src/agent/index.ts). The prose
146
- // names the snapshot semantics and tells the model how to get a fresh
147
- // reading when it matters (run `date` via bash).
148
- export function renderNowBlock(now: Date): string {
154
+ // The block emits both English and Korean weekday names alongside the ISO
155
+ // timestamp because models replying in a non-English language frequently
156
+ // compute weekday-from-ISO incorrectly; pre-computing the weekday in both
157
+ // candidate reply languages removes that arithmetic step entirely. The
158
+ // framing is a single `<current-time>` XML tag for parity with other
159
+ // runtime-injected per-turn blocks the agent already sees
160
+ // (`<system-reminder>` etc.), so the model reads it as a structured anchor
161
+ // rather than as content authored by a human in the chat.
162
+ export function renderTurnTimeAnchor(now: Date = new Date()): string {
149
163
  const iso = formatLocalDateTime(now)
150
164
  const zone = resolveLocalTimezoneName()
151
- return `## Now
152
-
153
- Session started at \`${iso}\` (${zone}). This is a session-creation snapshot, not a live clock — the value above does not advance during this session. If you need the current wall-clock time precisely (e.g. before scheduling a cron, replying with "it's 3pm", or computing a deadline), run \`date\` via bash instead of trusting this stamp; the container's timezone is set to the host's, so \`date\` returns the user's local time.`
165
+ const weekday = formatLocalWeekday(now)
166
+ return `<current-time>${iso} (${zone}, ${weekday.en} / ${weekday.ko})</current-time>`
154
167
  }
155
168
 
156
169
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
@@ -8,9 +8,13 @@ import type { ChannelRouter } from '@/channels/router'
8
8
  import type { AdapterId } from '@/channels/schema'
9
9
 
10
10
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
11
+ import { normalizeRef } from './normalize-ref'
11
12
 
12
13
  export type ChannelFetchAttachmentOrigin = {
13
14
  adapter: AdapterId
15
+ workspace: string
16
+ chat: string
17
+ thread: string | null
14
18
  }
15
19
 
16
20
  export type CreateChannelFetchAttachmentToolOptions = {
@@ -34,18 +38,16 @@ export function createChannelFetchAttachmentTool({
34
38
  name: 'channel_fetch_attachment',
35
39
  label: 'Channel Fetch Attachment',
36
40
  description:
37
- 'Download a file the user attached to the inbound channel message and save it to disk. Inbound channel ' +
38
- 'messages with uploads carry a `[<Platform> message with attachment: <name> (<mime>) <ref>]` summary pass ' +
39
- "the literal `<ref>` value as `ref`. For Slack the ref looks like `id=Fxxxx` (use `Fxxxx`); for Discord it's " +
40
- 'the full `https://cdn.discordapp.com/...` URL. The tool authenticates with the channel adapter (Slack ' +
41
- 'url_private requires the bot token; Discord CDN URLs are signed and expire ~24h, so fetch promptly). On ' +
42
- 'success returns the absolute path of the saved file plus its detected mimetype and size. On failure returns ' +
43
- 'the upstream error verbatim.',
41
+ 'Download a file the user attached to the current inbound channel message and save it to disk. Inbound channel ' +
42
+ 'messages with attachments show `[<Platform> attachment #N: <kind> <metadata>]` in the text. Pass `N` as ' +
43
+ '`attachment_id`; do not invent ids that are not present in the inbound message. The router validates the id ' +
44
+ 'against the current turn and resolves the private platform ref itself. On success returns the absolute path ' +
45
+ 'of the saved file plus its detected mimetype and size.',
44
46
  parameters: Type.Object({
45
- ref: Type.String({
47
+ attachment_id: Type.Integer({
46
48
  description:
47
- 'Slack: the file id `Fxxxx` (with or without the `id=` prefix). Discord: the full `https://cdn.discordapp.com/...` or `https://media.discordapp.net/...` URL.',
48
- minLength: 1,
49
+ 'The number N from the inbound `[<Platform> attachment #N: ...]` placeholder. Must be present in this turn.',
50
+ minimum: 1,
49
51
  }),
50
52
  filename: Type.Optional(
51
53
  Type.String({
@@ -58,10 +60,38 @@ export function createChannelFetchAttachmentTool({
58
60
 
59
61
  async execute(_toolCallId, params) {
60
62
  type Details = { ok: boolean; error?: string; path?: string; mimetype?: string; size?: number }
61
- const ref = normalizeRef(params.ref)
63
+ const found = router.lookupInboundAttachment({
64
+ adapter,
65
+ workspace: origin.workspace,
66
+ chat: origin.chat,
67
+ thread: origin.thread,
68
+ id: params.attachment_id,
69
+ })
70
+ if (found === null) {
71
+ const validIds = router.listInboundAttachmentIds({
72
+ adapter,
73
+ workspace: origin.workspace,
74
+ chat: origin.chat,
75
+ thread: origin.thread,
76
+ })
77
+ const validMsg =
78
+ validIds.length === 0
79
+ ? 'no attachments are present in the current turn'
80
+ : `valid attachment_ids in this turn: ${validIds.join(', ')}`
81
+ return errorResult(
82
+ `no attachment with id=${params.attachment_id} in this turn (${validMsg}). Do not call channel_fetch_attachment for attachments that do not appear in the inbound message — they do not exist.`,
83
+ )
84
+ }
85
+ if (found.ref === '') {
86
+ return errorResult(
87
+ `attachment #${params.attachment_id} (${found.kind}) has no fetchable ref — likely a sticker or an upstream payload without a public URL. Acknowledge the user but do not promise to view it.`,
88
+ )
89
+ }
90
+ const ref = normalizeRef(found.ref)
91
+ const filename = params.filename ?? found.filename
62
92
  const result = await router.fetchAttachment(adapter, {
63
93
  ref,
64
- ...(params.filename !== undefined ? { filename: params.filename } : {}),
94
+ ...(filename !== undefined ? { filename } : {}),
65
95
  })
66
96
  if (!result.ok) {
67
97
  logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: ${result.error}`))
@@ -98,10 +128,9 @@ export function createChannelFetchAttachmentTool({
98
128
  })
99
129
  }
100
130
 
101
- function normalizeRef(ref: string): string {
102
- const trimmed = ref.trim()
103
- if (trimmed.startsWith('id=')) return trimmed.slice(3)
104
- return trimmed
131
+ function errorResult(message: string) {
132
+ const details = { ok: false, error: message }
133
+ return { content: [{ type: 'text' as const, text: `channel_fetch_attachment error: ${message}` }], details }
105
134
  }
106
135
 
107
136
  const UNSAFE_FILENAME_CHARS = /[^A-Za-z0-9._-]/g
@@ -0,0 +1,11 @@
1
+ export function normalizeRef(ref: string): string {
2
+ const trimmed = ref.trim()
3
+ // New classifiers store bare Slack file ids; legacy persisted refs (and
4
+ // anything still hitting the lookup path from older contextBuffer state)
5
+ // may carry the old prompt-visible `id=Fxxxx` prefix. Strip it here so
6
+ // both attachment-fetching tools route the same ref through the adapter
7
+ // callback — without this, `channel_fetch_attachment` would silently
8
+ // succeed on a legacy ref while `look_at_channel_attachment` would fail.
9
+ if (trimmed.startsWith('id=')) return trimmed.slice(3)
10
+ return trimmed
11
+ }
@@ -39,10 +39,13 @@ export type SkipResponseDetails = {
39
39
  // `skip_response` is preferred whenever the model has a reason worth
40
40
  // recording. See session-origin.ts for the prompt-level decision rule.
41
41
  //
42
- // Order-dependence with `channel_reply`/`channel_send`: once `skip_response`
43
- // fires in a turn, the router rejects any subsequent tool-source send for
44
- // the same turn with `SKIP_RESPONSE_LOCK_ERROR`. The model gets a clear
45
- // error and learns to commit on the next turn instead of mid-turn.
42
+ // Order-dependence with `channel_reply`/`channel_send` is asymmetric:
43
+ // - skip BEFORE any send → commits to silence; the router rejects any
44
+ // subsequent tool-source send this turn with `SKIP_RESPONSE_LOCK_ERROR`.
45
+ // - skip AFTER a send accepted as a terminal no-op (`recorded-after-send`).
46
+ // The earlier reply stands; this posts nothing and ends the turn. Rejecting
47
+ // it (the old behavior) drove a livelock: denied a clean silent exit, the
48
+ // model re-sent, got re-denied on the next skip, and repeated to the cap.
46
49
  export function createSkipResponseTool({
47
50
  router,
48
51
  sessionId,
@@ -55,12 +58,14 @@ export function createSkipResponseTool({
55
58
  'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
56
59
  'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
57
60
  'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
58
- 'never delivered to the user. The contract is bidirectional: after calling this, any ' +
59
- '`channel_reply` / `channel_send` in the same turn will be rejected, AND calling this ' +
60
- 'after a `channel_reply` / `channel_send` has already landed in this turn will also ' +
61
- 'be rejected commit to silence or commit to replying, not both. Decide before you ' +
62
- 'send, and call this as your terminal tool when you decide to stay silent. Prefer ' +
63
- 'this over the `NO_REPLY` text sentinel whenever you have a reason worth recording.',
61
+ 'never delivered to the user. If you call this BEFORE sending anything this turn, it ' +
62
+ 'commits you to silence and any later `channel_reply` / `channel_send` in the same ' +
63
+ 'turn is rejected. If you call it AFTER a reply has already landed this turn (e.g. you ' +
64
+ 'posted an ack and now want to wait quietly for a backgrounded subagent), it is ' +
65
+ 'accepted as a terminal no-op: your earlier reply stands, nothing further is sent, and ' +
66
+ 'your turn ends. Either way, call this as your terminal tool when you decide to stop ' +
67
+ 'talking — do NOT keep sending "still working" updates. Prefer this over the ' +
68
+ '`NO_REPLY` text sentinel whenever you have a reason worth recording.',
64
69
  parameters: Type.Object({
65
70
  reason: Type.String({
66
71
  description:
@@ -85,33 +90,20 @@ export function createSkipResponseTool({
85
90
  }
86
91
 
87
92
  const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
88
- if (result.kind === 'send-already-happened') {
89
- // Symmetric counterpart of the send-after-skip lock in `router.send()`.
90
- // The model already committed to replying earlier in this turn; calling
91
- // skip_response now would land the reply AND claim silence at the same
92
- // time, which is the contract violation the lock exists to prevent.
93
- // Surface a clear error and refuse to stamp the flag so the rest of
94
- // the turn behaves as a normal reply turn.
95
- logger.warn(
96
- formatChannelToolFailure(
97
- 'skip_response',
98
- `channel send already happened this turn (reason=${JSON.stringify(reason)})`,
99
- ),
100
- )
101
- const details: SkipResponseDetails = {
102
- ok: false,
103
- suppressed: false,
104
- reason,
105
- error: 'send-already-happened',
106
- }
93
+ if (result.kind === 'recorded-after-send') {
94
+ // Reply-first skip: an ack already landed; this just ends the turn
95
+ // quietly. Not suppressed (the reply stands) and not an error (erroring
96
+ // here is what drove the historical re-send livelock). Router logged it.
97
+ const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
107
98
  return {
108
99
  content: [
109
100
  {
110
101
  type: 'text' as const,
111
102
  text:
112
- 'skip_response denied: you already sent a channel reply in this turn. ' +
113
- 'Commit to silence or commit to replying, not both. ' +
114
- 'End your turn now; the reply you already sent stands.',
103
+ 'skip_response accepted: your earlier channel reply this turn stands, and ' +
104
+ 'no further message will be sent. End your turn now — do not send "still ' +
105
+ 'working" updates while a backgrounded subagent runs; the completion ' +
106
+ 'reminder will wake you when it finishes.',
115
107
  },
116
108
  ],
117
109
  details,
@@ -103,6 +103,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
103
103
  if (params.description !== undefined) payload.description = params.description
104
104
 
105
105
  const startedAt = now()
106
+ const spawnedByRole = permissions?.resolveRole(origin)
106
107
  const { handle, completion } = startSubagent(subagentName, {
107
108
  registry,
108
109
  createSessionForSubagent,
@@ -110,6 +111,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
110
111
  userPrompt: params.prompt,
111
112
  payload: subagent.payloadSchema ? payload : undefined,
112
113
  parentSessionId,
114
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
113
115
  ...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
114
116
  taskId,
115
117
  })
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { createReviewerSubagent } from './reviewer'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ subagents: {
8
+ reviewer: createReviewerSubagent(),
9
+ },
10
+ }),
11
+ })