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
@@ -131,11 +131,26 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
131
131
  'pull_request_review.submitted',
132
132
  ] as const
133
133
 
134
+ // Which pull_request webhook action triggers an agent code review. The two
135
+ // event values are GitHub's bare PR action names (the `pull_request.` event
136
+ // prefix is implied by this field living under the review config); `off` is the
137
+ // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
138
+ // - 'review_requested' — review only when the bot is requested (default)
139
+ // - 'opened' — review every PR as soon as it opens
140
+ // - 'off' — disable code review entirely
141
+ export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
142
+
143
+ export type GithubReviewOn = (typeof GITHUB_REVIEW_ON_VALUES)[number]
144
+
145
+ export const DEFAULT_GITHUB_REVIEW_ON: GithubReviewOn = 'review_requested'
146
+
134
147
  // PR-review policy knobs. Grouped under `review` so future toggles
135
- // (`requestChanges`, auto-review-on-request, severity thresholds) cluster
136
- // here instead of flattening onto the channel root.
148
+ // (`requestChanges`, severity thresholds) cluster here instead of flattening
149
+ // onto the channel root.
150
+ //
151
+ // `on` gates which pull_request action triggers a code review (see values above).
137
152
  //
138
- // `approve` gates whether the agent may submit a formal review with
153
+ // `approve` gates *whether* the agent may submit a formal review with
139
154
  // `event: APPROVE`. When `false`, the adapter appends an operator-policy note
140
155
  // to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
141
156
  // verdict to a `COMMENT` review (findings still posted, no formal approval).
@@ -144,9 +159,10 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
144
159
  // temp file the command interceptor never sees.
145
160
  const githubReviewSchema = z
146
161
  .object({
162
+ on: z.enum(GITHUB_REVIEW_ON_VALUES).default(DEFAULT_GITHUB_REVIEW_ON),
147
163
  approve: z.boolean().default(true),
148
164
  })
149
- .default({ approve: true })
165
+ .default({ on: DEFAULT_GITHUB_REVIEW_ON, approve: true })
150
166
 
