typeclaw 0.24.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 (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +42 -5
  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 +90 -12
  8. package/src/agent/session-origin.ts +30 -0
  9. package/src/agent/subagent-completion-reminder.ts +23 -0
  10. package/src/agent/subagents.ts +31 -2
  11. package/src/agent/system-prompt.ts +1 -1
  12. package/src/agent/tool-not-found-nudge.ts +8 -1
  13. package/src/agent/tools/channel-reply.ts +3 -3
  14. package/src/agent/tools/curl-impersonate.ts +2 -2
  15. package/src/agent/tools/spawn-subagent.ts +19 -2
  16. package/src/agent/tools/subagent-access.ts +40 -5
  17. package/src/agent/tools/subagent-cancel.ts +3 -1
  18. package/src/agent/tools/subagent-output.ts +6 -2
  19. package/src/agent/tools/webfetch/fetch.ts +18 -18
  20. package/src/agent/tools/webfetch/index.ts +1 -1
  21. package/src/agent/tools/webfetch/tool.ts +13 -13
  22. package/src/agent/tools/webfetch/types.ts +1 -1
  23. package/src/agent/tools/websearch.ts +6 -6
  24. package/src/bundled-plugins/backup/index.ts +40 -37
  25. package/src/bundled-plugins/backup/runner.ts +22 -1
  26. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  27. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  28. package/src/bundled-plugins/memory/README.md +11 -11
  29. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  30. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  31. package/src/bundled-plugins/operator/operator.ts +5 -1
  32. package/src/bundled-plugins/reviewer/reviewer.ts +18 -9
  33. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  34. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  35. package/src/bundled-plugins/scout/scout.ts +7 -7
  36. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  37. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  38. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  39. package/src/channels/adapters/github/inbound.ts +11 -0
  40. package/src/channels/adapters/github/webhook-register.ts +32 -27
  41. package/src/channels/router.ts +61 -23
  42. package/src/channels/schema.ts +2 -1
  43. package/src/channels/subagent-completion-bridge.ts +18 -18
  44. package/src/channels/types.ts +1 -1
  45. package/src/cli/inspect-controller.ts +130 -38
  46. package/src/container/start.ts +7 -1
  47. package/src/git/mutex.ts +22 -0
  48. package/src/git/reconcile-ignored.ts +214 -0
  49. package/src/hostd/daemon.ts +26 -1
  50. package/src/hostd/portbroker-manager.ts +7 -0
  51. package/src/init/dockerfile.ts +1 -1
  52. package/src/init/gitignore.ts +25 -16
  53. package/src/inspect/index.ts +31 -4
  54. package/src/inspect/loop.ts +16 -12
  55. package/src/plugin/define.ts +2 -2
  56. package/src/plugin/index.ts +2 -2
  57. package/src/portbroker/hostd-client.ts +36 -13
  58. package/src/run/index.ts +14 -0
  59. package/src/sandbox/build.ts +10 -0
  60. package/src/sandbox/index.ts +9 -1
  61. package/src/sandbox/policy.ts +12 -0
  62. package/src/sandbox/session-tmp.ts +43 -0
  63. package/src/sandbox/writable-zones.ts +103 -3
  64. package/src/server/command-runner.ts +1 -1
  65. package/src/server/index.ts +8 -0
  66. package/src/skills/typeclaw-channel-github/SKILL.md +37 -10
  67. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  68. package/src/tui/format.ts +11 -11
@@ -54,17 +54,25 @@ export async function registerGithubWebhooks(
54
54
  options: RegisterGithubWebhooksOptions,
55
55
  ): Promise<WebhookRegistrationResult> {
56
56
  const fetchImpl = options.fetchImpl ?? fetch
57
- const repos: WebhookRepoResult[] = []
58
- for (const repo of options.repos) {
59
- let token: string
60
- try {
61
- token = await options.token(repo)
62
- } catch (err) {
63
- repos.push({ repo, action: 'failed', error: describe(err) })
64
- continue
65
- }
66
- repos.push(await registerOne(fetchImpl, token, repo, options))
67
- }
57
+ // Dedupe before fanning out: the serial loop self-corrected on a repeated
58
+ // repo (the second pass saw the first pass's hook and updated it), but
59
+ // concurrent passes would both list an empty set and each POST a hook,
60
+ // creating a duplicate. Collapsing to distinct slugs restores convergence.
61
+ const distinctRepos = [...new Set(options.repos)]
62
+ // Repos are independent (own installation token, own hooks), so register them
63
+ // concurrently. Every task resolves to a result (failures are caught into a
64
+ // `failed` entry, never thrown), so the batch never rejects and order is kept.
65
+ const repos = await Promise.all(
66
+ distinctRepos.map(async (repo): Promise<WebhookRepoResult> => {
67
+ let token: string
68
+ try {
69
+ token = await options.token(repo)
70
+ } catch (err) {
71
+ return { repo, action: 'failed', error: describe(err) }
72
+ }
73
+ return registerOne(fetchImpl, token, repo, options)
74
+ }),
75
+ )
68
76
  return { repos }
69
77
  }
70
78
 
@@ -82,17 +90,17 @@ export async function deregisterGithubWebhooks(
82
90
  options: DeregisterGithubWebhooksOptions,
83
91
  ): Promise<WebhookDeregistrationResult> {
84
92
  const fetchImpl = options.fetchImpl ?? fetch
85
- const hooks: WebhookDeregistrationResult['hooks'] = []
86
- for (const hook of options.hooks) {
87
- let token: string
88
- try {
89
- token = await options.token(hook.repo)
90
- } catch (err) {
91
- hooks.push({ ...hook, action: 'failed', error: describe(err) })
92
- continue
93
- }
94
- hooks.push(await deleteOne(fetchImpl, token, hook))
95
- }
93
+ const hooks = await Promise.all(
94
+ options.hooks.map(async (hook): Promise<WebhookDeregistrationResult['hooks'][number]> => {
95
+ let token: string
96
+ try {
97
+ token = await options.token(hook.repo)
98
+ } catch (err) {
99
+ return { ...hook, action: 'failed', error: describe(err) }
100
+ }
101
+ return deleteOne(fetchImpl, token, hook)
102
+ }),
103
+ )
96
104
  return { hooks }
97
105
  }
98
106
 
@@ -125,11 +133,8 @@ async function registerOne(
125
133
  // inspecting the repo's webhook list.
126
134
  const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
127
135
  await updateHook(fetchImpl, token, parsed, keep!, options)
128
- let stalePruned = 0
129
- for (const id of stale) {
130
- const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
131
- if (ok) stalePruned++
132
- }
136
+ const pruned = await Promise.all(stale.map((id) => tryDeleteHook(fetchImpl, token, parsed, id)))
137
+ const stalePruned = pruned.filter(Boolean).length
133
138
  return { repo, action: 'updated', hookId: keep!, stalePruned }
134
139
  } catch (err) {
135
140
  return { repo, action: 'failed', error: describe(err) }
@@ -4,6 +4,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
6
  import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
7
+ import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
7
8
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
9
  import type { RestartHandoff } from '@/agent/restart-handoff'
9
10
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
@@ -15,6 +16,7 @@ import {
15
16
  recordTurnStart,
16
17
  runIdleContinuation,
17
18
  } from '@/agent/todo/continuation-wiring'
19
+ import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
18
20
  import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
19
21
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
20
22
  import type { HookBus } from '@/plugin'
@@ -669,6 +671,7 @@ export type ChannelRouter = {
669
671
  ok: boolean
670
672
  durationMs: number
671
673
  error?: string
674
+ channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
672
675
  }) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
673
676
  // Record that the agent invoked `skip_response` during the current turn
674
677
  // for the channel session identified by `parentSessionId`. The reason is
@@ -3219,6 +3222,47 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3219
3222
  return { kind: 'unknown-command', name: lowered }
3220
3223
  }
