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
@@ -0,0 +1,71 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
2
+
3
+ import { type ContinuationLimits, DEFAULT_CONTINUATION_LIMITS, decideContinuation } from './continuation-policy'
4
+ import { consumeRestartKickSuppression, readContinuationState, writeContinuationState } from './continuation-state'
5
+ import { resolveTodoScope, type TodoScope } from './scope'
6
+ import { readTodos } from './store'
7
+
8
+ export const TODO_CONTINUATION_SOURCE = 'todo-continuation'
9
+
10
+ export const CONTINUATION_PROMPT = [
11
+ '---',
12
+ '**[SYSTEM MESSAGE — not from a human]**',
13
+ '',
14
+ 'Incomplete todo items remain in your list. Continue working on the next',
15
+ 'pending item now, without asking for permission. Mark each item complete (or',
16
+ 'cancelled) as you finish it by calling `todo_write` with the updated list. If',
17
+ 'you believe all the work is already done, do not just assert it — re-examine',
18
+ 'each remaining item skeptically, verify the work actually landed, and update',
19
+ 'the list accordingly. When everything is genuinely complete, call',
20
+ '`todo_clear`. Do not acknowledge or reply to this notice; just continue the',
21
+ 'work.',
22
+ '',
23
+ '---',
24
+ '',
25
+ ].join('\n')
26
+
27
+ export type ContinuationInjectResult =
28
+ | { kind: 'injected'; scope: TodoScope; text: string }
29
+ | { kind: 'skipped'; reason: string }
30
+
31
+ export type MaybeInjectContinuationArgs = {
32
+ agentDir: string
33
+ origin: SessionOrigin | undefined
34
+ now?: number
35
+ limits?: ContinuationLimits
36
+ newEpisodeId?: () => string
37
+ }
38
+
39
+ // Decide-and-persist entry point called from the idle path of each origin's
40
+ // drain loop. On `injected`, the caller is responsible for actually delivering
41
+ // `text` into the session (TUI: stream.publish; channel: pendingSystemReminders
42
+ // + drain). The episode mutation is persisted BEFORE returning so a crash
43
+ // between persist and deliver can only UNDER-count (fail-safe: a missed
44
+ // delivery costs one wasted budget slot, never an unbounded loop).
45
+ //
46
+ // The restart-kick one-shot is consumed here even on skip, so the first
47
+ // post-restart idle always burns the suppressor exactly once.
48
+ export async function maybeInjectContinuation(args: MaybeInjectContinuationArgs): Promise<ContinuationInjectResult> {
49
+ if (args.origin === undefined) return { kind: 'skipped', reason: 'no-origin' }
50
+ const scope = resolveTodoScope(args.origin)
51
+ if (scope === null) return { kind: 'skipped', reason: 'no-scope' }
52
+
53
+ const now = args.now ?? Date.now()
54
+ const limits = args.limits ?? DEFAULT_CONTINUATION_LIMITS
55
+ const newEpisodeId = args.newEpisodeId ?? (() => crypto.randomUUID())
56
+
57
+ const todos = await readTodos(args.agentDir, scope)
58
+ const state = await readContinuationState(args.agentDir, scope)
59
+
60
+ const decision = decideContinuation({ state, todos, limits, now, newEpisodeId })
61
+
62
+ if (decision.kind === 'skip') {
63
+ if (state.suppressNextIdleNudgeReason !== null) {
64
+ await writeContinuationState(args.agentDir, scope, consumeRestartKickSuppression(state))
65
+ }
66
+ return { kind: 'skipped', reason: decision.reason }
67
+ }
68
+
69
+ await writeContinuationState(args.agentDir, scope, { ...state, episode: decision.episode })
70
+ return { kind: 'injected', scope, text: CONTINUATION_PROMPT }
71
+ }
@@ -0,0 +1,77 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
2
+
3
+ // A todo scope is the durable identity a todo list hangs off. It is
4
+ // deliberately NOT the raw sessionId: sessionIds churn across TUI reconnects
5
+ // and every cron fire, and a channel session can roll to a fresh sessionId on
6
+ // stale-rollover (see src/channels/router.ts SESSION_FRESHNESS_TTL_MS). Keying
7
+ // on origin identity instead lets a todo list survive those transitions so
8
+ // interrupted work can be resumed.
9
+ //
10
+ // `key` is a filesystem-safe relative path segment (no leading slash, no `..`).
11
+ // `kind` mirrors the originating `SessionOrigin['kind']` so the continuation
12
+ // injector can enforce that a nudge only fires into a live session whose origin
13
+ // matches the scope (the eligible-session invariant).
14
+ export type TodoScope = {
15
+ kind: 'tui' | 'channel' | 'cron'
16
+ key: string
17
+ }
18
+
19
+ // Resolve the durable todo scope for a session origin, or `null` when the
20
+ // origin owns no todo list.
21
+ //
22
+ // - tui → singleton `tui`. There is no stable per-operator identity (the
23
+ // sessionId churns on every reconnect and the restart handoff is
24
+ // once-per-boot), so TUI is modeled as one global workstream per
25
+ // agent. Concurrent TUI attaches therefore share a scope; this is
26
+ // an accepted, documented limitation.
27
+ // - channel → keyed by the adapter/workspace/chat/thread tuple, matching how
28
+ // channels/sessions.json already identifies a conversation. This
29
+ // survives both container restart and stale-rollover.
30
+ // - cron → keyed by jobId. The sessionId is useless here (fresh every
31
+ // fire); the job is the durable identity.
32
+ // - subagent → null. Subagents do not own continuation; their parent does.
33
+ // - system → null. Runtime infrastructure (memory/backup) is not
34
+ // user-delegated work and must never auto-continue.
35
+ export function resolveTodoScope(origin: SessionOrigin): TodoScope | null {
36
+ switch (origin.kind) {
37
+ case 'tui':
38
+ return { kind: 'tui', key: 'tui' }
39
+ case 'channel':
40
+ return { kind: 'channel', key: channelScopeKey(origin) }
41
+ case 'cron':
42
+ return { kind: 'cron', key: `cron/${encodeComponent(origin.jobId)}` }
43
+ case 'subagent':
44
+ case 'system':
45
+ return null
46
+ default: {
47
+ const _exhaustive: never = origin
48
+ void _exhaustive
49
+ return null
50
+ }
51
+ }
52
+ }
53
+
54
+ function channelScopeKey(origin: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
55
+ const parts = [
56
+ encodeComponent(origin.adapter),
57
+ encodeComponent(origin.workspace),
58
+ encodeComponent(origin.chat),
59
+ encodeComponent(origin.thread),
60
+ ]
61
+ return `channel/${parts.join(':')}`
62
+ }
63
+
64
+ // Encode one scope component injectively. Every component is emitted as a
65
+ // discriminant prefix plus its `encodeURIComponent` form:
66
+ // - null → `n` (the channel-root / no-thread case)
67
+ // - any string s → `s<encoded>`
68
+ // The prefix makes the three cases pairwise distinguishable that lossy schemes
69
+ // confused: a null thread vs a literal "n" string, an empty string vs a
70
+ // literal "_empty" string, and any value vs another whose unsafe chars happen
71
+ // to map together. `encodeURIComponent` is itself injective and never emits
72
+ // `/` or `:`, so the joined key is both a single filesystem-safe path segment
73
+ // and a collision-free identity for the conversation whose todo file it names.
74
+ function encodeComponent(value: string | null): string {
75
+ if (value === null) return 'n'
76
+ return `s${encodeURIComponent(value)}`
77
+ }
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, join, relative } from 'node:path'
4
+
5
+ import type { TodoScope } from './scope'
6
+
7
+ export const TODO_STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'] as const
8
+ export type TodoStatus = (typeof TODO_STATUSES)[number]
9
+
10
+ export const TODO_PRIORITIES = ['high', 'medium', 'low'] as const
11
+ export type TodoPriority = (typeof TODO_PRIORITIES)[number]
12
+
13
+ export type Todo = {
14
+ content: string
15
+ status: TodoStatus
16
+ priority?: TodoPriority
17
+ id?: string
18
+ }
19
+
20
+ type TodoFile = {
21
+ version: 1
22
+ todos: Todo[]
23
+ }
24
+
25
+ export function todoDir(agentDir: string): string {
26
+ return join(agentDir, 'todo')
27
+ }
28
+
29
+ // Defense-in-depth: the resolved file must stay inside todo/. Scope keys from
30
+ // resolveTodoScope are already collision- and traversal-safe, but this function
31
+ // is an exported primitive — a future caller passing a hand-built scope like
32
+ // `{ key: '../sessions/x' }` would otherwise escape. We assert here rather than
33
+ // trust every caller to use resolveTodoScope.
34
+ export function todoContentPath(agentDir: string, scope: TodoScope): string {
35
+ const dir = todoDir(agentDir)
36
+ const path = join(dir, `${scope.key}.json`)
37
+ const rel = relative(dir, path)
38
+ if (rel.startsWith('..') || isAbsolute(rel)) {
39
+ throw new Error(`todo scope key escapes the todo directory: ${JSON.stringify(scope.key)}`)
40
+ }
41
+ return path
42
+ }
43
+
44
+ export async function readTodos(agentDir: string, scope: TodoScope): Promise<Todo[]> {
45
+ const path = todoContentPath(agentDir, scope)
46
+ let raw: string
47
+ try {
48
+ raw = await readFile(path, 'utf8')
49
+ } catch (err) {
50
+ if (isEnoent(err)) return []
51
+ throw err
52
+ }
53
+ let parsed: Partial<TodoFile>
54
+ try {
55
+ parsed = JSON.parse(raw) as Partial<TodoFile>
56
+ } catch {
57
+ return []
58
+ }
59
+ if (!Array.isArray(parsed.todos)) return []
60
+ // The file is force-committed and hand-editable, so a corrupt or partially
61
+ // edited entry can appear. Drop anything that is not a well-formed Todo
62
+ // rather than let a `null`/malformed item crash incompleteTodos (`t.status`)
63
+ // or surface as trusted state to the model.
64
+ return parsed.todos.filter(isValidTodo)
65
+ }
66
+
67
+ function isValidTodo(value: unknown): value is Todo {
68
+ if (typeof value !== 'object' || value === null) return false
69
+ const t = value as Record<string, unknown>
70
+ if (typeof t.content !== 'string' || t.content.length === 0) return false
71
+ if (typeof t.status !== 'string' || !(TODO_STATUSES as readonly string[]).includes(t.status)) return false
72
+ if (t.priority !== undefined && !(TODO_PRIORITIES as readonly string[]).includes(t.priority as string)) return false
73
+ if (t.id !== undefined && typeof t.id !== 'string') return false
74
+ return true
75
+ }
76
+
77
+ // Write is atomic (temp file + rename) so a crash mid-write can never leave a
78
+ // half-serialized JSON file that the next read would throw on. Mirrors the
79
+ // channels/sessions.json writer. A scope is normally owned by a single live
80
+ // session (see resolveTodoScope), so the only concurrent writers are the rare
81
+ // duplicate-attach case, where last-writer-wins on the rename is acceptable —
82
+ // the alternative (lost-update detection) is not worth a lock for a todo list.
83
+ export async function writeTodos(agentDir: string, scope: TodoScope, todos: Todo[]): Promise<void> {
84
+ const path = todoContentPath(agentDir, scope)
85
+ const payload: TodoFile = { version: 1, todos }
86
+ await mkdir(dirname(path), { recursive: true })
87
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
88
+ await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
89
+ await rename(tmp, path)
90
+ }
91
+
92
+ export function incompleteTodos(todos: readonly Todo[]): Todo[] {
93
+ return todos.filter((t) => t.status !== 'completed' && t.status !== 'cancelled')
94
+ }
95
+
96
+ function isEnoent(err: unknown): boolean {
97
+ return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'
98
+ }
@@ -0,0 +1,119 @@
1
+ // Minimal structural view of the pieces of pi's AgentSession this module
2
+ // touches. Declared locally (not imported) so the pure nudge logic stays
3
+ // testable with a hand-rolled fake and does not drag in the full session type.
4
+ export type NudgeableSession = {
5
+ subscribe: (listener: (event: unknown) => void) => () => void
6
+ steer: (text: string) => Promise<void>
7
+ }
8
+
9
+ const NOT_FOUND_RE = /^Tool (.+?) not found$/
10
+
11
+ // Levenshtein distance ceiling for a name to count as "did you mean". A typo
12
+ // like web_search -> websearch is distance 1 (one '_' removed); read_file ->
13
+ // read is larger but still a clear prefix relationship. Keeping the ceiling
14
+ // small avoids suggesting an unrelated tool for a genuinely unknown name.
15
+ const MAX_SUGGESTION_DISTANCE = 4
16
+
17
+ export function extractNotFoundToolName(resultText: string): string | null {
18
+ const match = NOT_FOUND_RE.exec(resultText.trim())
19
+ return match?.[1] ?? null
20
+ }
21
+
22
+ export function closestToolName(requested: string, known: readonly string[]): string | null {
23
+ let best: string | null = null
24
+ let bestDistance = Number.POSITIVE_INFINITY
25
+ for (const candidate of known) {
26
+ if (candidate === requested) return candidate
27
+ const distance = boundedLevenshtein(requested, candidate, MAX_SUGGESTION_DISTANCE)
28
+ if (distance < bestDistance) {
29
+ bestDistance = distance
30
+ best = candidate
31
+ }
32
+ }
33
+ return bestDistance <= MAX_SUGGESTION_DISTANCE ? best : null
34
+ }
35
+
36
+ export function renderToolNotFoundNudge(requested: string, suggestion: string): string {
37
+ return (
38
+ `<system-reminder>\n` +
39
+ `You called the tool \`${requested}\`, which does not exist. ` +
40
+ `Did you mean \`${suggestion}\`? Re-issue the call using the exact name \`${suggestion}\`.\n` +
41
+ `</system-reminder>`
42
+ )
43
+ }
44
+
45
+ export function buildToolNotFoundNudge(resultText: string, known: readonly string[]): string | null {
46
+ const requested = extractNotFoundToolName(resultText)
47
+ if (requested === null) return null
48
+ const suggestion = closestToolName(requested, known)
49
+ if (suggestion === null || suggestion === requested) return null
50
+ return renderToolNotFoundNudge(requested, suggestion)
51
+ }
52
+
53
+ function firstTextChunk(result: unknown): string | null {
54
+ const content = (result as { content?: unknown })?.content
55
+ if (!Array.isArray(content)) return null
56
+ for (const part of content) {
57
+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
58
+ const text = (part as { text?: unknown }).text
59
+ if (typeof text === 'string') return text
60
+ }
61
+ }
62
+ return null
63
+ }
64
+
65
+ // Watches a session's tool-execution events and, when the model calls a tool
66
+ // name that does not exist but is a near-miss of a real one, steers a
67
+ // "did you mean" reminder into the running turn so the model self-corrects.
68
+ //
69
+ // This lives here, on the session event stream, because pi-agent-core's
70
+ // `prepareToolCall` returns the `Tool X not found` result BEFORE any
71
+ // `beforeToolCall`/`afterToolCall` hook runs — so TypeClaw's tool.before/after
72
+ // buses never see an unknown tool name. The emitted `tool_execution_end` event
73
+ // is the only seam reachable without forking pi. `steer` (not `followUp`)
74
+ // delivers the reminder after the current assistant turn's tool calls settle,
75
+ // which is exactly when the model is ready to retry.
76
+ //
77
+ // The model re-issues the call under the suggested (canonical) name, so every
78
+ // security guard, budget, and loop-guard keyed on that real name applies
79
+ // normally — unlike a silent alias, this rescue path cannot bypass policy.
80
+ export function attachToolNotFoundNudge(session: NudgeableSession, knownToolNames: readonly string[]): () => void {
81
+ const known = [...new Set(knownToolNames)]
82
+ return session.subscribe((event) => {
83
+ const e = event as { type?: unknown; isError?: unknown; result?: unknown }
84
+ if (e?.type !== 'tool_execution_end' || e.isError !== true) return
85
+ const text = firstTextChunk(e.result)
86
+ if (text === null) return
87
+ const nudge = buildToolNotFoundNudge(text, known)
88
+ if (nudge === null) return
89
+ void session.steer(nudge)
90
+ })
91
+ }
92
+
93
+ // Wagner–Fischer with an early bail-out once every cell in a row exceeds the
94
+ // ceiling: a name far from every candidate never produces a suggestion, and
95
+ // the bound keeps the scan cheap when the known-tool list is large.
96
+ function boundedLevenshtein(a: string, b: string, ceiling: number): number {
97
+ if (a === b) return 0
98
+ if (Math.abs(a.length - b.length) > ceiling) return ceiling + 1
99
+
100
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
101
+ let curr = Array.from({ length: b.length + 1 }, () => 0)
102
+
103
+ for (let i = 1; i <= a.length; i++) {
104
+ curr[0] = i
105
+ let rowMin = i
106
+ for (let j = 1; j <= b.length; j++) {
107
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
108
+ const deletion = (prev[j] ?? 0) + 1
109
+ const insertion = (curr[j - 1] ?? 0) + 1
110
+ const substitution = (prev[j - 1] ?? 0) + cost
111
+ const cell = Math.min(deletion, insertion, substitution)
112
+ curr[j] = cell
113
+ if (cell < rowMin) rowMin = cell
114
+ }
115
+ if (rowMin > ceiling) return ceiling + 1
116
+ ;[prev, curr] = [curr, prev]
117
+ }
118
+ return prev[b.length] ?? ceiling + 1
119
+ }
@@ -80,6 +80,14 @@ export function createChannelReplyTool({
80
80
  'Do not set it just to seem responsive; only when genuine multi-step work follows in the same turn.',
81
81
  }),