151
167
  const githubChannelSchema = adapterSchema.extend({
152
168
  // Optional now (PR 2): when omitted and a `tunnels[]` entry with
@@ -52,6 +52,9 @@ export type InboundMessage = {
52
52
  chat: string
53
53
  thread: string | null
54
54
  text: string
55
+ // Prompt-only context for replied-to / quoted / linked messages. Kept out
56
+ // of `text` so the engagement gate sees only the human-authored body.
57
+ referenceContext?: InboundReferenceContext
55
58
  // Non-text attachments the user sent on this inbound. Empty / omitted
56
59
  // when the message is text-only. The router carries these through to
57
60
  // the live session's promptQueue/contextBuffer so channel tools can
@@ -94,6 +97,15 @@ export type InboundMessage = {
94
97
  // means "unknown" — the formatter renders such lines without a
95
98
  // timestamp prefix instead of stamping them with the wrong clock.
96
99
  ts: number
100
+ // Platform-native anchor for showing a typing/status indicator, kept
101
+ // SEPARATE from `thread` on purpose: `thread` drives reply threading,
102
+ // this drives ONLY the typing surface. Slack's `assistant.threads.
103
+ // setStatus` (the bot's only typing signal) requires a real message ts
104
+ // even in a flat DM, where `thread` is null because replies stay top-
105
+ // level. The classifier sets this to the inbound message ts for DMs so
106
+ // the status can render without forcing the reply into a thread. Omitted
107
+ // for non-DM inbounds, where the typing path falls back to `thread`.
108
+ typingThread?: string
97
109
  // Opaque, adapter-owned handle for the entity an emoji reaction would
98
110
  // attach to. The classifier stamps it because only there is the platform-
99
111
  // side target type still known (GitHub: issue body vs issue-comment vs
@@ -183,6 +195,10 @@ export type OutboundMessage = {
183
195
  // `uploadFile` does not accept a content body or a thread id, see the
184
196
  // adapter for the workaround details.
185
197
  attachments?: OutboundAttachment[]
198
+ // Typing-only anchor (see InboundMessage.typingThread), stamped by the
199
+ // router from the live session so the adapter can CLEAR the status after a
200
+ // flat DM send — where `thread` is null and would otherwise no-op the clear.
201
+ typingThread?: string
186
202
  // Set by the router (native render mode + anchor fired) so an adapter can
187
203
  // reply to the inbound it answers. Telegram/Discord consume `externalMessageId`;
188
204
  // `quote`-mode adapters never see this (the router prepends the blockquote into
@@ -207,6 +223,11 @@ export type QuoteAnchorSource = {
207
223
  text: string
208
224
  }
209
225
 
226
+ export type InboundReferenceContext = {
227
+ kind: 'reply' | 'quote' | 'link'
228
+ sources: readonly QuoteAnchorSource[]
229
+ }
230
+
210
231
  export type SendErrorCode =
211
232
  | 'duplicate'
212
233
  | 'turn-cap'
@@ -224,6 +245,10 @@ export type TypingTarget = {
224
245
  workspace: string
225
246
  chat: string
226
247
  thread?: string | null
248
+ // Typing-only anchor (see InboundMessage.typingThread). An adapter whose
249
+ // typing surface needs a message ts even when `thread` is null (Slack DMs)
250
+ // reads this first and falls back to `thread`. Never used for reply routing.
251
+ typingThread?: string
227
252
  // 'tick' is the heartbeat fired during debouncing/generation; adapters
228
253
  // should set the indicator visible. 'stop' is fired exactly once when the
229
254
  // router decides the turn is over (drain finally, /stop command, or
@@ -243,6 +268,33 @@ export type ResolvedChannelNames = {
243
268
 
244
269
  export type ChannelNameResolver = (key: ChannelKey) => Promise<ResolvedChannelNames>
245
270
 
271
+ // The bot's OWN identity on a platform, surfaced into the channel system
272
+ // prompt so the model recognizes mentions of itself. The engagement gate
273
+ // already knows this id (it sets `isBotMention`), but the model only knows
274
+ // its NAME (from identity files) — not its platform user id. Without this,
275
+ // a message addressed to `<@U0ABFG8TYN7>` (the bot's own Slack id) reads to
276
+ // the model as "addressed to someone else" and it skips a turn it was
277
+ // correctly engaged for.
278
+ //
279
+ // - `id` is the raw platform user id (Slack `U…`, Discord snowflake,
280
+ // Telegram numeric id as string, GitHub numeric id as string). For
281
+ // angle-id platforms this is what appears inside `<@…>`.
282
+ // - `username` is the human-typed handle used for at-mentions on platforms
283
+ // where the id is NOT what gets typed (Telegram `@username`, GitHub
284
+ // `@login`). Omitted when the platform mentions by id, or when the
285
+ // account simply has no username.
286
+ export type ChannelSelfIdentity = {
287
+ id: string
288
+ username?: string
289
+ }
290
+
291
+ // Resolves the bot's own identity for a given workspace. `workspace` is
292
+ // passed because identity is conceptually per-workspace (Slack team); most
293
+ // adapters serve a single identity and ignore the argument. Returns null
294
+ // when identity is not yet resolved (startup race) or unknown — callers
295
+ // MUST treat null as "omit the self-mention prompt line", never as an error.
296
+ export type ChannelSelfIdentityResolver = (workspace: string) => ChannelSelfIdentity | null
297
+
246
298
  // History entries are intentionally distinct from InboundMessage:
247
299
  // `InboundMessage` carries router-classification fields (`isBotMention`,
248
300
  // `isDm`) that are turn-delivery concerns, not history concerns. History
@@ -253,6 +305,7 @@ export type ChannelHistoryMessage = {
253
305
  authorId: string
254
306
  authorName: string
255
307
  text: string
308
+ referenceContext?: InboundReferenceContext
256
309
  attachments?: readonly InboundAttachment[]
257
310
  ts: number
258
311
  isBot: boolean
@@ -287,6 +340,48 @@ export type FetchAttachmentResult =
287
340
 
288
341
  export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
289
342
 
343
+ // A request to resolve (close out) a review-comment thread the bot itself
344
+ // opened, after the author addressed it. Adapter-specific: only the github
345
+ // adapter registers a resolver today. The router carries the request through
346
+ // to that resolver, which is responsible for the platform-side authorship
347
+ // check — `resolveReviewThread` MUST only close a thread whose root comment
348
+ // the bot authored, never a human reviewer's thread. The address fields below
349
+ // are the same ones a `channel_reply` origin carries: `workspace` is the repo
350
+ // slug `owner/name`, `chat` is `pr:<N>`, and `rootCommentId` is the numeric id
351
+ // of the thread's root comment (the `thread` value the inbound carried).
352
+ export type ReviewThreadResolveRequest = {
353
+ adapter: AdapterId
354
+ workspace: string
355
+ chat: string
356
+ rootCommentId: string
357
+ }
358
+
359
+ // `already-resolved` is a success-shaped no-op: the thread was closed before we
360
+ // got here (a duplicate turn, a manual resolve), so the desired end state holds
361
+ // and the caller should treat it like `ok: true`. `not-author` is a hard
362
+ // refusal: the root comment is not the bot's, so resolving would erase a
363
+ // human's open question — the caller must NOT proceed as if it closed the loop.
364
+ //
365
+ // `no-match` is the ONLY non-blocking failure: the PR's threads listed cleanly
366
+ // but none is rooted at this comment (already deleted, or the wrong target),
367
+ // so there is genuinely nothing to close and an acknowledgement may still post.
368
+ // Every other code is a hard failure — `not-found` here means an HTTP 404 from
369
+ // the API (a real problem, e.g. wrong repo/PR), NOT "no such thread"; a caller
370
+ // must treat it as blocking so it never claims a thread is settled on a failed
371
+ // or misdirected lookup.
372
+ export type ReviewThreadResolveResult =
373
+ | { ok: true; alreadyResolved?: boolean }
374
+ | {
375
+ ok: false
376
+ error: string
377
+ code?: 'not-author' | 'no-match' | 'not-found' | 'unsupported' | 'permission-denied' | 'transient'
378
+ }
379
+
380
+ // Registered per-adapter on the ChannelRouter, last-write-wins like the
381
+ // self-identity resolver (one bot account per adapter). Adapters that do not
382
+ // support review threads never register one; the router answers `unsupported`.
383
+ export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
384
+
290
385
  export function channelKeyId(key: ChannelKey): string {
291
386
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
292
387
  }
@@ -14,6 +14,8 @@ export type EscController = {
14
14
  dispose: () => void
15
15
  }
16
16
 
17
+ const QUIT_KEY = 0x71
18
+
17
19
  export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
18
20
  let currentCtrl: AbortController | null = null
19
21
  let pendingEsc: ReturnType<typeof setTimeout> | null = null
@@ -64,3 +66,100 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
64
66
  },
65
67
  }
66
68
  }
69
+
70
+ export type TailIntent = 'back' | 'exit'
71
+
72
+ export type TailScope = {
73
+ signal: AbortSignal
74
+ // null when the tail ended on its own (stream closed / replay-only); the loop
75
+ // treats null the same as 'back'. The stream only ever sees signal.aborted —
76
+ // intent is read by the loop, keeping abort decoupled from what abort meant.
77
+ intent: () => TailIntent | null
78
+ dispose: () => void
79
+ }
80
+
81
+ type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'on' | 'off'>
82
+
83
+ type ProcessSignals = Pick<NodeJS.Process, 'once' | 'off'>
84
+
85
+ // One disposable interaction scope per live-tail iteration. Creates a FRESH
86
+ // AbortController, installs a temporary raw-mode 'data' listener plus
87
+ // SIGINT/SIGTERM handlers, and tears all of it down on dispose(). This mirrors
88
+ // the `dreams` viewer-key pattern: raw mode is scoped to a single tail attempt
89
+ // and never survives into the clack picker, which removes the pause/resume
90
+ // state machine that made the old inspect listener fragile.
91
+ export function createTailScope(opts: { debounceMs: number; input?: RawInput; proc?: ProcessSignals }): TailScope {
92
+ const stdin = opts.input ?? process.stdin
93
+ const proc = opts.proc ?? process
94
+ const controller = new AbortController()
95
+ let intent: TailIntent | null = null
96
+ let disposed = false
97
+
98
+ const settle = (next: TailIntent): void => {
99
+ if (intent === null) intent = next
100
+ controller.abort()
101
+ }
102
+
103
+ const onSigExit = (): void => {
104
+ settle('exit')
105
+ }
106
+
107
+ const isTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function'
108
+ const esc = isTty ? createEscController({ debounceMs: opts.debounceMs }) : null
109
+ const escSignal = esc?.armForStream()
110
+ // A bare ESC fires through the debounce controller, not the 'data' handler:
111
+ // route its abort into 'back' intent here so the loop can re-open the picker.
112
+ const onEscAbort = (): void => settle('back')
113
+
114
+ const onData = (chunk: Buffer): void => {
115
+ if (esc === null) return
116
+ if (chunk[0] === QUIT_KEY) {
117
+ // q mirrors dreams' quit key and is symmetric with Ctrl-C in live tail.
118
+ settle('exit')
119
+ return
120
+ }
121
+ const { sigint } = esc.onChunk(chunk)
122
+ if (sigint) settle('exit')
123
+ }
124
+
125
+ const dispose = (): void => {
126
+ if (disposed) return
127
+ disposed = true
128
+ proc.off('SIGINT', onSigExit)
129
+ proc.off('SIGTERM', onSigExit)
130
+ escSignal?.removeEventListener('abort', onEscAbort)
131
+ if (esc !== null) {
132
+ stdin.off('data', onData)
133
+ esc.dispose()
134
+ try {
135
+ stdin.setRawMode(false)
136
+ } catch {
137
+ /* terminal already torn down */
138
+ }
139
+ // Deliberately NOT stdin.pause(): a paused process.stdin does not reliably
140
+ // re-flow into the next clack picker under Bun (same reason as
141
+ // prepareStdinForClack / dreams' waitForViewerKey). Leave it flowing.
142
+ }
143
+ // Abort last so a stream still awaiting on this signal unblocks during
144
+ // teardown rather than hanging.
145
+ controller.abort()
146
+ }
147
+
148
+ proc.once('SIGINT', onSigExit)
149
+ proc.once('SIGTERM', onSigExit)
150
+
151
+ if (esc !== null && escSignal !== undefined) {
152
+ escSignal.addEventListener('abort', onEscAbort, { once: true })
153
+ stdin.setRawMode(true)
154
+ // Attach the data handler before resume() so no raw-mode keystroke slips
155
+ // through between resuming the stream and registering the listener.
156
+ stdin.on('data', onData)
157
+ stdin.resume()
158
+ }
159
+
160
+ return {
161
+ signal: controller.signal,
162
+ intent: () => intent,
163
+ dispose,
164
+ }
165
+ }
@@ -5,10 +5,10 @@ import { findAgentDir } from '@/init'
5
5
  import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
- import { createEscController } from './inspect-controller'
8
+ import { createTailScope } from './inspect-controller'
9
9
  import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
10
10
 
11
- const ESC_LISTEN_DELAY_MS = 50
11
+ const ESC_DEBOUNCE_MS = 50
12
12
 
13
13
  export const inspectCommand = defineCommand({
14
14
  meta: {
@@ -45,46 +45,24 @@ export const inspectCommand = defineCommand({
45
45
 
46
46
  const isJson = args.json === true
47
47
  const liveSource = isJson ? undefined : await buildLiveSource(cwd)
48
- const signalCtrl = installSigintAbort()
49
- const signal = signalCtrl.signal
50
- // Raw-mode Ctrl-C arrives as byte 0x03 and must abort the exit controller
51
- // directly: under Bun a self-issued process.kill(SIGINT) does not reliably
52
- // re-enter our process.once('SIGINT') handler, so the live tail never exits.
53
- const escListener = isJson ? null : createEscListener(() => signalCtrl.abort())
54
- const liveHint = escListener === null ? undefined : escHintLine(color)
55
-
56
- // try/finally so a thrown loop never leaves the terminal stuck in raw mode.
57
- let result: Awaited<ReturnType<typeof runInspectLoop>>
58
- try {
59
- result = await runInspectLoop({
60
- agentDir: cwd,
61
- ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
62
- ...(filterArg !== undefined ? { filter: filterArg } : {}),
63
- ...(sinceArg !== undefined ? { since: sinceArg } : {}),
64
- json: isJson,
65
- color,
66
- selectSession: (sessions, selectOpts) => {
67
- escListener?.pause()
68
- return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
69
- escListener?.resume()
70
- })
71
- },
72
- ...(liveSource !== undefined ? { liveSource } : {}),
73
- signal,
74
- newEscSignal: () => {
75
- if (escListener === null) return new AbortController().signal
76
- return escListener.armForStream()
77
- },
78
- afterEscStream: () => {
79
- escListener?.pause()
80
- },
81
- ...(liveHint !== undefined ? { liveHint } : {}),
82
- stdout: (line) => process.stdout.write(`${line}\n`),
83
- stderr: (line) => process.stderr.write(`${line}\n`),
84
- })
85
- } finally {
86
- escListener?.stop()
87
- }
48
+ const interactive = !isJson && Boolean(process.stdin.isTTY)
49
+ const liveHint = interactive ? escHintLine(color) : undefined
50
+
51
+ const result = await runInspectLoop({
52
+ agentDir: cwd,
53
+ ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
54
+ ...(filterArg !== undefined ? { filter: filterArg } : {}),
55
+ ...(sinceArg !== undefined ? { since: sinceArg } : {}),
56
+ json: isJson,
57
+ color,
58
+ selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
59
+ ...(liveSource !== undefined ? { liveSource } : {}),
60
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
61
+ ...(interactive ? { interactive: true } : {}),
62
+ ...(liveHint !== undefined ? { liveHint } : {}),
63
+ stdout: (line) => process.stdout.write(`${line}\n`),
64
+ stderr: (line) => process.stderr.write(`${line}\n`),
65
+ })
88
66
 
89
67
  if (!result.ok) {
90
68
  process.stderr.write(`${errorLine(result.reason)}\n`)
@@ -115,88 +93,8 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
115
93
  })
116
94
  }
117
95
 
118
- function installSigintAbort(): AbortController {
119
- const ctrl = new AbortController()
120
- const onSig = (): void => {
121
- ctrl.abort()
122
- }
123
- process.once('SIGINT', onSig)
124
- process.once('SIGTERM', onSig)
125
- return ctrl
126
- }
127
-
128
- type EscListener = {
129
- armForStream: () => AbortSignal
130
- pause: () => void
131
- resume: () => void
132
- stop: () => void
133
- }
134
-
135
- type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
136
-
137
- export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
138
- const stdin = input
139
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
140
-
141
- const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
142
- let active = false
143
-
144
- const onData = (chunk: Buffer): void => {
145
- const { sigint } = ctrl.onChunk(chunk)
146
- if (sigint) onSigint()
147
- }
148
-
149
- const start = (): void => {
150
- if (active) return
151
- active = true
152
- stdin.setRawMode(true)
153
- // Attach the data handler before resume() so no raw-mode keystroke can slip
154
- // through between resuming the stream and registering the listener.
155
- stdin.on('data', onData)
156
- stdin.resume()
157
- }
158
- const stop = (): void => {
159
- if (!active) return
160
- active = false
161
- stdin.off('data', onData)
162
- try {
163
- stdin.setRawMode(false)
164
- } catch {
165
- /* terminal already torn down */
166
- }
167
- // Do NOT pause stdin here: this teardown hands control to the clack picker,
168
- // and under Bun clack does not reliably re-flow a previously paused
169
- // process.stdin, so its keypresses never arrive and arrow keys echo as raw
170
- // bytes. Leaving the stream flowing lets clack own raw mode during the picker.
171
- ctrl.clearPending()
172
- }
173
-
174
- return {
175
- armForStream: () => {
176
- const signal = ctrl.armForStream()
177
- start()
178
- return signal
179
- },
180
- pause: () => {
181
- stop()
182
- },
183
- resume: () => {
184
- // Resume the listener WITHOUT replacing the AbortController.
185
- // The signal returned by armForStream() is held by the live source
186
- // through streamSession's combinedSignal; replacing the controller
187
- // here would orphan that signal so a subsequent ESC press could
188
- // not abort the live tail.
189
- start()
190
- },
191
- stop: () => {
192
- ctrl.dispose()
193
- stop()
194
- },
195
- }
196
- }
197
-
198
96
  function escHintLine(color: boolean): string {
199
- const text = '(press esc to return to session list)'
97
+ const text = '(esc to return to session list · q to quit)'
200
98
  return color ? `\u001b[2m${text}\u001b[0m` : text
201
99
  }
202
100
 
@@ -25,6 +25,13 @@ export type Command<Context> = {
25
25
  description: string
26
26
  permission?: CommandPermission
27
27
  requiresLiveSession?: boolean
28
+ // Resolve an existing live session into the handler context WITHOUT failing
29
+ // when none exists (distinct from `requiresLiveSession`, which aborts the
30
+ // command if no session is live). Used by /restart: it must bounce the
31
+ // container even from a cold channel, but when a session IS live it needs
32
+ // that session's identity to write a resume handoff. Ignored when
33
+ // `requiresLiveSession` is true (that path already resolves the session).
34
+ wantsLiveSession?: boolean
28
35
  handler: CommandHandler<Context>
29
36
  }
30
37
 
@@ -42,6 +49,7 @@ export type CommandInfo = {
42
49
  description: string
43
50
  permission: CommandPermission
44
51
  requiresLiveSession: boolean
52
+ wantsLiveSession: boolean
45
53
  }
46
54
 
47
55
  export type CommandResult =
@@ -74,6 +82,7 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
74
82
  description: command.description,
75
83
  permission: command.permission ?? 'session.control',
76
84
  requiresLiveSession: command.requiresLiveSession ?? true,
85
+ wantsLiveSession: command.wantsLiveSession ?? false,
77
86
  })
78
87
 
79
88
  return {
@@ -32,16 +32,19 @@ auth.json
32
32
  node_modules/
33
33
  packages/*/node_modules/
34
34
  workspace/
35
+ public/
35
36
  mounts/
36
37
  Dockerfile
37
38
  .DS_Store
38
39
 
39
40
  # System-managed: gitignored by default so the agent never stages them by hand,
40
- # but TypeClaw force-commits them on its own schedule (sessions/ via auto-backup,
41
- # memory/ via the dreaming subagent). Treat them as runtime-owned, not agent-owned.
41
+ # but TypeClaw force-commits them on its own schedule (sessions/ + todo/ via
42
+ # auto-backup, memory/ via the dreaming subagent). Treat them as runtime-owned,
43
+ # not agent-owned.
42
44
  sessions/
43
45
  memory/
44
46
  channels/
47
+ todo/
45
48
  `
46
49
  }
47
50
 
@@ -30,11 +30,11 @@ export type RunInspectOptions = {
30
30
  stdout: (line: string) => void
31
31
  stderr: (line: string) => void
32
32
  liveSource?: LiveSourceFactory
33
+ // Aborting this signal stops the live tail and returns escToPicker=true; the
34
+ // caller's loop inspects its own scope intent to tell back from exit.
33
35
  signal?: AbortSignal
34
- // Aborting escSignal (and only escSignal) returns escToPicker=true so a
35
- // caller-side loop can re-open the picker; signal still means process exit.
36
- escSignal?: AbortSignal
37
36
  liveHint?: string
37
+ interactive?: boolean
38
38
  }
39
39
 
40
40
  export type SelectSessionOptions = {
@@ -81,8 +81,8 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
81
81
  stderr: opts.stderr,
82
82
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
83
83
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
84
- ...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
85
84
  ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
85
+ ...(opts.interactive === true ? { interactive: true } : {}),
86
86
  })
87
87
  if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
88
88
  return { ok: true, exitCode: 0 }
@@ -147,8 +147,8 @@ async function streamSession(opts: {
147
147
  stderr: (line: string) => void
148
148
  liveSource?: LiveSourceFactory
149
149
  signal?: AbortSignal
150
- escSignal?: AbortSignal
151
150
  liveHint?: string
151
+ interactive?: boolean
152
152
  }): Promise<{ escToPicker: boolean }> {
153
153
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
154
154
  const emit = (event: InspectEvent): void => {
@@ -161,26 +161,37 @@ async function streamSession(opts: {
161
161
  }
162
162
  }
163
163
 
164
- const escAborted = (): boolean => opts.escSignal?.aborted === true
164
+ const aborted = (): boolean => opts.signal?.aborted === true
165
165
 
166
166
  for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
167
- if (escAborted()) return { escToPicker: true }
167
+ if (aborted()) return { escToPicker: true }
168
168
  emit(event)
169
169
  }
170
170
 
171
171
  if (opts.liveSource === undefined) {
172
172
  if (!opts.json) opts.stdout('─── end of transcript ───')
173
- return { escToPicker: escAborted() }
173
+ // Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
174
+ if (aborted()) return { escToPicker: true }
175
+ // Interactive replay-only: hold a stable viewer like `dreams` instead of
176
+ // bouncing straight back to the picker. Block until the tail scope aborts
177
+ // (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
178
+ // no listener and would hang) or in json/non-interactive mode (scriptability).
179
+ if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
180
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
181
+ opts.stdout(divider(opts.color, opts.liveHint))
182
+ }
183
+ await waitForAbort(opts.signal)
184
+ }
185
+ return { escToPicker: aborted() }
174
186
  }
175
187
 
176
- if (escAborted()) return { escToPicker: true }
188
+ if (aborted()) return { escToPicker: true }
177
189
 
178
- const combinedSignal = combineSignals(opts.signal, opts.escSignal)
179
190
  let sessionLive = false
180
191
  const liveIter = opts.liveSource({
181
192
  sessionId: opts.summary.sessionId,
182
193
  ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
183
- ...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
194
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
184
195
  onSubscribed: (live) => {
185
196
  sessionLive = live
186
197
  },
@@ -204,21 +215,7 @@ async function streamSession(opts: {
204
215
  opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
205
216
  }
206
217
  if (!opts.json) opts.stdout('─── end of transcript ───')
207
- return { escToPicker: escAborted() && opts.signal?.aborted !== true }
208
- }
209
-
210
- function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
211
- if (a === undefined) return b
212
- if (b === undefined) return a
213
- if (a.aborted) return a
214
- if (b.aborted) return b
215
- const ctrl = new AbortController()
216
- const onAbort = (): void => {
217
- ctrl.abort()
218
- }
219
- a.addEventListener('abort', onAbort, { once: true })
220
- b.addEventListener('abort', onAbort, { once: true })
221
- return ctrl.signal
218
+ return { escToPicker: aborted() }
222
219
  }
223
220
 
224
221
  function divider(color: boolean, text: string): string {
@@ -226,6 +223,13 @@ function divider(color: boolean, text: string): string {
226
223
  return text
227
224
  }
228
225
 
226
+ export async function waitForAbort(signal: AbortSignal): Promise<void> {
227
+ if (signal.aborted) return
228
+ await new Promise<void>((resolve) => {
229
+ signal.addEventListener('abort', () => resolve(), { once: true })
230
+ })
231
+ }
232
+
229
233
  function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
230
234
  const id = shortSessionId(summary.sessionId)
231
235
  const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)