3221
3224
 
3225
+ const deliverCompletionReminder = (
3226
+ live: LiveSession,
3227
+ args: {
3228
+ parentSessionId: string
3229
+ subagent: string
3230
+ taskId: string
3231
+ ok: boolean
3232
+ durationMs: number
3233
+ error?: string
3234
+ },
3235
+ ): { kind: 'delivered'; keyId: string } => {
3236
+ const text = renderSubagentCompletionReminder({
3237
+ subagent: args.subagent,
3238
+ taskId: args.taskId,
3239
+ ok: args.ok,
3240
+ durationMs: args.durationMs,
3241
+ ...(args.error !== undefined ? { error: args.error } : {}),
3242
+ channel: true,
3243
+ })
3244
+ live.pendingSystemReminders.push(text)
3245
+ // The reminder tells the agent to fetch this result now; clear the
3246
+ // subagent_output window so an earlier premature-polling streak can't
3247
+ // hard-block that legitimate fetch.
3248
+ forgetSharedLoopGuardTool(live.sessionId, SUBAGENT_OUTPUT_TOOL_NAME)
3249
+ logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
3250
+ // Wake the drain loop. If a turn is already in flight, the wakeup is
3251
+ // a no-op because drain() will pick up the reminder on its next
3252
+ // iteration (it now gates on promptQueue OR pendingSystemReminders).
3253
+ // If the session is idle, fire drain() immediately rather than going
3254
+ // through the debounce path — the reminder is not a user inbound,
3255
+ // so the "coalesce nearby inbounds" rationale for debouncing does
3256
+ // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
3257
+ // semantics: the channel router doesn't have a `delivery: interrupt`
3258
+ // mechanism (no in-flight abort during a turn), but firing drain()
3259
+ // immediately is the equivalent for an idle session.
3260
+ if (!live.draining) {
3261
+ void drain(live)
3262
+ }
3263
+ return { kind: 'delivered', keyId: live.keyId }
3264
+ }
3265
+
3222
3266
  const injectSubagentCompletionReminder = (args: {
3223
3267
  parentSessionId: string
3224
3268
  subagent: string
@@ -3226,34 +3270,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3226
3270
  ok: boolean
3227
3271
  durationMs: number
3228
3272
  error?: string
3273
+ channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
3229
3274
  }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
3230
3275
  for (const live of liveSessions.values()) {
3231
3276
  if (live.destroyed) continue
3232
3277
  if (live.sessionId !== args.parentSessionId) continue
3233
- const text = renderSubagentCompletionReminder({
3234
- subagent: args.subagent,
3235
- taskId: args.taskId,
3236
- ok: args.ok,
3237
- durationMs: args.durationMs,
3238
- ...(args.error !== undefined ? { error: args.error } : {}),
3239
- channel: true,
3240
- })
3241
- live.pendingSystemReminders.push(text)
3242
- logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
3243
- // Wake the drain loop. If a turn is already in flight, the wakeup is
3244
- // a no-op because drain() will pick up the reminder on its next
3245
- // iteration (it now gates on promptQueue OR pendingSystemReminders).
3246
- // If the session is idle, fire drain() immediately rather than going
3247
- // through the debounce path — the reminder is not a user inbound,
3248
- // so the "coalesce nearby inbounds" rationale for debouncing does
3249
- // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
3250
- // semantics: the channel router doesn't have a `delivery: interrupt`
3251
- // mechanism (no in-flight abort during a turn), but firing drain()
3252
- // immediately is the equivalent for an idle session.
3253
- if (!live.draining) {
3254
- void drain(live)
3278
+ return deliverCompletionReminder(live, args)
3279
+ }
3280
+ // The exact parent session is gone. If the subagent was spawned from a
3281
+ // channel session, the conversation may have rolled over
3282
+ // (SESSION_FRESHNESS_TTL_MS) or been idle-evicted onto a fresh sessionId
3283
+ // for the same channel key while the subagent ran. Fall back to the live
3284
+ // successor for that key so a finished review/result still surfaces
3285
+ // instead of being silently dropped.
3286
+ if (args.channelKey !== undefined) {
3287
+ const targetKeyId = channelKeyId(args.channelKey)
3288
+ const successor = liveSessions.get(targetKeyId)
3289
+ if (successor !== undefined && !successor.destroyed) {
3290
+ logger.info(
3291
+ `[channels] ${targetKeyId}: subagent-completion reminder rerouted to live successor (parent ${args.parentSessionId} gone) task=${args.taskId}`,
3292
+ )
3293
+ return deliverCompletionReminder(successor, args)
3255
3294
  }
3256
- return { kind: 'delivered', keyId: live.keyId }
3257
3295
  }
3258
3296
  return { kind: 'no-live-session' }
3259
3297
  }
@@ -136,7 +136,8 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
136
136
  // prefix is implied by this field living under the review config); `off` is the
137
137
  // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
138
138
  // - 'review_requested' — review only when the bot is requested (default)
139
- // - 'opened' — review every PR as soon as it opens
139
+ // - 'opened' — review every non-draft PR as soon as it opens; draft
140
+ // PRs are skipped until an explicit review_requested
140
141
  // - 'off' — disable code review entirely
141
142
  export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
142
143
 
@@ -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
  })