82
82
  ),
83
+ resolve_review_thread: Type.Optional(
84
+ Type.Boolean({
85
+ description:
86
+ 'GitHub only. Set `true` when this reply acknowledges that a review-comment thread YOU authored has been addressed, to resolve (close) that thread atomically with the reply. ' +
87
+ 'The thread is resolved BEFORE the acknowledgement is posted, and only if its root comment is yours — so it never closes a human reviewer\'s thread, and a failed resolve blocks the misleading "looks resolved" reply. ' +
88
+ 'Valid only on a github session replying inside a thread (the origin must carry a `thread`). Ignored elsewhere.',
89
+ }),
90
+ ),
83
91
  }),
84
92
 
85
93
  async execute(_toolCallId, params) {
@@ -123,6 +131,22 @@ export function createChannelReplyTool({
123
131
  }
124
132
  }
125
133
 
134
+ // Resolve BEFORE posting: a successful channel_reply ends the turn, so a
135
+ // resolve attempted "after" the ack would never run (the exact bug this
136
+ // flag fixes). Resolve-failure blocks the reply so the agent never posts
137
+ // a "looks resolved" ack next to a still-open thread; the router enforces
138
+ // that only the bot's own threads can be resolved.
139
+ if (params.resolve_review_thread === true) {
140
+ const resolveError = await resolveReviewThreadBeforeReply(router, origin)
141
+ if (resolveError !== null) {
142
+ logger.warn(formatChannelToolFailure('channel_reply', resolveError))
143
+ return {
144
+ content: [{ type: 'text' as const, text: `channel_reply denied: ${resolveError}` }],
145
+ details: { ok: false, error: resolveError },
146
+ }
147
+ }
148
+ }
149
+
126
150
  const result = await router.send({
127
151
  adapter: origin.adapter,
128
152
  workspace: origin.workspace,
@@ -192,6 +216,33 @@ export function createChannelReplyTool({
192
216
  })
193
217
  }
194
218
 
219
+ // Returns an error string when the resolve should block the reply, or null
220
+ // when it's safe to proceed. Only `no-match` (the thread is already gone, so
221
+ // there's nothing to close) joins success as non-blocking; every hard failure
222
+ // — wrong author, permission denial, HTTP 404 on a misdirected lookup,
223
+ // transient API error — blocks, so the agent never claims a thread is settled
224
+ // when the resolve did not actually run.
225
+ async function resolveReviewThreadBeforeReply(
226
+ router: ChannelRouter,
227
+ origin: ChannelReplyOrigin,
228
+ ): Promise<string | null> {
229
+ if (origin.adapter !== 'github') {
230
+ return 'resolve_review_thread is only supported on github sessions.'
231
+ }
232
+ if (origin.thread === null) {
233
+ return 'resolve_review_thread requires replying inside a review thread (no thread on this origin).'
234
+ }
235
+ const result = await router.resolveReviewThread({
236
+ adapter: origin.adapter,
237
+ workspace: origin.workspace,
238
+ chat: origin.chat,
239
+ rootCommentId: origin.thread,
240
+ })
241
+ if (result.ok) return null
242
+ if (result.code === 'no-match') return null
243
+ return `could not resolve review thread: ${result.error}`
244
+ }
245
+
195
246
  // Tool results reach the model as USER-role messages (OpenAI / Anthropic
196
247
  // tool-API contract — the engine cannot tag them as system). Without this
197
248
  // marker a persona-rich model reads its own echo as a fresh user inbound
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
4
  import { requestContainerRestart } from '@/agent/restart'
5
+ import type { RestartHandoffOrigin } from '@/agent/restart-handoff'
5
6
  import type { Stream } from '@/stream'
6
7
 
7
8
  const EXIT_DELAY_MS = 500
@@ -47,11 +48,15 @@ export type CreateRestartToolOptions = {
47
48
  // so the `typeclaw.restart-self` custom message entry that was just
48
49
  // appended is part of the LLM context on the next turn. When omitted,
49
50
  // no handoff is written — the new container cold-starts and no
50
- // "I'm back" greeting fires. Gates the handoff on the origin being a
51
- // TUI session: channel/cron/subagent origins should pass undefined so
52
- // the next boot does not produce a stray channel post or unattended
53
- // greeting (see issue #291's scoping concerns).
51
+ // "I'm back" greeting fires. Written for persisted TUI and channel
52
+ // origins; cron/subagent/system origins pass undefined so the next boot
53
+ // does not resume an unattended session.
54
54
  originatingSessionFile?: string
55
+ // Which subsystem owns resuming the originating session on the next boot
56
+ // (tui → websocket open handler; channel → channel router startup). Required
57
+ // alongside `originatingSessionFile` for the handoff to be written; omit to
58
+ // skip the handoff. See buildRestartHandoffWiring in src/agent/index.ts.
59
+ handoffOrigin?: RestartHandoffOrigin
55
60
  }
56
61
 
57
62
  export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
@@ -69,6 +74,7 @@ export function createRestartTool({
69
74
  ackTimeoutMs,
70
75
  agentDir,
71
76
  originatingSessionFile,
77
+ handoffOrigin,
72
78
  }: CreateRestartToolOptions) {
73
79
  const doExit = exit ?? ((code: number) => process.exit(code))
74
80
 
@@ -114,6 +120,7 @@ export function createRestartTool({
114
120
  ...(stream !== undefined ? { stream } : {}),
115
121
  ...(agentDir !== undefined ? { agentDir } : {}),
116
122
  ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
123
+ ...(handoffOrigin !== undefined ? { handoffOrigin } : {}),
117
124
  })
118
125
  if (!result.ok) {
119
126
  const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
@@ -0,0 +1,119 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { SessionOrigin } from '@/agent/session-origin'
5
+ import { resolveTodoScope, type TodoScope } from '@/agent/todo/scope'
6
+ import { incompleteTodos, type Todo, TODO_PRIORITIES, TODO_STATUSES, readTodos, writeTodos } from '@/agent/todo/store'
7
+
8
+ export type CreateTodoToolsOptions = {
9
+ agentDir: string
10
+ getOrigin: () => SessionOrigin | undefined
11
+ }
12
+
13
+ const NO_SCOPE_NOTICE =
14
+ 'Todos are owned by the originating session. This session (a subagent, system task, or one ' +
15
+ 'with no resolvable origin) does not own a todo list, so the call was a no-op.'
16
+
17
+ type TodoToolDetails = {
18
+ ok: boolean
19
+ reason?: string
20
+ total?: number
21
+ remaining?: number
22
+ }
23
+
24
+ // Resolve the scope for the current origin, or null when this session owns no
25
+ // todo list. An UNDEFINED origin is treated as no-scope, NOT defaulted to the
26
+ // shared TUI scope — defaulting would fail open, silently routing an unknown
27
+ // actor's todos into the operator's global `tui` list.
28
+ function scopeForOrigin(getOrigin: () => SessionOrigin | undefined): TodoScope | null {
29
+ const origin = getOrigin()
30
+ return origin === undefined ? null : resolveTodoScope(origin)
31
+ }
32
+
33
+ const TODO_ITEM = Type.Object({
34
+ content: Type.String({ minLength: 1, description: 'What the task is.' }),
35
+ status: Type.Union(
36
+ TODO_STATUSES.map((s) => Type.Literal(s)),
37
+ { description: 'One of: pending, in_progress, completed, cancelled.' },
38
+ ),
39
+ priority: Type.Optional(Type.Union(TODO_PRIORITIES.map((p) => Type.Literal(p)))),
40
+ id: Type.Optional(Type.String()),
41
+ })
42
+
43
+ export function createTodoTools({ agentDir, getOrigin }: CreateTodoToolsOptions) {
44
+ const writeTool = defineTool({
45
+ name: 'todo_write',
46
+ label: 'Write Todos',
47
+ description:
48
+ 'Replace your entire todo list for this session with the provided items. Maintain a todo ' +
49
+ 'list for any multi-step or long-running task so that if this session is interrupted ' +
50
+ '(restart, crash, or a later turn), you can resume the remaining work instead of silently ' +
51
+ 'dropping it. Mark items `completed` (or `cancelled`) as you finish them by writing the full ' +
52
+ 'list again with updated statuses. This is a full replace, not a merge: include every item ' +
53
+ 'you still care about on each call.',
54
+ parameters: Type.Object({
55
+ todos: Type.Array(TODO_ITEM, { description: 'The complete todo list. Replaces any prior list.' }),
56
+ }),
57
+ async execute(_toolCallId, params) {
58
+ const scope = scopeForOrigin(getOrigin)
59
+ if (scope === null) {
60
+ const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
61
+ return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
62
+ }
63
+ const todos = params.todos as Todo[]
64
+ await writeTodos(agentDir, scope, todos)
65
+ const remaining = incompleteTodos(todos).length
66
+ const details: TodoToolDetails = { ok: true, total: todos.length, remaining }
67
+ return {
68
+ content: [
69
+ {
70
+ type: 'text' as const,
71
+ text: `Saved ${todos.length} todo(s); ${remaining} remaining (${todos.length - remaining} done).`,
72
+ },
73
+ ],
74
+ details,
75
+ }
76
+ },
77
+ })
78
+
79
+ const readTool = defineTool({
80
+ name: 'todo_read',
81
+ label: 'Read Todos',
82
+ description: 'Return your current todo list for this session. Use it to re-sync after an interruption.',
83
+ parameters: Type.Object({}),
84
+ async execute() {
85
+ const scope = scopeForOrigin(getOrigin)
86
+ if (scope === null) {
87
+ const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
88
+ return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
89
+ }
90
+ const todos = await readTodos(agentDir, scope)
91
+ const details: TodoToolDetails = { ok: true, total: todos.length }
92
+ return {
93
+ content: [{ type: 'text' as const, text: JSON.stringify(todos, null, 2) }],
94
+ details,
95
+ }
96
+ },
97
+ })
98
+
99
+ const clearTool = defineTool({
100
+ name: 'todo_clear',
101
+ label: 'Clear Todos',
102
+ description:
103
+ 'Empty your todo list for this session. Call this when all work is genuinely done or the ' +
104
+ 'task was abandoned, so the runtime stops tracking pending work.',
105
+ parameters: Type.Object({}),
106
+ async execute() {
107
+ const scope = scopeForOrigin(getOrigin)
108
+ if (scope === null) {
109
+ const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
110
+ return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
111
+ }
112
+ await writeTodos(agentDir, scope, [])
113
+ const details: TodoToolDetails = { ok: true }
114
+ return { content: [{ type: 'text' as const, text: 'Todo list cleared.' }], details }
115
+ },
116
+ })
117
+
118
+ return [writeTool, readTool, clearTool]
119
+ }
@@ -5,7 +5,7 @@ export const COMMIT_TIMEOUT_MS = 30_000
5
5
  export const NETWORK_TIMEOUT_MS = 60_000
6
6
 
7
7
  const RUNTIME_OWNED_PREFIXES = ['memory/'] as const
8
- const FORCE_ADD_PREFIXES = ['sessions/'] as const
8
+ const FORCE_ADD_PREFIXES = ['sessions/', 'todo/'] as const
9
9
 
10
10
  const NONINTERACTIVE_ENV = {
11
11
  GIT_TERMINAL_PROMPT: '0',