typeclaw 0.33.0 → 0.34.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 (61) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  15. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  16. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  17. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
  18. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  19. package/src/channels/adapters/line-classify.ts +80 -0
  20. package/src/channels/adapters/line-format.ts +11 -0
  21. package/src/channels/adapters/line.ts +350 -0
  22. package/src/channels/engagement.ts +4 -2
  23. package/src/channels/manager.ts +65 -6
  24. package/src/channels/router.ts +186 -41
  25. package/src/channels/schema.ts +6 -1
  26. package/src/cli/channel.ts +112 -1
  27. package/src/cli/cron.ts +22 -4
  28. package/src/cli/oauth-callbacks.ts +5 -4
  29. package/src/config/providers.ts +62 -0
  30. package/src/cron/consumer.ts +33 -0
  31. package/src/cron/count-state.ts +208 -0
  32. package/src/cron/index.ts +4 -17
  33. package/src/cron/list.ts +24 -6
  34. package/src/cron/scheduler.ts +84 -9
  35. package/src/cron/schema.ts +100 -13
  36. package/src/doctor/channel-checks.ts +28 -0
  37. package/src/hostd/daemon.ts +14 -6
  38. package/src/hostd/protocol.ts +6 -2
  39. package/src/init/gitignore.ts +1 -1
  40. package/src/init/index.ts +36 -3
  41. package/src/init/line-auth.ts +98 -0
  42. package/src/init/models-dev.ts +1 -0
  43. package/src/init/run-owner-claim.ts +1 -0
  44. package/src/init/validate-api-key.ts +2 -0
  45. package/src/inspect/label.ts +1 -0
  46. package/src/permissions/match-rule.ts +28 -12
  47. package/src/permissions/resolve.ts +8 -1
  48. package/src/role-claim/match-rule.ts +5 -1
  49. package/src/run/index.ts +41 -4
  50. package/src/secrets/line-store.ts +112 -0
  51. package/src/secrets/oauth-xai.ts +1 -1
  52. package/src/secrets/schema.ts +25 -0
  53. package/src/server/index.ts +17 -4
  54. package/src/shared/protocol.ts +4 -1
  55. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  56. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  57. package/src/skills/typeclaw-config/SKILL.md +54 -184
  58. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  59. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  60. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  61. package/typeclaw.schema.json +167 -3
package/auth.schema.json CHANGED
@@ -276,6 +276,72 @@
276
276
  }
277
277
  }
278
278
  },
279
+ "line": {
280
+ "type": "object",
281
+ "properties": {
282
+ "currentAccount": {
283
+ "anyOf": [
284
+ {
285
+ "type": "string"
286
+ },
287
+ {
288
+ "type": "null"
289
+ }
290
+ ]
291
+ },
292
+ "accounts": {
293
+ "type": "object",
294
+ "propertyNames": {
295
+ "type": "string"
296
+ },
297
+ "additionalProperties": {
298
+ "type": "object",
299
+ "properties": {
300
+ "account_id": {
301
+ "type": "string"
302
+ },
303
+ "auth_token": {
304
+ "type": "string"
305
+ },
306
+ "certificate": {
307
+ "type": "string"
308
+ },
309
+ "device": {
310
+ "type": "string",
311
+ "enum": [
312
+ "DESKTOPWIN",
313
+ "DESKTOPMAC",
314
+ "ANDROID",
315
+ "ANDROIDSECONDARY",
316
+ "IOS",
317
+ "IOSIPAD"
318
+ ]
319
+ },
320
+ "display_name": {
321
+ "type": "string"
322
+ },
323
+ "created_at": {
324
+ "type": "string"
325
+ },
326
+ "updated_at": {
327
+ "type": "string"
328
+ }
329
+ },
330
+ "required": [
331
+ "account_id",
332
+ "auth_token",
333
+ "device",
334
+ "created_at",
335
+ "updated_at"
336
+ ]
337
+ }
338
+ }
339
+ },
340
+ "required": [
341
+ "currentAccount",
342
+ "accounts"
343
+ ]
344
+ },
279
345
  "kakaotalk": {
280
346
  "type": "object",
281
347
  "properties": {
package/cron.schema.json CHANGED
@@ -22,6 +22,19 @@
22
22
  "type": "string",
23
23
  "minLength": 1
24
24
  },
25
+ "at": {
26
+ "type": "string",
27
+ "minLength": 1
28
+ },
29
+ "until": {
30
+ "type": "string",
31
+ "minLength": 1
32
+ },
33
+ "count": {
34
+ "type": "integer",
35
+ "exclusiveMinimum": 0,
36
+ "maximum": 9007199254740991
37
+ },
25
38
  "enabled": {
26
39
  "default": true,
27
40
  "type": "boolean"
@@ -49,7 +62,6 @@
49
62
  },
50
63
  "required": [
51
64
  "id",
52
- "schedule",
53
65
  "kind",
54
66
  "prompt"
55
67
  ]
@@ -66,6 +78,19 @@
66
78
  "type": "string",
67
79
  "minLength": 1
68
80
  },