@@ -382,6 +382,6 @@ export type ReviewThreadResolveResult =
382
382
  // support review threads never register one; the router answers `unsupported`.
383
383
  export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
384
384
 
385
- export function channelKeyId(key: ChannelKey): string {
385
+ export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
386
386
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
387
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,17 +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
17
27
  const QUIT_KEY = 0x71
18
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
+
19
43
  export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
20
44
  let currentCtrl: AbortController | null = null
45
+ let state: ParseState = 'idle'
21
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)
22
51
 
23
52
  const clearPending = (): void => {
24
53
  if (pendingEsc !== null) {
25
54
  clearTimeout(pendingEsc)
26
55
  pendingEsc = null
27
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
+ }
28
75
  }
29
76
 
30
77
  return {
@@ -34,34 +81,81 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
34
81
  return currentCtrl.signal
35
82
  },
36
83
  onChunk: (chunk) => {
37
- if (chunk.length === 0) return { sigint: false }
38
- if (chunk[0] === 0x03) {
39
- // Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
40
- // SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
41
- // we deliberately keep the AbortController lifecycle separate from SIGINT.
42
- return { sigint: true }
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
+ }
43
152
  }
44
- if (chunk.length === 1 && chunk[0] === 0x1b) {
45
- // Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
46
- // sequences from arrow keys, mouse, paste) cancels the pending fire.
47
- // Snapshot currentCtrl so a late-firing timer can't abort a controller
48
- // created by a subsequent armForStream() call.
49
- clearPending()
50
- const ctrl = currentCtrl
51
- pendingEsc = setTimeout(() => {
52
- pendingEsc = null
53
- ctrl?.abort()
54
- }, debounceMs)
55
- return { sigint: false }
56
- }
57
- // Any other byte arriving within the ESC window is the second byte of a CSI
58
- // sequence; cancel the pending abort.
59
- clearPending()
60
- return { sigint: false }
153
+ return { sigint, quit }
61
154
  },
62
155
  clearPending,
63
156
  dispose: () => {
64
- clearPending()
157
+ cancelPendingTimer()
158
+ state = 'idle'
65
159
  currentCtrl = null
66
160
  },
67
161
  }
@@ -113,13 +207,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
113
207
 
114
208
  const onData = (chunk: Buffer): void => {
115
209
  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')
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')
123
215
  }
124
216
 
125
217
  const dispose = (): void => {
@@ -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
+ }