typeclaw 0.22.0 → 0.24.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/session-origin.ts +41 -2
  7. package/src/agent/subagent-completion-reminder.ts +3 -1
  8. package/src/agent/subagents.ts +44 -1
  9. package/src/agent/system-prompt.ts +4 -0
  10. package/src/agent/todo/continuation-policy.ts +242 -0
  11. package/src/agent/todo/continuation-state.ts +87 -0
  12. package/src/agent/todo/continuation-wiring.ts +113 -0
  13. package/src/agent/todo/continuation.ts +71 -0
  14. package/src/agent/todo/scope.ts +77 -0
  15. package/src/agent/todo/store.ts +98 -0
  16. package/src/agent/tool-not-found-nudge.ts +119 -0
  17. package/src/agent/tools/channel-reply.ts +51 -0
  18. package/src/agent/tools/restart.ts +11 -4
  19. package/src/agent/tools/todo/index.ts +119 -0
  20. package/src/bundled-plugins/backup/runner.ts +1 -1
  21. package/src/bundled-plugins/memory/memory-logger.ts +28 -10
  22. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  23. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  24. package/src/channels/adapters/discord-bot.ts +31 -3
  25. package/src/channels/adapters/github/inbound.ts +161 -10
  26. package/src/channels/adapters/github/index.ts +18 -0
  27. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  28. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  29. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  30. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  31. package/src/channels/adapters/slack-bot.ts +75 -8
  32. package/src/channels/adapters/telegram-bot.ts +11 -0
  33. package/src/channels/manager.ts +8 -2
  34. package/src/channels/router.ts +477 -22
  35. package/src/channels/schema.ts +20 -4
  36. package/src/channels/types.ts +95 -0
  37. package/src/cli/inspect-controller.ts +99 -0
  38. package/src/cli/inspect.ts +21 -123
  39. package/src/commands/index.ts +9 -0
  40. package/src/init/gitignore.ts +5 -2
  41. package/src/inspect/index.ts +30 -26
  42. package/src/inspect/live.ts +17 -3
  43. package/src/inspect/loop.ts +23 -17
  44. package/src/run/index.ts +60 -5
  45. package/src/sandbox/build.ts +10 -0
  46. package/src/sandbox/index.ts +2 -0
  47. package/src/sandbox/policy.ts +10 -0
  48. package/src/sandbox/writable-zones.ts +78 -0
  49. package/src/server/index.ts +118 -4
  50. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  51. package/src/skills/typeclaw-config/SKILL.md +1 -1
  52. package/src/skills/typeclaw-git/SKILL.md +1 -1
  53. package/typeclaw.schema.json +10 -0
@@ -10,8 +10,11 @@ export type StreamLiveOptions = {
10
10
  WebSocketImpl?: typeof WebSocket
11
11
  onSubscribed?: (live: boolean) => void
12
12
  onError?: (message: string) => void
13
+ connectTimeoutMs?: number
13
14
  }
14
15
 
16
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
17
+
15
18
  export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
16
19
  const WS = opts.WebSocketImpl ?? WebSocket
17
20
  const ws = new WS(opts.url)
@@ -63,9 +66,11 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
63
66
  }
64
67
  })
65
68
 
66
- // Settle on open OR on any terminal condition (error/close/abort). Resolving
67
- // false here is what unblocks the connect gate when esc aborts mid-connect
68
- // otherwise `await onOpen` would hang forever and freeze the inspect CLI.
69
+ // Settle on open OR on any terminal condition (error/close/abort/timeout).
70
+ // Resolving false on abort/close/timeout is what unblocks the connect gate —
71
+ // otherwise `await onOpen` would hang forever and freeze the inspect CLI. The
72
+ // timeout bounds Bun/websocket states that neither open nor error promptly.
73
+ let connectTimer: ReturnType<typeof setTimeout> | null = null
69
74
  const onOpen = new Promise<boolean>((resolve, reject) => {
70
75
  ws.addEventListener('open', () => resolve(true), { once: true })
71
76
  ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
@@ -74,6 +79,8 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
74
79
  if (opts.signal.aborted) resolve(false)
75
80
  else opts.signal.addEventListener('abort', () => resolve(false), { once: true })
76
81
  }
82
+ const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
83
+ connectTimer = setTimeout(() => reject(new Error('websocket connect timed out')), timeoutMs)
77
84
  })