81
+ "at": {
82
+ "type": "string",
83
+ "minLength": 1
84
+ },
85
+ "until": {
86
+ "type": "string",
87
+ "minLength": 1
88
+ },
89
+ "count": {
90
+ "type": "integer",
91
+ "exclusiveMinimum": 0,
92
+ "maximum": 9007199254740991
93
+ },
69
94
  "enabled": {
70
95
  "default": true,
71
96
  "type": "boolean"
@@ -92,7 +117,6 @@
92
117
  },
93
118
  "required": [
94
119
  "id",
95
- "schedule",
96
120
  "kind",
97
121
  "command"
98
122
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -276,6 +276,72 @@
276
276
  }
277
277
  }
278
278
  },
279
+ "line": {
280
+ "type": "object",
281
+ "properties": {
282
+ "currentAccount": {
283
+ "anyOf": [
284
+ {
285
+ "type": "string"
286
+ },
287
+ {
288
+ "type": "null"
289
+ }
290
+ ]
291
+ },
292
+ "accounts": {
293
+ "type": "object",
294
+ "propertyNames": {
295
+ "type": "string"
296
+ },
297
+ "additionalProperties": {
298
+ "type": "object",
299
+ "properties": {
300
+ "account_id": {
301
+ "type": "string"
302
+ },
303
+ "auth_token": {
304
+ "type": "string"
305
+ },
306
+ "certificate": {
307
+ "type": "string"
308
+ },
309
+ "device": {
310
+ "type": "string",
311
+ "enum": [
312
+ "DESKTOPWIN",
313
+ "DESKTOPMAC",
314
+ "ANDROID",
315
+ "ANDROIDSECONDARY",
316
+ "IOS",
317
+ "IOSIPAD"
318
+ ]
319
+ },
320
+ "display_name": {
321
+ "type": "string"
322
+ },
323
+ "created_at": {
324
+ "type": "string"
325
+ },
326
+ "updated_at": {
327
+ "type": "string"
328
+ }
329
+ },
330
+ "required": [
331
+ "account_id",
332
+ "auth_token",
333
+ "device",
334
+ "created_at",
335
+ "updated_at"
336
+ ]
337
+ }
338
+ }
339
+ },
340
+ "required": [
341
+ "currentAccount",
342
+ "accounts"
343
+ ]
344
+ },
279
345
  "kakaotalk": {
280
346
  "type": "object",
281
347
  "properties": {
@@ -67,6 +67,7 @@ import {
67
67
  wrapAgentToolWithBudget,
68
68
  wrapToolDefinitionWithBudget,
69
69
  } from './tool-result-budget'
70
+ import { createChannelDisengageTool } from './tools/channel-disengage'
70
71
  import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
71
72
  import { createChannelHistoryTool } from './tools/channel-history'
72
73
  import { createChannelReactTool } from './tools/channel-react'
@@ -621,9 +622,11 @@ export function formatRestartNoticeOriginating(restartedAt: string): string {
621
622
  }
622
623
 
623
624
  // Builds the channel tool subset: channel_send (always when a router is
624
- // available), plus channel_reply + channel_history + skip_response (only
625
- // when the session origin is a channel — those rely on origin-bound
626
- // addressing or per-session turn state). Extracted from
625
+ // available), plus the origin-bound channel tools when the session origin is
626
+ // a channel — channel_reply, channel_history, channel_react,
627
+ // channel_fetch_attachment, look_at_channel_attachment, channel_disengage, and
628
+ // (when sessionId is known) skip_response. Those rely on origin-bound
629
+ // addressing or per-session turn state. Extracted from
627
630
  // createSessionWithDispose so composition can be unit-tested without
628
631
  // going through createAgentSession / auth.
629
632
  //
@@ -683,6 +686,7 @@ export function buildChannelTools(
683
686
  }),
684
687
  )
