typeclaw 0.23.0 → 0.25.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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -131,11 +131,27 @@ 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 non-draft PR as soon as it opens; draft
140
+ // PRs are skipped until an explicit review_requested
141
+ // - 'off' — disable code review entirely
142
+ export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
143
+
144
+ export type GithubReviewOn = (typeof GITHUB_REVIEW_ON_VALUES)[number]
145
+
146
+ export const DEFAULT_GITHUB_REVIEW_ON: GithubReviewOn = 'review_requested'
147
+
134
148
  // 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.
149
+ // (`requestChanges`, severity thresholds) cluster here instead of flattening
150
+ // onto the channel root.
151
+ //
152
+ // `on` gates which pull_request action triggers a code review (see values above).
137
153
  //
138
- // `approve` gates whether the agent may submit a formal review with
154
+ // `approve` gates *whether* the agent may submit a formal review with
139
155
  // `event: APPROVE`. When `false`, the adapter appends an operator-policy note
140
156
  // to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
141
157
  // verdict to a `COMMENT` review (findings still posted, no formal approval).
@@ -144,9 +160,10 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
144
160
  // temp file the command interceptor never sees.
145
161
  const githubReviewSchema = z
146
162
  .object({
163
+ on: z.enum(GITHUB_REVIEW_ON_VALUES).default(DEFAULT_GITHUB_REVIEW_ON),
147
164
  approve: z.boolean().default(true),
148
165
  })
149
- .default({ approve: true })
166
+ .default({ on: DEFAULT_GITHUB_REVIEW_ON, approve: true })
150
167
 
