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
@@ -1,6 +1,6 @@
1
1
  import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
2
2
  import type { AdapterId } from '@/channels/schema'
3
- import type { ReactionRef } from '@/channels/types'
3
+ import type { ChannelSelfIdentity, ReactionRef } from '@/channels/types'
4
4
 
5
5
  export type ChannelParticipant = {
6
6
  authorId: string
@@ -42,6 +42,7 @@ export type SessionOrigin =
42
42
  reactionRef?: ReactionRef
43
43
  participants?: readonly ChannelParticipant[]
44
44
  membership?: MembershipCount
45
+ self?: ChannelSelfIdentity
45
46
  }
46
47
  | {
47
48
  kind: 'subagent'
@@ -262,6 +263,7 @@ function renderChannelOrigin(
262
263
  thread: string | null
263
264
  participants?: readonly ChannelParticipant[]
264
265
  membership?: MembershipCount
266
+ self?: ChannelSelfIdentity
265
267
  },
266
268
  now: number,
267
269
  ): string {
@@ -398,7 +400,7 @@ function renderChannelOrigin(
398
400
  "matching the channel's `allow` rules are accepted (the tool returns",
399
401
  '`{ ok: false }` otherwise).',
400
402
  '',
401
- ...renderMentionGuidance(platformInfo, origin.participants ?? [], now),
403
+ ...renderMentionGuidance(platformInfo, origin.participants ?? [], now, origin.self),
402
404
  )
403
405
 
404
406
  const participantsBlock = renderParticipants(origin.participants ?? [], platformInfo, now)
@@ -437,6 +439,7 @@ function renderMentionGuidance(
437
439
  platformInfo: PlatformInfo,
438
440
  participants: readonly ChannelParticipant[],
439
441
  now: number,
442
+ self?: ChannelSelfIdentity,
440
443
  ): string[] {
441
444
  const cutoff = now - PARTICIPANTS_MAX_AGE_MS
442
445
  const fresh = [...participants]
@@ -454,6 +457,7 @@ function renderMentionGuidance(
454
457
  `For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
455
458
  `**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platformInfo.displayName},`,
456
459
  'and other bots in this channel will not see the message as addressed to them.',
460
+ ...renderSelfMention(platformInfo, self),
457
461
  ]
458
462
  case 'at-username':
459
463
  return [
@@ -462,6 +466,7 @@ function renderMentionGuidance(
462
466
  'block below are a typeclaw convention for parsing inbound mentions — do not echo them back as outbound mentions.',
463
467
  'If you only know an author by their display name and they have no `@username`, address them by display name',
464
468
  'and they will see the message via the reply context.',
469
+ ...renderSelfMention(platformInfo, self),
465
470
  ]
466
471
  case 'alias':
467
472
  return [
@@ -474,6 +479,40 @@ function renderMentionGuidance(
474
479
  }
475
480
  }
476
481
 
482
+ // The model knows its NAME from identity files but not its platform user
483
+ // id, so a message addressed to its own id reads as "addressed to someone
484
+ // else" and it wrongly skips the turn (issue: skipped_by_tool "Message
485
+ // addressed to @U…, not to <name>"). This line closes that gap by stating
486
+ // the bot's own addressing token explicitly. Empty for the alias platform
487
+ // (KakaoTalk has no in-band mention token to recognize) and when identity
488
+ // has not resolved yet — both fall through to "omit the line".
489
+ function renderSelfMention(platformInfo: PlatformInfo, self: ChannelSelfIdentity | undefined): string[] {
490
+ if (self === undefined) return []
491
+ switch (platformInfo.mentionMode) {
492
+ case 'angle-id': {
493
+ const forms =
494
+ platformInfo.displayName === 'Discord' ? `\`<@${self.id}>\` (also \`<@!${self.id}>\`)` : `\`<@${self.id}>\``
495
+ return [
496
+ '',
497
+ `**You are ${forms} on this ${platformInfo.displayName} workspace.** When a message`,
498
+ `contains your id, it is addressed to YOU — treat it as a mention of yourself, not of`,
499
+ 'someone else, and do not skip the turn as "addressed to another user".',
500
+ ]
501
+ }
502
+ case 'at-username': {
503
+ if (self.username === undefined || self.username === '') return []
504
+ return [
505
+ '',
506
+ `**You are \`@${self.username}\` on ${platformInfo.displayName}.** A message mentioning`,
507
+ `\`@${self.username}\` is addressed to YOU — treat it as a mention of yourself, not of`,
508
+ 'someone else.',
509
+ ]
510
+ }
511
+ case 'alias':
512
+ return []
513
+ }
514
+ }
515
+
477
516
  function renderConversationLine(origin: {
478
517
  adapter: AdapterId
479
518
  workspace: string
@@ -43,7 +43,9 @@ export function renderSubagentCompletionReminder(args: CompletionReminderArgs):
43
43
  return (
44
44
  `<system-reminder>\n` +
45
45
  `Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
46
- `Use subagent_output to inspect.${channelTail}\n` +
46
+ `Use subagent_output to inspect. If this work was tracked in your todo list, ` +
47
+ `keep the item pending (or add a recovery item) via todo_write so it is not ` +
48
+ `dropped.${channelTail}\n` +
47
49
  `</system-reminder>`
48
50
  )
49
51
  }
@@ -325,6 +325,20 @@ export type StartSubagentOptions = InvokeSubagentOptions & {
325
325
  // The two promises share a single underlying invokeSubagent invocation;
326
326
  // `completion` settles after dispose, so the session reference exposed via
327
327
  // `handle.abort` becomes a no-op once `completion` resolves.
328
+ //
329
+ // `timeoutMs` enforcement: the `spawn_subagent` tool drives its background
330
+ // `subagent.completed` broadcast off this `completion` promise, so an
331
+ // unbounded `invokeSubagent` (a wedged `session.prompt` that never settles)
332
+ // would leave `completion` pending forever and the parent never woken. When
333
+ // the subagent declares `timeoutMs`, we race the work against a ceiling and
334
+ // settle `completion` with `ok: false` on expiry — which fires the FAILED
335
+ // broadcast so the parent learns the spawn died instead of hanging silently.
336
+ // This mirrors `awaitWithSubagentTimeout` on the SubagentConsumer path; here
337
+ // the timeout resolves (rather than rejects) because `completion` already maps
338
+ // failures to `{ ok: false }`. Cancellation is best-effort: pi's
339
+ // `session.prompt` takes no AbortSignal, so we call the session `abort` handle
340
+ // (which the handle resolution captured) to tear down what we can; the LLM
341
+ // stream may keep running until the OS reaps it.
328
342
  export function startSubagent(name: string, options: StartSubagentOptions): StartSubagentResult {
329
343
  let resolveHandle: (h: SubagentHandle) => void
330
344
  let rejectHandle: (err: Error) => void
@@ -334,11 +348,13 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
334
348
  })
335
349
  let handleSettled = false
336
350
  let finalMessage: string | undefined
351
+ let abortSession: (() => Promise<void>) | undefined
337
352
 
338
- const completion = invokeSubagent(name, {
353
+ const work = invokeSubagent(name, {
339
354
  ...options,
340
355
  onSessionCreated: (event) => {
341
356
  handleSettled = true
357
+ abortSession = event.abort
342
358
  resolveHandle({ taskId: options.taskId, sessionId: event.sessionId, abort: event.abort })
343
359
  if (options.onSession !== undefined) {
344
360
  options.onSession(event)
@@ -357,9 +373,36 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
357
373
  return { ok: false as const, error }
358
374
  })
359
375
 
376
+ const timeoutMs = options.registry[name]?.timeoutMs
377
+ const completion = timeoutMs === undefined ? work : raceSubagentCompletion(work, name, options.taskId, timeoutMs)
378
+
379
+ void completion.then(() => {
380
+ if (timeoutMs !== undefined) void abortSession?.()
381
+ })
382
+
360
383
  return { handle, completion }
361
384
  }
362
385
 
386
+ type SubagentCompletion = { ok: true; finalMessage?: string } | { ok: false; error: string }
387
+
388
+ function raceSubagentCompletion(
389
+ work: Promise<SubagentCompletion>,
390
+ name: string,
391
+ taskId: string,
392
+ timeoutMs: number,
393
+ ): Promise<SubagentCompletion> {
394
+ let timer: ReturnType<typeof setTimeout> | null = null
395
+ const timeout = new Promise<SubagentCompletion>((resolve) => {
396
+ timer = setTimeout(
397
+ () => resolve({ ok: false, error: new SubagentTimeoutError(name, taskId, timeoutMs).message }),
398
+ timeoutMs,
399
+ )
400
+ })
401
+ return Promise.race([work, timeout]).finally(() => {
402
+ if (timer !== null) clearTimeout(timer)
403
+ })
404
+ }
405
+
363
406
  function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg: string) => void): void {
364
407
  try {
365
408
  session.subscribe((event: unknown) => {
@@ -42,6 +42,10 @@ When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it
42
42
 
43
43
  When the user gives you work, start doing it in the same turn — a real action, not a plan or a promise-to-act. Commentary-only turns are incomplete when the next action is clear. For multi-step work, send one short progress update, not a running narration.
44
44
 
45
+ ## Tracking your work
46
+
47
+ For any multi-step or long-running task, maintain a todo list with \`todo_write\` and mark items complete as you finish them. This is not bookkeeping for its own sake: if this session is interrupted — a restart, a crash, or simply a later turn — the runtime uses the remaining incomplete items to resume the work instead of silently dropping it. Write the list when you start the work, update statuses as you go, and call \`todo_clear\` when everything is genuinely done. A single-step request needs no todo list.
48
+
45
49
  ## Tool-call style
46
50
 
47
51
  Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only when it helps: multi-step work, risky actions (deletions, external sends, irreversible changes), or when the user asks.
@@ -0,0 +1,242 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { incompleteTodos, type Todo } from './store'
4
+
5
+ export const DEFAULT_MAX_AUTO_TURNS = 3
6
+ export const DEFAULT_MAX_CUMULATIVE_TOKENS = 25_000
7
+ export const DEFAULT_MAX_WALL_CLOCK_MS = 30 * 60_000
8
+ export const DEFAULT_STAGNATION_LIMIT = 2
9
+
10
+ export type ContinuationLimits = {
11
+ maxAutoTurns: number
12
+ maxCumulativeTokens: number
13
+ maxWallClockMs: number
14
+ stagnationLimit: number
15
+ }
16
+
17
+ export const DEFAULT_CONTINUATION_LIMITS: ContinuationLimits = {
18
+ maxAutoTurns: DEFAULT_MAX_AUTO_TURNS,
19
+ maxCumulativeTokens: DEFAULT_MAX_CUMULATIVE_TOKENS,
20
+ maxWallClockMs: DEFAULT_MAX_WALL_CLOCK_MS,
21
+ stagnationLimit: DEFAULT_STAGNATION_LIMIT,
22
+ }
23
+
24
+ // A continuation episode is the unit a budget applies to. It opens when the
25
+ // first auto-nudge fires after a real user turn (or restart recovery) and
26
+ // resets only on the next REAL user prompt — never on the runtime's own
27
+ // injected prompts. Persisting it lets the budgets survive a restart so a
28
+ // crash-loop cannot reset the ceiling.
29
+ export type ContinuationEpisode = {
30
+ episodeId: string
31
+ startedAt: number
32
+ autoTurnCount: number
33
+ cumulativeTokens: number
34
+ failureCount: number
35
+ stagnationCount: number
36
+ lastIncompleteHash: string | null
37
+ }
38
+
39
+ // The outcome of the most recently completed turn, recorded from the
40
+ // `message_end` subscription (authoritative) or a prompt `finally` fallback.
41
+ // `stopReason: 'unknown'` is the fail-closed value: an idle that sees it does
42
+ // not auto-inject.
43
+ export type TurnOutcome = {
44
+ turnId: string
45
+ stopReason: 'stop' | 'aborted' | 'error' | 'unknown'
46
+ endedAt: number
47
+ // Total tokens the just-completed turn consumed (from the assistant
48
+ // message's usage). Accumulated into the episode's cumulativeTokens so the
49
+ // token ceiling reflects real spend. Optional for older state files and for
50
+ // turns whose usage was unavailable; missing counts as 0.
51
+ tokens?: number
52
+ }
53
+
54
+ export type ContinuationState = {
55
+ episode: ContinuationEpisode | null
56
+ lastTurnOutcome: TurnOutcome | null
57
+ // One-shot suppressor: the restart kick prompt owns the first post-restart
58
+ // idle, so the first idle after a restart consumes this and skips exactly
59
+ // one injection.
60
+ suppressNextIdleNudgeReason: 'restart-kick' | null
61
+ // Durable user-abort suppressor (policy D1). Set when a turn ends via
62
+ // explicit user abort; cleared only by the next real user turn. While set,
63
+ // no auto-continuation fires regardless of episode budget.
64
+ autoResumeBlockedUntilRealUserTurn: boolean
65
+ }
66
+
67
+ export function emptyContinuationState(): ContinuationState {
68
+ return {
69
+ episode: null,
70
+ lastTurnOutcome: null,
71
+ suppressNextIdleNudgeReason: null,
72
+ autoResumeBlockedUntilRealUserTurn: false,
73
+ }
74
+ }
75
+
76
+ const STOP_REASONS = new Set<TurnOutcome['stopReason']>(['stop', 'aborted', 'error', 'unknown'])
77
+
78
+ // Validate a persisted state object field-by-field and fail closed: any field
79
+ // that does not match the expected shape is dropped to its empty value rather
80
+ // than trusted. A partially-written file or a newer/older schema must never
81
+ // surface a malformed `episode` whose `undefined`/`NaN` counters would compare
82
+ // false against the ceilings and so bypass the token-burst guard. A malformed
83
+ // episode collapses to `null` (a fresh episode opens on the next decision); a
84
+ // malformed outcome collapses to `null` (the idle path then fails closed, not
85
+ // auto-injecting).
86
+ export function parseContinuationState(value: unknown): ContinuationState {
87
+ if (typeof value !== 'object' || value === null) return emptyContinuationState()
88
+ const v = value as Record<string, unknown>
89
+ return {
90
+ episode: parseEpisode(v.episode),
91
+ lastTurnOutcome: parseOutcome(v.lastTurnOutcome),
92
+ suppressNextIdleNudgeReason: v.suppressNextIdleNudgeReason === 'restart-kick' ? 'restart-kick' : null,
93
+ autoResumeBlockedUntilRealUserTurn: v.autoResumeBlockedUntilRealUserTurn === true,
94
+ }
95
+ }
96
+
97
+ function parseEpisode(value: unknown): ContinuationEpisode | null {
98
+ if (typeof value !== 'object' || value === null) return null
99
+ const e = value as Record<string, unknown>
100
+ if (typeof e.episodeId !== 'string') return null
101
+ if (!isFiniteNumber(e.startedAt)) return null
102
+ if (!isFiniteNumber(e.autoTurnCount)) return null
103
+ if (!isFiniteNumber(e.cumulativeTokens)) return null
104
+ if (!isFiniteNumber(e.failureCount)) return null
105
+ if (!isFiniteNumber(e.stagnationCount)) return null
106
+ if (e.lastIncompleteHash !== null && typeof e.lastIncompleteHash !== 'string') return null
107
+ return {
108
+ episodeId: e.episodeId,
109
+ startedAt: e.startedAt,
110
+ autoTurnCount: e.autoTurnCount,
111
+ cumulativeTokens: e.cumulativeTokens,
112
+ failureCount: e.failureCount,
113
+ stagnationCount: e.stagnationCount,
114
+ lastIncompleteHash: e.lastIncompleteHash,
115
+ }
116
+ }
117
+
118
+ function parseOutcome(value: unknown): TurnOutcome | null {
119
+ if (typeof value !== 'object' || value === null) return null
120
+ const o = value as Record<string, unknown>
121
+ if (typeof o.turnId !== 'string') return null
122
+ if (typeof o.stopReason !== 'string' || !STOP_REASONS.has(o.stopReason as TurnOutcome['stopReason'])) return null
123
+ if (!isFiniteNumber(o.endedAt)) return null
124
+ return {
125
+ turnId: o.turnId,
126
+ stopReason: o.stopReason as TurnOutcome['stopReason'],
127
+ endedAt: o.endedAt,
128
+ ...(isFiniteNumber(o.tokens) ? { tokens: o.tokens } : {}),
129
+ }
130
+ }
131
+
132
+ function isFiniteNumber(value: unknown): value is number {
133
+ return typeof value === 'number' && Number.isFinite(value)
134
+ }
135
+
136
+ // Canonical hash of the INCOMPLETE todos only. Normalization (sort by id or
137
+ // normalized text, collapse whitespace, include status) makes the hash stable
138
+ // under reordering and cosmetic edits so it is a usable stagnation heuristic.
139
+ // It is deliberately NOT used as proof of progress — see hasRealProgress.
140
+ export function hashIncomplete(todos: readonly Todo[]): string {
141
+ const incomplete = incompleteTodos(todos)
142
+ const canonical = incomplete
143
+ .map((t) => ({
144
+ id: t.id ?? '',
145
+ status: t.status,
146
+ content: t.content.trim().replace(/\s+/g, ' '),
147
+ }))
148
+ .sort((a, b) => {
149
+ const ka = a.id !== '' ? a.id : a.content
150
+ const kb = b.id !== '' ? b.id : b.content
151
+ return ka < kb ? -1 : ka > kb ? 1 : 0
152
+ })
153
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
154
+ }
155
+
156
+ // "Real progress" is stricter than "the hash changed": the incomplete set must
157
+ // shrink. Text churn (reword/reorder/split) does not count, which is what
158
+ // closes the fake-progress loophole. Only a drop in the number of incomplete
159
+ // items resets the stagnation counter.
160
+ export function hasRealProgress(prev: readonly Todo[], next: readonly Todo[]): boolean {
161
+ return incompleteTodos(next).length < incompleteTodos(prev).length
162
+ }
163
+
164
+ export type ContinuationDecision =
165
+ | { kind: 'inject'; episode: ContinuationEpisode }
166
+ | { kind: 'skip'; reason: ContinuationSkipReason }
167
+
168
+ export type ContinuationSkipReason =
169
+ | 'no-incomplete-todos'
170
+ | 'restart-kick-suppressed'
171
+ | 'user-abort-blocked'
172
+ | 'turn-not-safe'
173
+ | 'max-auto-turns'
174
+ | 'max-tokens'
175
+ | 'max-wall-clock'
176
+ | 'stagnation'
177
+
178
+ // Pure decision: given the current persisted state, the current todos, the
179
+ // last turn outcome, a fresh episode-id factory, and `now`, decide whether to
180
+ // inject a continuation and return the episode to persist. The caller is
181
+ // responsible for persisting `episode` from an `inject` result before actually
182
+ // injecting. Fails closed on every ambiguity.
183
+ export function decideContinuation(args: {
184
+ state: ContinuationState
185
+ todos: readonly Todo[]
186
+ limits: ContinuationLimits
187
+ now: number
188
+ newEpisodeId: () => string
189
+ }): ContinuationDecision {
190
+ const { state, todos, limits, now } = args
191
+
192
+ if (incompleteTodos(todos).length === 0) return { kind: 'skip', reason: 'no-incomplete-todos' }
193
+
194
+ if (state.suppressNextIdleNudgeReason === 'restart-kick') {
195
+ return { kind: 'skip', reason: 'restart-kick-suppressed' }
196
+ }
197
+
198
+ if (state.autoResumeBlockedUntilRealUserTurn) return { kind: 'skip', reason: 'user-abort-blocked' }
199
+
200
+ const outcome = state.lastTurnOutcome
201
+ if (outcome === null || outcome.stopReason === 'unknown' || outcome.stopReason === 'aborted') {
202
+ return { kind: 'skip', reason: 'turn-not-safe' }
203
+ }
204
+
205
+ const hash = hashIncomplete(todos)
206
+ const base: ContinuationEpisode = state.episode ?? {
207
+ episodeId: args.newEpisodeId(),
208
+ startedAt: now,
209
+ autoTurnCount: 0,
210
+ cumulativeTokens: 0,
211
+ failureCount: 0,
212
+ stagnationCount: 0,
213
+ lastIncompleteHash: null,
214
+ }
215
+
216
+ // Fold the just-completed turn's token spend into the episode BEFORE checking
217
+ // the ceiling, so the budget reflects what the previous auto-turn actually
218
+ // cost. `lastTurnOutcome.tokens` is the spend of the turn that drove this
219
+ // idle; missing usage counts as 0.
220
+ const episode: ContinuationEpisode = {
221
+ ...base,
222
+ cumulativeTokens: base.cumulativeTokens + (outcome.tokens ?? 0),
223
+ }
224
+
225
+ if (episode.autoTurnCount >= limits.maxAutoTurns) return { kind: 'skip', reason: 'max-auto-turns' }
226
+ if (episode.cumulativeTokens >= limits.maxCumulativeTokens) return { kind: 'skip', reason: 'max-tokens' }
227
+ if (now - episode.startedAt >= limits.maxWallClockMs) return { kind: 'skip', reason: 'max-wall-clock' }
228
+
229
+ const stagnated = episode.lastIncompleteHash === hash
230
+ const stagnationCount = stagnated ? episode.stagnationCount + 1 : episode.stagnationCount
231
+ if (stagnationCount >= limits.stagnationLimit) return { kind: 'skip', reason: 'stagnation' }
232
+
233
+ return {
234
+ kind: 'inject',
235
+ episode: {
236
+ ...episode,
237
+ autoTurnCount: episode.autoTurnCount + 1,
238
+ stagnationCount,
239
+ lastIncompleteHash: hash,
240
+ },
241
+ }
242
+ }
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import {
6
+ type ContinuationState,
7
+ emptyContinuationState,
8
+ parseContinuationState,
9
+ type TurnOutcome,
10
+ } from './continuation-policy'
11
+ import type { TodoScope } from './scope'
12
+ import { todoDir } from './store'
13
+
14
+ type StateFile = {
15
+ version: 1
16
+ state: ContinuationState
17
+ }
18
+
19
+ export function continuationStatePath(agentDir: string, scope: TodoScope): string {
20
+ return join(todoDir(agentDir), '.state', `${scope.key}.json`)
21
+ }
22
+
23
+ export async function readContinuationState(agentDir: string, scope: TodoScope): Promise<ContinuationState> {
24
+ const path = continuationStatePath(agentDir, scope)
25
+ let raw: string
26
+ try {
27
+ raw = await readFile(path, 'utf8')
28
+ } catch (err) {
29
+ if (isEnoent(err)) return emptyContinuationState()
30
+ throw err
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(raw) as Partial<StateFile>
34
+ return parseContinuationState(parsed.state)
35
+ } catch {
36
+ return emptyContinuationState()
37
+ }
38
+ }
39
+
40
+ export async function writeContinuationState(
41
+ agentDir: string,
42
+ scope: TodoScope,
43
+ state: ContinuationState,
44
+ ): Promise<void> {
45
+ const path = continuationStatePath(agentDir, scope)
46
+ const payload: StateFile = { version: 1, state }
47
+ await mkdir(dirname(path), { recursive: true })
48
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
49
+ await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
50
+ await rename(tmp, path)
51
+ }
52
+
53
+ // A real user turn ends any active continuation episode and clears both
54
+ // suppressors. This is the ONLY thing that resets the episode budget — the
55
+ // runtime's own injected continuation prompts must not. Callers pass `false`
56
+ // for injected prompts so the episode budget keeps counting down.
57
+ export function onTurnStart(state: ContinuationState, isRealUserTurn: boolean): ContinuationState {
58
+ if (!isRealUserTurn) return state
59
+ return {
60
+ ...state,
61
+ episode: null,
62
+ autoResumeBlockedUntilRealUserTurn: false,
63
+ suppressNextIdleNudgeReason: null,
64
+ }
65
+ }
66
+
67
+ // Record the most recently completed turn's outcome. Explicit user abort also
68
+ // arms the durable suppressor so no auto-continuation fires until a real user
69
+ // turn clears it (policy D1).
70
+ export function onTurnOutcome(state: ContinuationState, outcome: TurnOutcome): ContinuationState {
71
+ const next: ContinuationState = { ...state, lastTurnOutcome: outcome }
72
+ if (outcome.stopReason === 'aborted') next.autoResumeBlockedUntilRealUserTurn = true
73
+ return next
74
+ }
75
+
76
+ export function armRestartKickSuppression(state: ContinuationState): ContinuationState {
77
+ return { ...state, suppressNextIdleNudgeReason: 'restart-kick' }
78
+ }
79
+
80
+ export function consumeRestartKickSuppression(state: ContinuationState): ContinuationState {
81
+ if (state.suppressNextIdleNudgeReason === null) return state
82
+ return { ...state, suppressNextIdleNudgeReason: null }
83
+ }
84
+
85
+ function isEnoent(err: unknown): boolean {
86
+ return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'
87
+ }
@@ -0,0 +1,113 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
2
+
3
+ import { maybeInjectContinuation } from './continuation'
4
+ import { type TurnOutcome } from './continuation-policy'
5
+ import {
6
+ armRestartKickSuppression,
7
+ onTurnOutcome,
8
+ onTurnStart,
9
+ readContinuationState,
10
+ writeContinuationState,
11
+ } from './continuation-state'
12
+ import { resolveTodoScope } from './scope'
13
+ import { writeTodos } from './store'
14
+
15
+ // Map a pi `message_end` event's stopReason onto the TurnOutcome stopReason
16
+ // space. Anything we don't recognize collapses to 'unknown' so the idle path
17
+ // fails closed (no auto-injection on an outcome we can't classify).
18
+ export function classifyStopReason(raw: unknown): TurnOutcome['stopReason'] {
19
+ if (raw === 'stop' || raw === 'aborted' || raw === 'error') return raw
20
+ return 'unknown'
21
+ }
22
+
23
+ // Extract the stopReason and token usage from a pi `message_end` event.
24
+ // Returns null for any event that is not an assistant message_end. `tokens`
25
+ // comes from the assistant message's `usage.totalTokens`; it is undefined when
26
+ // the provider did not report usage.
27
+ export function extractTurnUsage(event: unknown): { stopReason: TurnOutcome['stopReason']; tokens?: number } | null {
28
+ if (typeof event !== 'object' || event === null) return null
29
+ const e = event as { type?: unknown; message?: unknown }
30
+ if (e.type !== 'message_end') return null
31
+ const message = e.message as { role?: unknown; stopReason?: unknown; usage?: unknown } | undefined
32
+ if (message?.role !== 'assistant') return null
33
+ const usage = message.usage as { totalTokens?: unknown } | undefined
34
+ const total = usage?.totalTokens
35
+ const tokens = typeof total === 'number' && Number.isFinite(total) ? total : undefined
36
+ return { stopReason: classifyStopReason(message.stopReason), ...(tokens !== undefined ? { tokens } : {}) }
37
+ }
38
+
39
+ export function extractStopReason(event: unknown): TurnOutcome['stopReason'] | null {
40
+ return extractTurnUsage(event)?.stopReason ?? null
41
+ }
42
+
43
+ // Persist the just-completed turn's outcome for a scope. No-op for origins
44
+ // without a todo scope (subagent/system). Safe to call from a subscription
45
+ // callback; it swallows nothing — callers wrap as they see fit.
46
+ export async function recordTurnOutcome(args: {
47
+ agentDir: string
48
+ origin: SessionOrigin
49
+ turnId: string
50
+ stopReason: TurnOutcome['stopReason']
51
+ tokens?: number
52
+ now?: number
53
+ }): Promise<void> {
54
+ const scope = resolveTodoScope(args.origin)
55
+ if (scope === null) return
56
+ const state = await readContinuationState(args.agentDir, scope)
57
+ const outcome: TurnOutcome = {
58
+ turnId: args.turnId,
59
+ stopReason: args.stopReason,
60
+ endedAt: args.now ?? Date.now(),
61
+ ...(args.tokens !== undefined ? { tokens: args.tokens } : {}),
62
+ }
63
+ await writeContinuationState(args.agentDir, scope, onTurnOutcome(state, outcome))
64
+ }
65
+
66
+ // Reset the continuation episode at the start of a REAL user turn. Injected
67
+ // continuation turns pass isRealUserTurn=false so the episode budget keeps
68
+ // counting down. No-op for scopeless origins.
69
+ export async function recordTurnStart(args: {
70
+ agentDir: string
71
+ origin: SessionOrigin
72
+ isRealUserTurn: boolean
73
+ }): Promise<void> {
74
+ const scope = resolveTodoScope(args.origin)
75
+ if (scope === null) return
76
+ const state = await readContinuationState(args.agentDir, scope)
77
+ const next = onTurnStart(state, args.isRealUserTurn)
78
+ if (next !== state) await writeContinuationState(args.agentDir, scope, next)
79
+ }
80
+
81
+ // Arm the one-shot restart-kick suppressor for an origin's scope, so the first
82
+ // idle after a restart skips exactly one continuation injection (the restart
83
+ // kick prompt owns that turn). No-op for scopeless origins.
84
+ export async function armRestartKickForOrigin(agentDir: string, origin: SessionOrigin): Promise<void> {
85
+ const scope = resolveTodoScope(origin)
86
+ if (scope === null) return
87
+ const state = await readContinuationState(agentDir, scope)
88
+ await writeContinuationState(agentDir, scope, armRestartKickSuppression(state))
89
+ }
90
+
91
+ // Empty the todo list for an origin's scope. No-op for scopeless origins.
92
+ export async function clearTodosForOrigin(agentDir: string, origin: SessionOrigin): Promise<void> {
93
+ const scope = resolveTodoScope(origin)
94
+ if (scope === null) return
95
+ await writeTodos(agentDir, scope, [])
96
+ }
97
+
98
+ export type DeliverContinuation = (text: string) => void
99
+
100
+ // Idle-path entry: decide whether to nudge and, if so, deliver via the
101
+ // origin-appropriate mechanism the caller supplies. Returns true if a nudge
102
+ // was delivered. The decide-and-persist step happens inside
103
+ // maybeInjectContinuation; delivery is the only side effect the caller owns.
104
+ export async function runIdleContinuation(args: {
105
+ agentDir: string
106
+ origin: SessionOrigin
107
+ deliver: DeliverContinuation
108
+ }): Promise<boolean> {
109
+ const result = await maybeInjectContinuation({ agentDir: args.agentDir, origin: args.origin })
110
+ if (result.kind !== 'injected') return false
111
+ args.deliver(result.text)
112
+ return true
113
+ }