685
688
  tools.push(createChannelLookAtTool(channelRouter, channelOrigin))
689
+ tools.push(createChannelDisengageTool({ router: channelRouter, origin: channelOrigin }))
686
690
  if (sessionId !== undefined) {
687
691
  tools.push(createSkipResponseTool({ router: channelRouter, sessionId }))
688
692
  }
@@ -156,6 +156,7 @@ const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
156
156
  supportsReactions: false,
157
157
  supportsAttachments: true,
158
158
  },
159
+ line: { displayName: 'LINE', mentionMode: 'alias', supportsReactions: false, supportsAttachments: false },
159
160
  kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias', supportsReactions: false, supportsAttachments: true },
160
161
  }
161
162
 
@@ -425,6 +426,22 @@ function renderChannelOrigin(
425
426
  ' have no reason worth recording. Any other visible text without a',
426
427
  ' channel tool call is blocked.',
427
428
  '',
429
+ 'Both of the above silence only the CURRENT turn. To stop being pulled',
430
+ 'back into FUTURE turns, use the engagement tool below.',
431
+ '',
432
+ '- **`channel_disengage()`** — drop "mid-conversation" stickiness for this',
433
+ ' conversation. After you reply to someone, their next message re-engages',
434
+ ' you without an @mention, and that is renewed on every reply — so in a',
435
+ ' busy group you can get stuck answering turn after turn even after being',
436
+ ' told to stop. Call this when a human or peer bot asks you to be quiet /',
437
+ ' stop replying, or when you notice you are in a redundant loop. After',
438
+ ' disengaging you only re-engage when explicitly addressed again (mention,',
439
+ ' reply, or DM). It sends no message and does not affect other channels.',
440
+ ' ORDER MATTERS: if you want to ack ("ok, backing off") before going quiet,',
441
+ " send that `channel_reply` FIRST, THEN call `channel_disengage` — it's the",
442
+ ' natural terminal action for the turn. Pair it with `skip_response` when',
443
+ ' you also want to stay silent this turn.',
444
+ '',
428
445
  '**Every user-facing sentence goes through `channel_reply`.** Narrating in',
429
446
  'plain text — "bumping to 16x now", "let me check that" — does NOT reach the',
430
447
  'user; it is invisible. If you want the user to see it, it is a',
@@ -15,6 +15,7 @@ export type CompletionReminderArgs = {
15
15
  ok: boolean
16
16
  durationMs: number
17
17
  error?: string
18
+ hasRecoverableOutput?: boolean
18
19
  channel?: boolean
19
20
  adapter?: string
20
21
  }
@@ -55,10 +56,14 @@ export function renderSubagentCompletionReminder(args: CompletionReminderArgs):
55
56
  )
56
57
  }
57
58
  const err = args.error ?? 'unknown error'