78
85
  ws.addEventListener('close', () => {
79
86
  closed = true
@@ -109,7 +116,14 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
109
116
  opened = await onOpen
110
117
  } catch (err) {
111
118
  closed = true
119
+ try {
120
+ ws.close()
121
+ } catch {
122
+ /* ignore */
123
+ }
112
124
  throw err
125
+ } finally {
126
+ if (connectTimer !== null) clearTimeout(connectTimer)
113
127
  }
114
128
  if (!opened || closed || opts.signal?.aborted === true) return
115
129
 
@@ -1,20 +1,21 @@
1
1
  import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
2
2
 
3
- export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
- newEscSignal: () => AbortSignal
5
- // Runs after every runInspect attempt settles. The caller disarms the raw-mode
6
- // ESC listener here so the live tail releases stdin before clack re-opens the
7
- // picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
8
- // handler attached), and handing clack that flowing stream freezes the picker
9
- // on SSH/Bun pseudo-TTYs.
10
- afterEscStream?: () => void
3
+ export type TailController = {
4
+ signal: AbortSignal
5
+ intent: () => 'back' | 'exit' | null
6
+ dispose: () => void
7
+ }
8
+
9
+ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
10
+ // Builds a fresh interaction scope for ONE live-tail attempt: a new
11
+ // AbortController plus a temporary raw-mode listener. The loop disposes it
12
+ // before the picker re-opens so clack always owns a clean, non-raw stdin —
13
+ // this is what replaces the old pause/resume-same-controller model.
14
+ createTailScope: () => TailController
11
15
  }
12
16
 
13
17
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
14
18
  let sessionArg = opts.sessionIdOrPrefix
15
- // Remember the last session the user picked from the interactive picker so
16
- // an ESC-back-to-picker re-opens with that row pre-selected. The picker
17
- // receives this through the `initialSessionId` hint on its second arg.
18
19
  let lastPickedId: string | undefined
19
20
  const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
20
21
  const hint = selectOpts?.initialSessionId ?? lastPickedId
@@ -24,18 +25,23 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
24
25
  }
25
26
 
26
27
  while (true) {
27
- const escSignal = opts.newEscSignal()
28
- const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
29
- if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
30
- else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
31
-
28
+ const scope = opts.createTailScope()
32
29
  let result: RunInspectResult
33
30
  try {
31
+ const callOpts: RunInspectOptions = {
32
+ ...opts,
33
+ selectSession: wrappedSelectSession,
34
+ signal: scope.signal,
35
+ }
36
+ if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
37
+ else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
34
38
  result = await runInspect(callOpts)
35
39
  } finally {
36
- opts.afterEscStream?.()
40
+ scope.dispose()
37
41
  }
42
+
38
43
  if (!result.ok) return result
44
+ if (scope.intent() === 'exit') return result
39
45
  if (result.escToPicker !== true) return result
40
46
  sessionArg = undefined
41
47
  }
package/src/run/index.ts CHANGED
@@ -4,6 +4,7 @@ import { createSession, createSessionWithDispose } from '@/agent'
4
4
  import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
6
  import { requestContainerRestart } from '@/agent/restart'
7
+ import { consumeRestartHandoff } from '@/agent/restart-handoff'
7
8
  import type { SessionOrigin } from '@/agent/session-origin'