151
168
  const githubChannelSchema = adapterSchema.extend({
152
169
  // Optional now (PR 2): when omitted and a `tunnels[]` entry with
@@ -47,27 +47,23 @@ const consoleLogger: SubagentCompletionBridgeLogger = {
47
47
  // `LiveSession`, so the lookup is O(N) over live sessions with N small
48
48
  // (one per active conversation).
49
49
  //
50
- // On `no-live-session`, we silently drop. Three observable paths reach
51
- // this branch in production:
50
+ // On `no-live-session`, we drop the reminder. When the parent was a
51
+ // channel session, the broadcast now carries the channel-key coordinate
52
+ // `{ adapter, workspace, chat, thread }`, and the router first tries to
53
+ // reroute to the live successor session for that key — covering the two
54
+ // common drop paths where the exact sessionId is gone but the
55
+ // conversation lives on:
52
56
  //
53
57
  // - The parent session was GC'd by the idle-eviction tick
54
58
  // (SESSION_IDLE_MS) while the subagent was running.
55
59
  // - The parent session rolled over (SESSION_FRESHNESS_TTL_MS) when a
56
- // new inbound arrived during a long-running subagent — the channel
57
- // conversation continues on the new sessionId, but the broadcast
58
- // still carries the old one.
59
- // - The parent was a TUI session (the TUI bridge in
60
- // src/server/index.ts handles it).
60
+ // new inbound arrived during a long-running subagent.
61
61
  //
62
- // The right fix for the first two paths is for the broadcast to carry
63
- // the channel-key coordinate `{ adapter, workspace, chat, thread }` so
64
- // the bridge can fall back to "any live session for the same channel
65
- // key" when the exact sessionId no longer matches. That requires
66
- // extending the broadcast payload (consumed by TUI and channel paths)
67
- // and gating spawn_subagent to capture the origin coordinates — both
68
- // non-trivial. Deferred until we see this drop pattern in production
69
- // logs; the info log line below makes the case diagnosable from logs
70
- // alone.
62
+ // A reminder still reaching this branch means there is no live session
63
+ // for the key at all (the whole conversation went idle), or the parent
64
+ // was a TUI session (handled by the TUI bridge in src/server/index.ts).
65
+ // Logged at warn with the channel key so an undelivered completion is
66
+ // diagnosable from logs alone.
71
67
  export function createSubagentCompletionBridge(options: SubagentCompletionBridgeOptions): SubagentCompletionBridge {
72
68
  const logger = options.logger ?? consoleLogger
73
69
  const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -75,8 +71,12 @@ export function createSubagentCompletionBridge(options: SubagentCompletionBridge
75
71
  if (parsed === null) return
76
72
  const result = options.router.injectSubagentCompletionReminder(parsed)
77
73
  if (result.kind === 'no-live-session') {
78
- logger.info(
79
- `[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}`,
74
+ const keyInfo =
75
+ parsed.channelKey !== undefined
76
+ ? ` channelKey=${parsed.channelKey.adapter}:${parsed.channelKey.workspace}:${parsed.channelKey.chat}:${parsed.channelKey.thread ?? ''}`
77
+ : ''
78
+ logger.warn(
79
+ `[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}${keyInfo}`,
80
80
  )
81
81
  }
82
82
  })
@@ -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
@@ -280,6 +305,7 @@ export type ChannelHistoryMessage = {
280
305
  authorId: string
281
306
  authorName: string
282
307
  text: string
308
+ referenceContext?: InboundReferenceContext
283
309
  attachments?: readonly InboundAttachment[]
284
310
  ts: number
285
311
  isBot: boolean
@@ -314,6 +340,48 @@ export type FetchAttachmentResult =
314
340
 
315
341
  export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
316
342
 
317
- export function channelKeyId(key: ChannelKey): string {
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
+
385
+ export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
318
386
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
319
387
  }
@@ -1,11 +1,17 @@
1
- // Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
2
- // Owns the AbortController lifecycle and the bare-ESC debounce timer,
3
- // independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
4
- // Extracted for testability: the lifecycle bug we want to pin is "armForStream's
5
- // signal must remain valid across pause()/resume() cycles" verifying that without
6
- // a real TTY requires this seam.
1
+ // Pure controller for the inspect CLI's esc/ctrl-c/quit key dispatch.
2
+ // Owns the AbortController lifecycle and a VT-input parser, independent of
3
+ // process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
4
+ //
5
+ // Input is parsed byte-by-byte through a small escape-sequence state machine so
6
+ // that arrow keys and other CSI/SS3 sequences can never be mistaken for a bare
7
+ // ESC — regardless of how the bytes are split across 'data' events. The old
8
+ // implementation used a 50ms wall-clock debounce to tell "ESC" from "ESC ["; on
9
+ // a laggy SSH link the inter-byte gap of a single arrow key routinely exceeds
10
+ // 50ms, so the leading ESC fired 'back' mid-keystroke and bounced the user out
11
+ // of the viewer. The parser below makes CSI/SS3 always win, with a much longer
12
+ // idle fallback used ONLY to resolve a genuinely-trailing bare ESC.
7
13
 
8
- export type EscChunkResult = { sigint: boolean }
14
+ export type EscChunkResult = { sigint: boolean; quit: boolean }
9
15
 
10
16
  export type EscController = {
11
17
  armForStream: () => AbortSignal
@@ -14,15 +20,58 @@ export type EscController = {
14
20
  dispose: () => void
15
21
  }
16
22
 
23
+ const ESC = 0x1b
24
+ const CSI_INTRODUCER = 0x5b
25
+ const SS3_INTRODUCER = 0x4f
26
+ const CTRL_C = 0x03
27
+ const QUIT_KEY = 0x71
28
+
29
+ type ParseState = 'idle' | 'sawEsc' | 'csi' | 'ss3'
30
+
31
+ // A CSI sequence ends at a final byte in 0x40..0x7e (e.g. arrow keys 'A'..'D',
32
+ // '~' for nav keys, 'M'/'m' for mouse). Parameter/intermediate bytes (0x20..0x3f)
33
+ // are consumed without ending it.
34
+ function isCsiFinal(byte: number): boolean {
35
+ return byte >= 0x40 && byte <= 0x7e
36
+ }
37
+
38
+ // C0 controls (0x00..0x1f plus DEL 0x7f) are not legal sequence-body bytes.
39
+ function isC0Control(byte: number): boolean {
40
+ return byte <= 0x1f || byte === 0x7f
41
+ }
42
+
17
43
  export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
18
44
  let currentCtrl: AbortController | null = null
45
+ let state: ParseState = 'idle'
19
46
  let pendingEsc: ReturnType<typeof setTimeout> | null = null
47
+ // A trailing ESC with no following byte cannot be proven "bare" without
48
+ // waiting. Use a generous idle window (>= debounceMs) so SSH-fragmented
49
+ // sequences whose continuation is still in flight are never misread.
50
+ const bareEscIdleMs = Math.max(debounceMs, 500)
20
51
 
21
52
  const clearPending = (): void => {
22
53
  if (pendingEsc !== null) {
23
54
  clearTimeout(pendingEsc)
24
55
  pendingEsc = null
25
56
  }
57
+ state = 'idle'
58
+ }
59
+
60
+ const scheduleBareEsc = (): void => {
61
+ if (pendingEsc !== null) clearTimeout(pendingEsc)
62
+ const ctrl = currentCtrl
63
+ pendingEsc = setTimeout(() => {
64
+ pendingEsc = null
65
+ state = 'idle'
66
+ ctrl?.abort()
67
+ }, bareEscIdleMs)
68
+ }
69
+
70
+ const cancelPendingTimer = (): void => {
71
+ if (pendingEsc !== null) {
72
+ clearTimeout(pendingEsc)
73
+ pendingEsc = null
74
+ }
26
75
  }
27
76
 
28
77
  return {
@@ -32,34 +81,81 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
32
81
  return currentCtrl.signal
33
82
  },
34
83
  onChunk: (chunk) => {
35
- if (chunk.length === 0) return { sigint: false }
36
- if (chunk[0] === 0x03) {
37
- // Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
38
- // SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
39
- // we deliberately keep the AbortController lifecycle separate from SIGINT.
40
- return { sigint: true }
41
- }
42
- if (chunk.length === 1 && chunk[0] === 0x1b) {
43
- // Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
44
- // sequences from arrow keys, mouse, paste) cancels the pending fire.
45
- // Snapshot currentCtrl so a late-firing timer can't abort a controller
46
- // created by a subsequent armForStream() call.
47
- clearPending()
48
- const ctrl = currentCtrl
49
- pendingEsc = setTimeout(() => {
50
- pendingEsc = null
51
- ctrl?.abort()
52
- }, debounceMs)
53
- return { sigint: false }
84
+ let sigint = false
85
+ let quit = false
86
+ for (const byte of chunk) {
87
+ switch (state) {
88
+ case 'idle':
89
+ if (byte === ESC) {
90
+ state = 'sawEsc'
91
+ scheduleBareEsc()
92
+ } else if (byte === CTRL_C) {
93
+ sigint = true
94
+ } else if (byte === QUIT_KEY) {
95
+ quit = true
96
+ }
97
+ break
98
+ case 'sawEsc':
99
+ if (byte === CSI_INTRODUCER) {
100
+ cancelPendingTimer()
101
+ state = 'csi'
102
+ } else if (byte === SS3_INTRODUCER) {
103
+ cancelPendingTimer()
104
+ state = 'ss3'
105
+ } else if (byte === CTRL_C || byte === QUIT_KEY) {
106
+ // ESC then an exit key: exit must win over the pending bare-ESC
107
+ // 'back'. Drop the pending ESC WITHOUT aborting (abort would settle
108
+ // 'back' synchronously and pre-empt the exit), and surface the key.
109
+ cancelPendingTimer()
110
+ state = 'idle'
111
+ if (byte === CTRL_C) sigint = true
112
+ else quit = true
113
+ } else if (byte === ESC) {
114
+ // The first ESC was bare; abort now and keep this ESC pending.
115
+ cancelPendingTimer()
116
+ currentCtrl?.abort()
117
+ state = 'sawEsc'
118
+ scheduleBareEsc()
119
+ } else {
120
+ // ESC + an ordinary byte: the ESC was bare. Abort to 'back' and
121
+ // drop the trailing byte (e.g. Alt+key is treated as a bare ESC).
122
+ cancelPendingTimer()
123
+ currentCtrl?.abort()
124
+ state = 'idle'
125
+ }
126
+ break
127
+ case 'csi':
128
+ case 'ss3':
129
+ // A C0 control byte is never a legal part of a CSI/SS3 body. A
130
+ // truncated or malformed sequence (e.g. dropped final byte over a
131
+ // lossy SSH link) must not strand the parser swallowing the user's
132
+ // exit keys. ESC resynchronizes to a new sequence; Ctrl-C surfaces
133
+ // immediately; any other C0 control just abandons the sequence.
134
+ if (byte === ESC) {
135
+ state = 'sawEsc'
136
+ scheduleBareEsc()
137
+ } else if (byte === CTRL_C) {
138
+ cancelPendingTimer()
139
+ state = 'idle'
140
+ sigint = true
141
+ } else if (isC0Control(byte)) {
142
+ cancelPendingTimer()
143
+ state = 'idle'
144
+ } else if (state === 'csi') {
145
+ if (isCsiFinal(byte)) state = 'idle'
146
+ } else {
147
+ // SS3 carries exactly one final byte (e.g. application-mode arrows).
148
+ state = 'idle'
149
+ }
150
+ break
151
+ }
54
152
  }
55
- // Any other byte arriving within the ESC window is the second byte of a CSI
56
- // sequence; cancel the pending abort.
57
- clearPending()
58
- return { sigint: false }
153
+ return { sigint, quit }
59
154
  },
60
155
  clearPending,
61
156
  dispose: () => {
62
- clearPending()
157
+ cancelPendingTimer()
158
+ state = 'idle'
63
159
  currentCtrl = null
64
160
  },
65
161
  }
@@ -111,8 +207,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
111
207
 
112
208
  const onData = (chunk: Buffer): void => {
113
209
  if (esc === null) return
114
- const { sigint } = esc.onChunk(chunk)
115
- if (sigint) settle('exit')
210
+ // Route every byte through the parser so arrow keys (CSI/SS3) are consumed
211
+ // as no-ops and q/ctrl-c are detected even when batched with other bytes.
212
+ // q mirrors dreams' quit key and is symmetric with Ctrl-C in live tail.
213
+ const { sigint, quit } = esc.onChunk(chunk)
214
+ if (sigint || quit) settle('exit')
116
215
  }
117
216
 
118
217
  const dispose = (): void => {
@@ -58,6 +58,7 @@ export const inspectCommand = defineCommand({
58
58
  selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
59
59
  ...(liveSource !== undefined ? { liveSource } : {}),
60
60
  createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
61
+ ...(interactive ? { interactive: true } : {}),
61
62
  ...(liveHint !== undefined ? { liveHint } : {}),
62
63
  stdout: (line) => process.stdout.write(`${line}\n`),
63
64
  stderr: (line) => process.stderr.write(`${line}\n`),
@@ -93,7 +94,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
93
94
  }
94
95
 
95
96
  function escHintLine(color: boolean): string {
96
- const text = '(press esc to return to session list)'
97
+ const text = '(esc to return to session list · q to quit)'
97
98
  return color ? `\u001b[2m${text}\u001b[0m` : text
98
99
  }
99
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 {
@@ -4,6 +4,7 @@ import { readFile, writeFile } from 'node:fs/promises'
4
4
  import { isAbsolute, join, resolve } from 'node:path'
5
5
 
6
6
  import { expandMountPath, loadConfigSync, type Config } from '@/config'
7
+ import { commitGitignoreWithUntracks, untrackTrulyIgnoredFiles } from '@/git/reconcile-ignored'
7
8
  import { commitSystemFile as commitSystemFileShared } from '@/git/system-commit'
8
9
  import { send as sendToDaemon } from '@/hostd/client'
9
10
  import type { HttpInfoResult } from '@/hostd/protocol'
@@ -188,7 +189,12 @@ export async function start({
188
189
  // trigger the migration commit if the file was legacy.
189
190
  await refreshGitignore(cwd)
190
191
  const pkgRefresh = await refreshPackageJson(cwd)
191
- await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
192
+ const { untracked } = await untrackTrulyIgnoredFiles(cwd, (await loadTypeclawConfig(cwd)).git.ignore.append)
193
+ if (untracked.length > 0) {
194
+ await commitGitignoreWithUntracks(cwd, GITIGNORE_FILE, untracked, 'Untrack newly-ignored files')
195
+ } else {
196
+ await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
197
+ }
192
198
  if (pkgRefresh.changed) {
193
199
  await commitSystemFile(cwd, pkgRefresh.files, 'Enable bun workspaces (packages/*)')
194
200
  }
@@ -0,0 +1,22 @@
1
+ const chains = new Map<string, Promise<void>>()
2
+
3
+ export async function withGitLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
4
+ const previous = chains.get(key) ?? Promise.resolve()
5
+ let release!: () => void
6
+ const current = new Promise<void>((resolve) => {
7
+ release = resolve
8
+ })
9
+ const next = previous.then(
10
+ () => current,
11
+ () => current,
12
+ )
13
+ chains.set(key, next)
14
+
15
+ await previous.catch(() => undefined)
16
+ try {
17
+ return await fn()
18
+ } finally {
19
+ release()
20
+ if (chains.get(key) === next) chains.delete(key)
21
+ }
22
+ }