59
+ const recoveryHint =
60
+ args.hasRecoverableOutput === true
61
+ ? `It produced output before failing — call subagent_output to recover it instead of redoing the work. `
62
+ : `Use subagent_output to inspect. `
58
63
  return (
59
64
  `<system-reminder>\n` +
60
65
  `Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
61
- `Use subagent_output to inspect. If this work was tracked in your todo list, ` +
66
+ `${recoveryHint}If this work was tracked in your todo list, ` +
62
67
  `keep the item pending (or add a recovery item) via todo_write so it is not ` +
63
68
  `dropped.${channelTail}\n` +
64
69
  `</system-reminder>`
@@ -88,6 +93,12 @@ export type SubagentCompletedPayload = {
88
93
  ok: boolean
89
94
  durationMs: number
90
95
  error?: string
96
+ // A failed subagent can still carry a recoverable result (e.g. a researcher
97
+ // that produced its `<report>` then timed out). The boolean — not the content
98
+ // — rides the broadcast so the reminder can tell the parent to fetch it via
99
+ // subagent_output; the body stays in the registry retrieval path, off the
100
+ // broadcast bus, to keep large/sensitive output out of every subscriber.
101
+ hasRecoverableOutput?: boolean
91
102
  // Present when the parent was a channel session. Lets the router fall back
92
103
  // to the live successor session for the same channel key when the parent
93
104
  // rolled over (SESSION_FRESHNESS_TTL_MS) or was idle-evicted while the
@@ -109,6 +120,7 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
109
120
  ok?: unknown
110
121
  durationMs?: unknown
111
122
  error?: unknown
123
+ hasRecoverableOutput?: unknown
112
124
  channelKey?: unknown
113
125
  }
114
126
  if (p.kind !== 'subagent.completed') return null
@@ -121,6 +133,7 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
121
133
  ok: p.ok === true,
122
134
  durationMs: typeof p.durationMs === 'number' ? p.durationMs : 0,
123
135
  ...(typeof p.error === 'string' ? { error: p.error } : {}),
136
+ ...(p.hasRecoverableOutput === true ? { hasRecoverableOutput: true } : {}),
124
137
  ...(channelKey !== null ? { channelKey } : {}),
125
138
  }
126
139
  }
@@ -71,12 +71,14 @@ function collectPendingReminders(drain: SubagentBackgroundDrain, delivered: Set<
71
71
  if (child.status === 'running') continue
72
72
  if (delivered.has(child.taskId)) continue
73
73
  const completion = child.completion
74
+ const hasRecoverableOutput = child.status !== 'completed' && completion?.finalMessage !== undefined
74
75
  const text = renderSubagentCompletionReminder({
75
76
  subagent: child.subagentName,
76
77
  taskId: child.taskId,
77
78
  ok: child.status === 'completed',
78
79
  durationMs: completion?.durationMs ?? 0,
79
80
  ...(completion?.error !== undefined ? { error: completion.error } : {}),
81
+ ...(hasRecoverableOutput ? { hasRecoverableOutput: true } : {}),
80
82
  })
81
83
  pending.push({ taskId: child.taskId, text })
82
84
  }
@@ -360,7 +360,7 @@ export type SubagentHandle = {
360
360
 
361
361
  export type StartSubagentResult = {
362
362
  handle: Promise<SubagentHandle>
363
- completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string }>
363
+ completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string }>
364
364
  }
365
365
 
366
366
  export type StartSubagentOptions = InvokeSubagentOptions & {
@@ -427,7 +427,8 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
427
427
  })
428
428
 
429
429
  const timeoutMs = options.registry[name]?.timeoutMs
430
- const completion = timeoutMs === undefined ? work : raceSubagentCompletion(work, name, options.taskId, timeoutMs)
430
+ const completion =
431
+ timeoutMs === undefined ? work : raceSubagentCompletion(work, name, options.taskId, timeoutMs, () => finalMessage)
431
432
 
432
433
  void completion.then(() => {
433
434
  if (timeoutMs !== undefined) void abortSession?.()
@@ -436,20 +437,33 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
436
437
  return { handle, completion }
437
438
  }
438
439
 
439
- type SubagentCompletion = { ok: true; finalMessage?: string } | { ok: false; error: string }
440
+ type SubagentCompletion = { ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string }
440
441
 
442
+ // `getFinalMessage` is read INSIDE the timeout callback, not at race-construction
443
+ // time, so the timed-out result carries whatever the subagent had captured by the
444
+ // moment the timer fired (e.g. a researcher's `<report>` block emitted just before
445
+ // the kill). JS is single-threaded, so this read is torn-free; it preserves only
446
+ // what an assistant `message_end` already committed — a result still mid-stream
447
+ // when the timer fires cannot be recovered. The outcome stays `ok: false`: a
448
+ // timeout is a lifecycle failure, and `finalMessage` here is recovery data for
449
+ // the parent to inspect/re-persist, not proof the subagent honored its contract.
441
450
  function raceSubagentCompletion(
442
451
  work: Promise<SubagentCompletion>,
443
452
  name: string,
444
453
  taskId: string,
445
454
  timeoutMs: number,
455
+ getFinalMessage: () => string | undefined,
446
456
  ): Promise<SubagentCompletion> {
447
457
  let timer: ReturnType<typeof setTimeout> | null = null
448
458
  const timeout = new Promise<SubagentCompletion>((resolve) => {
449
- timer = setTimeout(
450
- () => resolve({ ok: false, error: new SubagentTimeoutError(name, taskId, timeoutMs).message }),
451
- timeoutMs,
452
- )
459
+ timer = setTimeout(() => {
460
+ const finalMessage = getFinalMessage()
461
+ resolve({
462
+ ok: false,
463
+ error: new SubagentTimeoutError(name, taskId, timeoutMs).message,
464
+ ...(finalMessage !== undefined ? { finalMessage } : {}),
465
+ })
466
+ }, timeoutMs)
453
467
  })
454
468
  return Promise.race([work, timeout]).finally(() => {
455
469
  if (timer !== null) clearTimeout(timer)
@@ -0,0 +1,66 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { ChannelRouter } from '@/channels/router'
5
+ import type { AdapterId } from '@/channels/schema'
6
+
7
+ export type ChannelDisengageOrigin = {
8
+ adapter: AdapterId
9
+ workspace: string
10
+ chat: string
11
+ thread: string | null
12
+ }
13
+
14
+ export type CreateChannelDisengageToolOptions = {
15
+ router: ChannelRouter
16
+ origin: ChannelDisengageOrigin
17
+ }
18
+
19
+ export type ChannelDisengageDetails = {
20
+ ok: boolean
21
+ cleared: number
22
+ }
23
+
24
+ // `channel_disengage` drops the "we're mid-conversation" sticky credits for the
25
+ // current channel/thread. Stickiness force-engages the bot on a participant's
26
+ // follow-up even without an @mention, and every reply re-grants a fresh credit —
27
+ // so in a busy group the bot keeps answering turn after turn, even after a human
28
+ // or peer bot asks it to stop. Calling this returns the bot to strict
29
+ // mention/reply/dm engagement for this conversation until someone addresses it
30
+ // again. Use it as a clean exit when you've been told to back off or when you
31
+ // notice you're stuck in a redundant back-and-forth.
32
+ export function createChannelDisengageTool({ router, origin }: CreateChannelDisengageToolOptions) {
33
+ return defineTool({
34
+ name: 'channel_disengage',
35
+ label: 'Channel Disengage',
36
+ description:
37
+ 'Stop auto-engaging on follow-up messages in THIS channel/thread. While engaged you ' +
38
+ "keep replying to a participant's next message without an @mention, and that " +
39
+ 'engagement is renewed every time you reply — so in a group you can get stuck ' +
40
+ 'answering turn after turn even after someone tells you to stop. Call this when a ' +
41
+ 'human or peer bot asks you to be quiet / stop replying, or when you realize you are ' +
42
+ 'in a redundant loop. After disengaging, you only re-engage in this conversation when ' +
43
+ 'explicitly addressed again (mention, reply, or DM). This does not send any message ' +
44
+ 'and does not affect other channels. Pair it with skip_response when you also want to ' +
45
+ 'stay silent on the current turn.',
46
+ parameters: Type.Object({}),
47
+
48
+ async execute() {
49
+ const result = router.clearSticky({
50
+ adapter: origin.adapter,
51
+ workspace: origin.workspace,
52
+ chat: origin.chat,
53
+ thread: origin.thread,
54
+ })
55
+ const details: ChannelDisengageDetails = { ok: true, cleared: result.cleared }
56
+ const summary =
57
+ result.cleared > 0
58
+ ? `Disengaged from this conversation (${result.cleared} active engagement${result.cleared === 1 ? '' : 's'} dropped). You will only re-engage here when explicitly addressed again.`
59
+ : 'You were not auto-engaged in this conversation; nothing to drop. You already only engage here when explicitly addressed.'
60
+ return {
61
+ content: [{ type: 'text' as const, text: summary }],
62
+ details,
63
+ }
64
+ },
65
+ })
66
+ }
@@ -1,7 +1,8 @@
1
1
  // Shared logger surface for the channel_* agent tools.
2
2
  //
3
- // Until now, channel_send / channel_reply / channel_history /
4
- // channel_fetch_attachment swallowed every failure into the model-visible
3
+ // Until now, the channel tools (channel_send / channel_reply /
4
+ // channel_history / channel_fetch_attachment / channel_disengage / ...)
5
+ // swallowed every failure into the model-visible
5
6
  // tool result and emitted nothing to the container's stdout/stderr. That
6
7
  // made operator-side debugging blind: a Slack send that 403'd, a
7
8
  // `thread-scope-requires-thread-session` denial, or a Discord attachment
@@ -29,7 +29,7 @@ export type SpawnSubagentToolDetails =
29
29
  taskId: string
30
30
  sessionId: string | undefined
31
31
  }
32
- | { ok: false; error: string }
32
+ | { ok: false; error: string; finalMessage?: string }
33
33
 
34
34
  export type CreateSpawnSubagentToolOptions = {
35
35
  registry: SubagentRegistry
@@ -167,6 +167,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
167
167
  const durationMs = now() - startedAt
168
168
  liveRegistry.recordCompletion(taskId, completionToFinalShape(c, durationMs))
169
169
  if (stream && background) {
170
+ const hasRecoverableOutput = !c.ok && c.finalMessage !== undefined
170
171
  stream.publish({
171
172
  target: { kind: 'broadcast' },
172
173
  payload: {
@@ -177,6 +178,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
177
178
  ok: c.ok,
178
179
  durationMs,
179
180
  ...(c.ok ? {} : { error: c.error }),
181
+ ...(hasRecoverableOutput ? { hasRecoverableOutput: true } : {}),
180
182
  ...(channelKey !== undefined ? { channelKey } : {}),
181
183
  },
182
184
  })
@@ -205,9 +207,22 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
205
207
  const result = await completion
206
208
  const durationMs = now() - startedAt
207
209
  if (!result.ok) {
208
- const details: SpawnSubagentToolDetails = { ok: false, error: result.error }
210
+ const details: SpawnSubagentToolDetails = {
211
+ ok: false,
212
+ error: result.error,
213
+ ...(result.finalMessage !== undefined ? { finalMessage: result.finalMessage } : {}),
214
+ }
215
+ const recovered =
216
+ result.finalMessage !== undefined
217
+ ? ` It produced output before failing; recover it below instead of redoing the work:\n\n${result.finalMessage}`
218
+ : ''
209
219
  return {
210
- content: [{ type: 'text' as const, text: `${subagentName} failed after ${durationMs}ms: ${result.error}` }],
220
+ content: [
221
+ {
222
+ type: 'text' as const,
223
+ text: `${subagentName} failed after ${durationMs}ms: ${result.error}.${recovered}`,
224
+ },
225
+ ],
211
226
  details,
212
227
  }
213
228
  }
@@ -312,13 +327,18 @@ function hasPermissionForSubagent(
312
327
  }
313
328
 
314
329
  function completionToFinalShape(
315
- c: { ok: true; finalMessage?: string } | { ok: false; error: string },
330
+ c: { ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string },
316
331
  durationMs: number,
317
332
  ): SubagentCompletion {
318
333
  if (c.ok) {
319
334
  return { ok: true, durationMs, ...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}) }
320
335
  }
321
- return { ok: false, error: c.error, durationMs }
336
+ return {
337
+ ok: false,
338
+ error: c.error,
339
+ durationMs,
340
+ ...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}),
341
+ }
322
342
  }
323
343
 
324
344
  type ToolReturn = {
@@ -37,6 +37,7 @@ export type SubagentOutputToolDetails =
37
37
  subagent: string
38
38
  durationMs: number
39
39
  error: string
40
+ finalMessage?: string
40
41
  }
41
42
  | { ok: false; error: string }
42
43
 
@@ -137,6 +138,7 @@ function renderSnapshot(snap: StatusSnapshot): ToolReturn {
137
138
  }
138
139
  }
139
140
  const error = snap.completion?.error ?? 'unknown error'
141
+ const finalMessage = snap.completion?.finalMessage
140
142
  const details: SubagentOutputToolDetails = {
141
143
  ok: true,
142
144
  status: 'failed',
@@ -144,9 +146,19 @@ function renderSnapshot(snap: StatusSnapshot): ToolReturn {
144
146
  subagent: snap.subagentName,
145
147
  durationMs: snap.completion?.durationMs ?? snap.elapsedMs,
146
148
  error,
149
+ ...(finalMessage !== undefined ? { finalMessage } : {}),
147
150
  }
151
+ const recovered =
152
+ finalMessage !== undefined
153
+ ? ` It produced output before failing; recover it below instead of redoing the work:\n\n${finalMessage}`
154
+ : ''
148
155
  return {
149
- content: [{ type: 'text' as const, text: `${snap.subagentName} failed after ${details.durationMs}ms: ${error}` }],
156
+ content: [
157
+ {
158
+ type: 'text' as const,
159
+ text: `${snap.subagentName} failed after ${details.durationMs}ms: ${error}.${recovered}`,
160
+ },
161
+ ],
150
162
  details,
151
163
  }
152
164
  }
@@ -71,7 +71,7 @@ function validateManagedContent(file: ManagedFile, content: string): { ok: true
71
71
  const result = parseConfigJson(content, { migrate: false })
72
72
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
73
73
  }
74
- const result = parseCronJson(content)
74
+ const result = parseCronJson(content, { mode: 'edit' })
75
75
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
76
76
  }
77
77
 
@@ -307,6 +307,13 @@ export function createMemoryLoggerSubagent(
307
307
  const logger = options.logger ?? consoleLogger
308
308
  return {
309
309
  systemPrompt: MEMORY_LOGGER_SYSTEM_PROMPT,
310
+ // Logging is "read transcript past the watermark, decide 0-N fragments,
311
+ // append" — mechanical extraction, no deep reasoning. Without this it fell
312
+ // back to `default`, sharing the slow reasoning model that a concurrent
313
+ // `researcher` pass saturates, which made the 50s spawn timeout fire under
314
+ // load. `fast` matches `memory-retrieval` (same I/O-bound shape) and itself
315
+ // falls back to `default` with a one-time warning when unconfigured.
316
+ profile: 'fast',
310
317
  tools: [readTool],
311
318
  customTools: [findEntryTool, appendTool, advanceWatermarkTool],
312
319
  payloadSchema: memoryLoggerPayloadSchema,
@@ -25,17 +25,20 @@ import { createWriteReportTool } from './write-report'
25
25
  // `./skills/`; no runtime change required.
26
26
  export const RESEARCHER_SKILLS: readonly LoadableSkill[] = [GENERAL_RESEARCH_SKILL]
27
27
 
28
- // Mirrors the reviewer ceiling. A researcher whose `session.prompt` stalls
29
- // mid-turn would otherwise leave `completion` pending forever the
30
- // `subagent.completed` broadcast never fires and the parent is never woken to
31
- // read the report. The ceiling makes `awaitWithSubagentTimeout` settle with
32
- // SubagentTimeoutError, surfacing a FAILED completion reminder so the request
33
- // fails loudly instead of vanishing. Sized for a thorough `deep`-model pass
34
- // (multi-source gathering, a few delegated workers, writing a report file),
35
- // well above a typical sub-minute lookup. This is liveness for the parent, not
36
- // hard cancellation: pi's `session.prompt` takes no AbortSignal, so the LLM
37
- // stream may run until the OS reaps it. See src/agent/subagents.ts `timeoutMs`.
38
- export const RESEARCHER_SPAWN_TIMEOUT_MS = 600_000
28
+ // A researcher whose `session.prompt` stalls mid-turn would otherwise leave
29
+ // `completion` pending forever and never wake the parent. This ceiling makes
30
+ // the spawn settle with SubagentTimeoutError, surfacing a completion reminder
31
+ // so the request resolves loudly instead of vanishing.
32
+ //
33
+ // 30m, not the prior 10m: a real pass spent ~2.5m composing its scout fan-out,
34
+ // ~4–7m on 4 parallel scouts, then was killed ~2s into the final `write_report`
35
+ // discarding a finished report. The `deep` profile trades speed for quality,
36
+ // so nested scout warmup + multi-source gathering + synthesis routinely exceed
37
+ // 10m. This is liveness, not hard cancellation: `session.prompt` takes no
38
+ // AbortSignal, so the stream may run until the OS reaps it. A report produced
39
+ // before the ceiling is no longer lost — see startSubagent's finalMessage
40
+ // preservation in src/agent/subagents.ts.
41
+ export const RESEARCHER_SPAWN_TIMEOUT_MS = 1_800_000
39
42
 
40
43
  // TODO(#452): Restrict the researcher's `bash` to a curated read-only allowlist
41
44
  // once per-subagent bash allowlist support lands. Today the read-only contract
@@ -55,6 +55,7 @@ const PROCESS_ENV_TARGETS: ReadonlyArray<string> = [
55
55
  'OPENAI_API_KEY',
56
56
  'ANTHROPIC_API_KEY',
57
57
  'MINIMAX_API_KEY',
58
+ 'DEEPSEEK_API_KEY',
58
59
  'GOOGLE_API_KEY',
59
60
  'GEMINI_API_KEY',
60
61
  'AWS_ACCESS_KEY_ID',