8
9
  import {
9
10
  awaitWithSubagentTimeout,
@@ -16,6 +17,7 @@ import {
16
17
  type SubagentRegistry,
17
18
  type SubagentShared,
18
19
  } from '@/agent/subagents'
20
+ import { clearTodosForOrigin } from '@/agent/todo/continuation-wiring'
19
21
  import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
20
22
  import {
21
23
  createChannelManager,
@@ -282,14 +284,31 @@ export async function startAgent({
282
284
  // `typeclaw run` outside Docker), the handler reports that instead of the
283
285
  // command resolving as unknown, which would make the advertised contract
284
286
  // depend on the runtime environment.
285
- onRestart: async (): Promise<string> => {
287
+ onRestart: async (ctx): Promise<string> => {
286
288
  if (containerName === undefined) {
287
289
  return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
288
290
  }
289
- // No originatingSessionId/stream/handoff: a channel-invoked restart must
290
- // not write a resume hint or fire the "I'm back" broadcast that a TUI
291
- // restart does (issue #291 scoping only TUI origins resume).
292
- const result = await requestContainerRestart({ containerName })
291
+ // When the /restart command resolved a live channel session, ctx carries
292
+ // its identity: pass stream + session id/file + channel handoffOrigin so
293
+ // the dying container appends the `typeclaw.restart-self` entry (via the
294
+ // broadcast) and writes a channel-origin handoff. On the next boot the
295
+ // channel resume path reopens that exact conversation. With no live
296
+ // session (cold channel / native slash), ctx is undefined and the
297
+ // container just bounces — the next inbound resumes pending todos.
298
+ const result = await requestContainerRestart({
299
+ containerName,
300
+ ...(ctx !== undefined
301
+ ? {
302
+ stream,
303
+ agentDir: cwd,
304
+ originatingSessionId: ctx.originatingSessionId,
305
+ ...(ctx.originatingSessionFile !== undefined
306
+ ? { originatingSessionFile: ctx.originatingSessionFile }
307
+ : {}),
308
+ handoffOrigin: ctx.handoffOrigin,
309
+ }
310
+ : {}),
311
+ })
293
312
  return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
294
313
  },
295
314
  })
@@ -434,6 +453,13 @@ export async function startAgent({
434
453
  // marker so the audit trail records "user edited cron.json".
435
454
  scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
436
455
  }
456
+ // Cron todos are per-fire ephemeral by default: each scheduled run starts
457
+ // with a clean list so an incomplete item from a prior fire cannot
458
+ // resurrect indefinitely on every tick. (A future opt-in could carry them
459
+ // forward; until then, clearing is the safe default.)
460
+ await clearTodosForOrigin(cwd, cronOrigin).catch((err) =>
461
+ console.error(`[cron] ${job.id}: clear todos failed: ${err instanceof Error ? err.message : String(err)}`),
462
+ )
437
463
  const session = await createSession({
438
464
  reloadRegistry,
439
465
  sessionManager,
@@ -507,8 +533,37 @@ export async function startAgent({
507
533
  })
508
534
 
509
535
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
536
+
537
+ // Two-phase channel restart-resume around adapter startup, to close the race
538
+ // where an adapter starts receiving before the resume claims the handoff:
539
+ // 1. Claim the channel handoff and RESERVE the originating key BEFORE
540
+ // channelManager.start(). The reservation installs a per-key gate, so an
541
+ // inbound that arrives the instant an adapter connects coalesces onto the
542
+ // resume instead of stale-rolling the mapping or creating a rival session.
543
+ // 2. start() the adapters (registers outbound callbacks the wake reply needs).
544
+ // 3. resume() the reservation: reopen the exact session and enqueue the wake
545
+ // — skipped automatically if a real inbound already coalesced in (2)→(3).
546
+ // Claims ONLY channel handoffs; tui handoffs are left on disk (peek-then-delete
547
+ // never removes an unclaimed handoff) for the websocket open handler to claim.
548
+ // Best-effort throughout: any failure leaves the todo to resume on the next inbound.
549
+ let restartReservation: ReturnType<typeof channelManager.router.reserveRestartHandoff> = null
550
+ try {
551
+ const handoff = await consumeRestartHandoff(cwd, { accept: (h) => h.origin.kind === 'channel' })
552
+ if (handoff !== null) restartReservation = channelManager.router.reserveRestartHandoff(handoff)
553
+ } catch (err) {
554
+ console.warn(`[run] channel restart-resume reserve failed: ${err instanceof Error ? err.message : err}`)
555
+ }
556
+
510
557
  await channelManager.start()
511
558
 
559
+ if (restartReservation !== null) {
560
+ try {
561
+ await restartReservation.resume()
562
+ } catch (err) {
563
+ console.warn(`[run] channel restart-resume failed: ${err instanceof Error ? err.message : err}`)
564
+ }
565
+ }
566
+
512
567
  // Captured separately from setSpawnSubagent so both the plugin context and
513
568
  // the plugin-command runner can dispatch through the same path. The setter
514
569
  // returns void, so without this local binding we couldn't reuse the fn.
@@ -110,6 +110,7 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
110
110
  }
111
111
 
112
112
  appendMasks(argv, policy)
113
+ appendWritable(argv, policy)
113
114
 
114
115
  if (policy.cwd !== undefined) {
115
116
  argv.push('--chdir', policy.cwd)
@@ -128,6 +129,15 @@ function appendMasks(argv: string[], policy: SandboxPolicy): void {
128
129
  }
129
130
  }
130
131
 
132
+ function appendWritable(argv: string[], policy: SandboxPolicy): void {
133
+ for (const dir of policy.writable?.dirs ?? []) {
134
+ argv.push('--bind', dir, dir)
135
+ }
136
+ for (const file of policy.writable?.files ?? []) {
137
+ argv.push('--bind', file, file)
138
+ }
139
+ }
140
+
131
141
  function appendMount(argv: string[], mount: SandboxMount): void {
132
142
  switch (mount.type) {
133
143
  case 'ro-bind':
@@ -1,6 +1,7 @@
1
1
  export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
2
  export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
3
3
  export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
4
+ export { resolveWritableZones, subtractMasked, type WritableZones } from './writable-zones'
4
5
  export { formatCommand, shellQuote } from './quote'
5
6
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
6
7
  export {
@@ -12,4 +13,5 @@ export {
12
13
  type SandboxPolicy,
13
14
  type SandboxProcessPolicy,
14
15
  type SandboxProcStrategy,
16
+ type SandboxWritablePolicy,
15
17
  } from './policy'
@@ -37,11 +37,21 @@ export type SandboxMaskPolicy = {
37
37
  files?: string[]
38
38
  }
39
39
 
40
+ // Writable carve-outs re-exposed on top of a read-only project root AND its
41
+ // masks. Rendered last so "last op wins" makes these the only RW paths: an RW
42
+ // bind here overrides the broad --ro-bind parent, while anything not listed
43
+ // stays read-only (EROFS) or masked.
44
+ export type SandboxWritablePolicy = {
45
+ dirs?: string[]
46
+ files?: string[]
47
+ }
48
+
40
49
  export type SandboxPolicy = {
41
50
  bwrapPath?: string
42
51
  cwd?: string
43
52
  mounts?: SandboxMount[]
44
53
  masks?: SandboxMaskPolicy
54
+ writable?: SandboxWritablePolicy
45
55
  network?: SandboxNetwork
46
56
  env?: SandboxEnvPolicy
47
57
  commandFilter?: SandboxCommandFilter
@@ -0,0 +1,78 @@
1
+ import { lstat } from 'node:fs/promises'
2
+ import path, { join } from 'node:path'
3
+
4
+ export type WritableZones = {
5
+ dirs: string[]
6
+ files: string[]
7
+ }
8
+
9
+ // SECURITY: a blanket RW bind is coarser than the write/edit guards, so this set
10
+ // is deliberately NARROWER than the write/edit allowlist — only genuinely
11
+ // free-write scratch zones. `.agents/skills` and `packages` are excluded: the
12
+ // former is validated (SKILL.md shape, name, frontmatter) by the skillAuthoring
13
+ // guard and the latter holds executable plugin code; bash must not get blanket
14
+ // RW to either. Skill authoring and package writes go through the guarded
15
+ // write/edit tool only.
16
+ const WRITABLE_DIRS = ['workspace', 'public', 'mounts'] as const
17
+
18
+ // Bash may EDIT these when present; creating a MISSING root file goes through
19
+ // write/edit (bwrap cannot RW-bind a non-existent source without pre-creating it).
20
+ const WRITABLE_ROOT_FILES = [
21
+ 'AGENTS.md',
22
+ 'IDENTITY.md',
23
+ 'SOUL.md',
24
+ 'USER.md',
25
+ 'cron.json',
26
+ 'package.json',
27
+ 'typeclaw.json',
28
+ ] as const
29
+
30
+ // SECURITY: the symlink rejection is load-bearing. An RW bind follows symlinks,
31
+ // so a `workspace -> /etc` symlink at a zone root would grant write access to an
32
+ // outside path. (Symlinks INSIDE a real zone are already safe — the kernel
33
+ // resolves them to the read-only parent mount.)
34
+ export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
35
+ const dirs = await collectExisting(
36
+ WRITABLE_DIRS.map((d) => join(agentDir, d)),
37
+ 'dir',
38
+ )
39
+ const files = await collectExisting(
40
+ WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
41
+ 'file',
42
+ )
43
+ return { dirs, files }
44
+ }
45
+
46
+ // SECURITY: a writable RW bind renders AFTER the masks and last-op-wins, so an
47
+ // RW bind on a masked path would re-expose the real (hidden) directory. Drop any
48
+ // writable zone that is, or is nested under, a masked path so the confidentiality
49
+ // boundary survives — e.g. a guest's masked `workspace/` is never re-exposed RW.
50
+ export function subtractMasked(writable: WritableZones, masked: { dirs: string[]; files: string[] }): WritableZones {
51
+ const maskedDirs = masked.dirs
52
+ const isMasked = (target: string): boolean =>
53
+ masked.files.includes(target) || maskedDirs.some((dir) => target === dir || isInside(dir, target))
54
+ return {
55
+ dirs: writable.dirs.filter((dir) => !isMasked(dir)),
56
+ files: writable.files.filter((file) => !isMasked(file)),
57
+ }
58
+ }
59
+
60
+ function isInside(parent: string, child: string): boolean {
61
+ const relative = path.relative(parent, child)
62
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
63
+ }
64
+
65
+ async function collectExisting(paths: string[], kind: 'dir' | 'file'): Promise<string[]> {
66
+ const checks = await Promise.all(paths.map((p) => isRealEntry(p, kind)))
67
+ return paths.filter((_, i) => checks[i])
68
+ }
69
+
70
+ async function isRealEntry(path: string, kind: 'dir' | 'file'): Promise<boolean> {
71
+ try {
72
+ const stats = await lstat(path)
73
+ if (stats.isSymbolicLink()) return false
74
+ return kind === 'dir' ? stats.isDirectory() : stats.isFile()
75
+ } catch {
76
+ return false
77
+ }
78
+ }
@@ -17,6 +17,14 @@ import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-hand
17
17
  import type { SessionOrigin } from '@/agent/session-origin'
18
18
  import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
19
19
  import type { CreateSessionForSubagent } from '@/agent/subagents'
20
+ import { TODO_CONTINUATION_SOURCE } from '@/agent/todo/continuation'
21
+ import {
22
+ armRestartKickForOrigin,
23
+ extractTurnUsage,
24
+ recordTurnOutcome,
25
+ recordTurnStart,
26
+ runIdleContinuation,
27
+ } from '@/agent/todo/continuation-wiring'
20
28
  import type { ChannelRouter } from '@/channels/router'
21
29
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
22
30
  import type { McpManager } from '@/mcp'
@@ -155,6 +163,7 @@ type QueuedPrompt = {
155
163
  text: string
156
164
  delivery: PromptDelivery
157
165
  ts: number
166
+ source?: string
158
167
  }
159
168
 
160
169
  type SessionState = {
@@ -172,6 +181,7 @@ type SessionState = {
172
181
  // generation that ran session.start. A plugin reload mid-connection does
173
182
  // not re-target this session's lifecycle hooks.
174
183
  runtimeSnapshot: PluginRuntimeState | null
184
+ unsubTurnOutcome: Unsubscribe | null
175
185
  dispose: () => Promise<void>
176
186
  }
177
187
 
@@ -257,7 +267,7 @@ export function createServer({
257
267
  handoffPending = false
258
268
  return null
259
269
  }
260
- handoffInFlight = consumeRestartHandoff(agentDir).catch(() => null)
270
+ handoffInFlight = consumeRestartHandoff(agentDir, { accept: (h) => h.origin.kind === 'tui' }).catch(() => null)
261
271
  const result = await handoffInFlight
262
272
  handoffPending = false
263
273
  handoffInFlight = null
@@ -497,6 +507,7 @@ export function createServer({
497
507
  unsubClaim: null,
498
508
  activeClaimCode: null,
499
509
  runtimeSnapshot: runtimeSnapshot ?? null,
510
+ unsubTurnOutcome: null,
500
511
  dispose,
501
512
  }
502
513
  sessionStates.set(ws, state)
@@ -505,12 +516,16 @@ export function createServer({
505
516
  await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
506
517
  }
507
518
 
519
+ if (agentDir !== undefined) {
520
+ state.unsubTurnOutcome = subscribeTurnOutcome(session, agentDir, origin, sessionFileId, logger)
521
+ }
522
+
508
523
  liveSessionRegistry?.register({ sessionId: sessionFileId, session })
509
524
  forwardSessionEvents(ws, session, logger, sessionFileId)
510
525
 
511
526
  if (stream) {
512
527
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
513
- enqueuePrompt(ws, state, msg, agentDir, logger),
528
+ enqueuePrompt(ws, state, msg, agentDir, logger, stream),
514
529
  )
515
530
 
516
531
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -543,6 +558,17 @@ export function createServer({
543
558
  // wired (state.unsubPrompts above) so the kick is enqueued, not
544
559
  // dropped on the floor.
545
560
  if (resumed !== null && stream) {
561
+ // Arm the one-shot restart-kick suppressor BEFORE publishing the
562
+ // kick: the kick owns the first post-restart turn ("I'm back"),
563
+ // so the first idle after it must not also fire a todo
564
+ // continuation. The flag is consumed by that first idle. Best-
565
+ // effort: a failure here only risks one redundant nudge, which
566
+ // the episode budget still bounds.
567
+ if (agentDir !== undefined) {
568
+ await armRestartKickForOrigin(agentDir, origin).catch((err) =>
569
+ logger.error(`[server] ${sessionFileId}: arm restart-kick suppression failed: ${describeErr(err)}`),
570
+ )
571
+ }
546
572
  stream.publish({
547
573
  target: { kind: 'session', sessionId: sessionFileId },
548
574
  payload: { kind: 'prompt', text: ' ', delivery: 'queue' },
@@ -798,6 +824,7 @@ export function createServer({
798
824
  }
799
825
  } finally {
800
826
  if (state) {
827
+ state.unsubTurnOutcome?.()
801
828
  state.session.dispose()
802
829
  await state.dispose()
803
830
  liveSessionRegistry?.unregister(state.sessionFileId)
@@ -867,6 +894,31 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
867
894
  })
868
895
  }
869
896
 
897
+ // Record each completed turn's stopReason for the todo-continuation guard.
898
+ // Ordering-independent by design: this writes the outcome from `message_end`,
899
+ // and the idle path only reads the stored outcome — it never assumes the
900
+ // event arrived before idle fired. An unrecognized stopReason classifies as
901
+ // 'unknown', which the idle path treats as not-safe-to-continue (fail closed).
902
+ function subscribeTurnOutcome(
903
+ session: AgentSession,
904
+ agentDir: string,
905
+ origin: SessionOrigin,
906
+ sessionFileId: string,
907
+ logger: ServerLogger,
908
+ ): Unsubscribe {
909
+ return session.subscribe((event) => {
910
+ const usage = extractTurnUsage(event)
911
+ if (usage === null) return
912
+ void recordTurnOutcome({
913
+ agentDir,
914
+ origin,
915
+ turnId: sessionFileId,
916
+ stopReason: usage.stopReason,
917
+ ...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
918
+ }).catch((err) => logger.error(`[server] ${sessionFileId}: todo outcome capture failed: ${describeErr(err)}`))
919
+ })
920
+ }
921
+
870
922
  function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
871
923
  const detected = detectProviderError(message)
872
924
  if (detected === null) return
@@ -895,6 +947,7 @@ function enqueuePrompt(
895
947
  msg: StreamMessage,
896
948
  agentDir: string | undefined,
897
949
  logger: ServerLogger,
950
+ stream: Stream | undefined,
898
951
  ): void {
899
952
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
900
953
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
@@ -904,14 +957,16 @@ function enqueuePrompt(
904
957
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
905
958
  })
906
959
  }
960
+ const source = (msg.meta as { source?: unknown } | undefined)?.source
907
961
  state.drainQueue.push({
908
962
  streamMessageId: msg.id,
909
963
  text: payload.text,
910
964
  delivery,
911
965
  ts: msg.ts,
966
+ ...(typeof source === 'string' ? { source } : {}),
912
967
  })
913
968
  pushQueueState(ws, state)
914
- void drain(ws, state, agentDir, logger)
969
+ void drain(ws, state, agentDir, logger, stream)
915
970
  }
916
971
 
917
972
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -948,7 +1003,13 @@ function makeTurnHookCallers(
948
1003
  }
949
1004
  }
950
1005
 
951
- async function drain(ws: Ws, state: SessionState, agentDir: string | undefined, logger: ServerLogger): Promise<void> {
1006
+ async function drain(
1007
+ ws: Ws,
1008
+ state: SessionState,
1009
+ agentDir: string | undefined,
1010
+ logger: ServerLogger,
1011
+ stream: Stream | undefined,
1012
+ ): Promise<void> {
952
1013
  if (state.draining) return
953
1014
  state.draining = true
954
1015
  const fireIdle = makeIdleHookCaller(state)
@@ -960,6 +1021,14 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
960
1021
  pushQueueState(ws, state)
961
1022
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
962
1023
 
1024
+ if (agentDir !== undefined) {
1025
+ await recordTurnStart({
1026
+ agentDir,
1027
+ origin: state.origin,
1028
+ isRealUserTurn: item.source !== TODO_CONTINUATION_SOURCE,
1029
+ }).catch((err) => logger.error(`[server] ${state.sessionFileId}: todo turn-start failed: ${describeErr(err)}`))
1030
+ }
1031
+
963
1032
  await fireTurnStart(item.text)
964
1033
  try {
965
1034
  await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
@@ -971,12 +1040,57 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
971
1040
  }
972
1041
  await fireTurnEnd()
973
1042
  await fireIdle()
1043
+
1044
+ // Idle-continuation runs INSIDE the loop and enqueues directly onto
1045
+ // drainQueue (not via stream.publish). Publishing would re-enter drain()
1046
+ // through the session subscriber while `state.draining` is still true, so
1047
+ // the nested call would no-op and the continuation would stall until some
1048
+ // unrelated event woke the loop again. Enqueuing here lets the same `while`
1049
+ // consume it on the next iteration. Only fires when the queue is otherwise
1050
+ // empty so a real user turn is never preempted by a continuation.
1051
+ if (state.drainQueue.length === 0) {
1052
+ await maybeContinueTodos(state, agentDir, logger)
1053
+ }
974
1054
  }
975
1055
  } finally {
976
1056
  state.draining = false
977
1057
  }
978
1058
  }
979
1059
 
1060
+ // If incomplete todos remain and all guards pass, push a single continuation
1061
+ // prompt directly onto this session's drainQueue, tagged TODO_CONTINUATION_SOURCE
1062
+ // so the next drain iteration treats it as an injected (non-user) turn that does
1063
+ // not reset the episode budget. The enclosing drain loop consumes it; this never
1064
+ // calls drain() itself.
1065
+ async function maybeContinueTodos(
1066
+ state: SessionState,
1067
+ agentDir: string | undefined,
1068
+ logger: ServerLogger,
1069
+ ): Promise<void> {
1070
+ if (agentDir === undefined) return
1071
+ try {
1072
+ await runIdleContinuation({
1073
+ agentDir,
1074
+ origin: state.origin,
1075
+ deliver: (text) => {
1076
+ state.drainQueue.push({
1077
+ streamMessageId: `todo-continuation-${crypto.randomUUID()}` as StreamMessageId,
1078
+ text,
1079
+ delivery: 'queue',
1080
+ ts: Date.now(),
1081
+ source: TODO_CONTINUATION_SOURCE,
1082
+ })
1083
+ },
1084
+ })
1085
+ } catch (err) {
1086
+ logger.error(`[server] ${state.sessionFileId}: todo continuation failed: ${describeErr(err)}`)
1087
+ }
1088
+ }
1089
+
1090
+ function describeErr(err: unknown): string {
1091
+ return err instanceof Error ? err.message : String(err)
1092
+ }
1093
+
980
1094
  function pushQueueState(ws: Ws, state: SessionState): void {
981
1095
  const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
982
1096
  id: q.streamMessageId,