typeclaw 0.24.0 → 0.26.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 (78) 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 +58 -5
  9. package/src/agent/subagent-completion-reminder.ts +39 -1
  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-react.ts +11 -4
  14. package/src/agent/tools/channel-reply.ts +3 -3
  15. package/src/agent/tools/curl-impersonate.ts +2 -2
  16. package/src/agent/tools/spawn-subagent.ts +19 -2
  17. package/src/agent/tools/subagent-access.ts +40 -5
  18. package/src/agent/tools/subagent-cancel.ts +3 -1
  19. package/src/agent/tools/subagent-output.ts +6 -2
  20. package/src/agent/tools/webfetch/fetch.ts +18 -18
  21. package/src/agent/tools/webfetch/index.ts +1 -1
  22. package/src/agent/tools/webfetch/tool.ts +13 -13
  23. package/src/agent/tools/webfetch/types.ts +1 -1
  24. package/src/agent/tools/websearch.ts +6 -6
  25. package/src/bundled-plugins/backup/index.ts +40 -37
  26. package/src/bundled-plugins/backup/runner.ts +22 -1
  27. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  28. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  29. package/src/bundled-plugins/memory/README.md +11 -11
  30. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  31. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  32. package/src/bundled-plugins/operator/operator.ts +5 -1
  33. package/src/bundled-plugins/reviewer/reviewer.ts +18 -9
  34. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  35. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  36. package/src/bundled-plugins/scout/scout.ts +7 -7
  37. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  38. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  39. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  40. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  41. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  42. package/src/channels/adapters/discord-bot.ts +23 -0
  43. package/src/channels/adapters/github/inbound.ts +19 -4
  44. package/src/channels/adapters/github/webhook-register.ts +32 -27
  45. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  46. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  47. package/src/channels/adapters/slack-bot.ts +24 -0
  48. package/src/channels/router.ts +63 -23
  49. package/src/channels/schema.ts +43 -1
  50. package/src/channels/subagent-completion-bridge.ts +18 -18
  51. package/src/channels/types.ts +1 -1
  52. package/src/cli/inspect-controller.ts +130 -38
  53. package/src/config/config.ts +43 -2
  54. package/src/container/start.ts +7 -1
  55. package/src/git/mutex.ts +22 -0
  56. package/src/git/reconcile-ignored.ts +214 -0
  57. package/src/hostd/daemon.ts +26 -1
  58. package/src/hostd/portbroker-manager.ts +7 -0
  59. package/src/init/dockerfile.ts +1 -1
  60. package/src/init/gitignore.ts +25 -16
  61. package/src/init/index.ts +3 -3
  62. package/src/inspect/index.ts +31 -4
  63. package/src/inspect/loop.ts +16 -12
  64. package/src/plugin/define.ts +2 -2
  65. package/src/plugin/index.ts +2 -2
  66. package/src/portbroker/hostd-client.ts +36 -13
  67. package/src/run/index.ts +14 -0
  68. package/src/sandbox/build.ts +10 -0
  69. package/src/sandbox/index.ts +9 -1
  70. package/src/sandbox/policy.ts +12 -0
  71. package/src/sandbox/session-tmp.ts +43 -0
  72. package/src/sandbox/writable-zones.ts +103 -3
  73. package/src/server/command-runner.ts +1 -1
  74. package/src/server/index.ts +8 -0
  75. package/src/skills/typeclaw-channel-github/SKILL.md +38 -11
  76. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  77. package/src/tui/format.ts +11 -11
  78. package/typeclaw.schema.json +1 -0
@@ -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,49 @@ 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 adapter = live.keyId.split(':', 1)[0] ?? ''
3237
+ const text = renderSubagentCompletionReminder({
3238
+ subagent: args.subagent,
3239
+ taskId: args.taskId,
3240
+ ok: args.ok,
3241
+ durationMs: args.durationMs,
3242
+ ...(args.error !== undefined ? { error: args.error } : {}),
3243
+ channel: true,
3244
+ adapter,
3245
+ })
3246
+ live.pendingSystemReminders.push(text)
3247
+ // The reminder tells the agent to fetch this result now; clear the
3248
+ // subagent_output window so an earlier premature-polling streak can't
3249
+ // hard-block that legitimate fetch.
3250
+ forgetSharedLoopGuardTool(live.sessionId, SUBAGENT_OUTPUT_TOOL_NAME)
3251
+ logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
3252
+ // Wake the drain loop. If a turn is already in flight, the wakeup is
3253
+ // a no-op because drain() will pick up the reminder on its next
3254
+ // iteration (it now gates on promptQueue OR pendingSystemReminders).
3255
+ // If the session is idle, fire drain() immediately rather than going
3256
+ // through the debounce path — the reminder is not a user inbound,
3257
+ // so the "coalesce nearby inbounds" rationale for debouncing does
3258
+ // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
3259
+ // semantics: the channel router doesn't have a `delivery: interrupt`
3260
+ // mechanism (no in-flight abort during a turn), but firing drain()
3261
+ // immediately is the equivalent for an idle session.
3262
+ if (!live.draining) {
3263
+ void drain(live)
3264
+ }
3265
+ return { kind: 'delivered', keyId: live.keyId }
3266
+ }
3267
+
3222
3268
  const injectSubagentCompletionReminder = (args: {
3223
3269
  parentSessionId: string
3224
3270
  subagent: string
@@ -3226,34 +3272,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3226
3272
  ok: boolean
3227
3273
  durationMs: number
3228
3274
  error?: string
3275
+ channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
3229
3276
  }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
3230
3277
  for (const live of liveSessions.values()) {
3231
3278
  if (live.destroyed) continue
3232
3279
  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)
3280
+ return deliverCompletionReminder(live, args)
3281
+ }
3282
+ // The exact parent session is gone. If the subagent was spawned from a
3283
+ // channel session, the conversation may have rolled over
3284
+ // (SESSION_FRESHNESS_TTL_MS) or been idle-evicted onto a fresh sessionId
3285
+ // for the same channel key while the subagent ran. Fall back to the live
3286
+ // successor for that key so a finished review/result still surfaces
3287
+ // instead of being silently dropped.
3288
+ if (args.channelKey !== undefined) {
3289
+ const targetKeyId = channelKeyId(args.channelKey)
3290
+ const successor = liveSessions.get(targetKeyId)
3291
+ if (successor !== undefined && !successor.destroyed) {
3292
+ logger.info(
3293
+ `[channels] ${targetKeyId}: subagent-completion reminder rerouted to live successor (parent ${args.parentSessionId} gone) task=${args.taskId}`,
3294
+ )
3295
+ return deliverCompletionReminder(successor, args)
3255
3296
  }
3256
- return { kind: 'delivered', keyId: live.keyId }
3257
3297
  }
3258
3298
  return { kind: 'no-live-session' }
3259
3299
  }
@@ -125,18 +125,60 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
125
125
  'discussion_comment.created',
126
126
  'issues.opened',
127
127
  'pull_request.opened',
128
+ 'pull_request.ready_for_review',
128
129
  'pull_request.review_requested',
129
130
  'pull_request.review_request_removed',
130
131
  'discussion.created',
131
132
  'pull_request_review.submitted',
132
133
  ] as const
133
134
 
135
+ // Prior values of DEFAULT_GITHUB_EVENT_ALLOWLIST that shipped in releases and
136
+ // were seeded verbatim into typeclaw.json. Kept as historical record so the
137
+ // migration can recognize and unfreeze configs created by those versions.
138
+ // NEVER edit these in place — they are snapshots of what was on disk.
139
+ // - v1: 7-event default, shipped 0.5.1–0.10.0 (commit fe4f3a8)
140
+ const GITHUB_EVENT_ALLOWLIST_V1 = [
141
+ 'issue_comment.created',
142
+ 'pull_request_review_comment.created',
143
+ 'discussion_comment.created',
144
+ 'issues.opened',
145
+ 'pull_request.opened',
146
+ 'discussion.created',
147
+ 'pull_request_review.submitted',
148
+ ] as const
149
+ // - v2: added review_requested + review_request_removed, shipped 0.11.0+ (commit 4f365ce)
150
+ const GITHUB_EVENT_ALLOWLIST_V2 = [
151
+ 'issue_comment.created',
152
+ 'pull_request_review_comment.created',
153
+ 'discussion_comment.created',
154
+ 'issues.opened',
155
+ 'pull_request.opened',
156
+ 'pull_request.review_requested',
157
+ 'pull_request.review_request_removed',
158
+ 'discussion.created',
159
+ 'pull_request_review.submitted',
160
+ ] as const
161
+
162
+ // Every event-allowlist that `channel add` / `init` has ever seeded verbatim
163
+ // into typeclaw.json, oldest first, current default last. The legacy-shape
164
+ // migration uses this to tell a seeded default (safe to strip so the config
165
+ // re-tracks the shipped default) from a user's deliberate customization (must
166
+ // be preserved). Append the prior array here — never edit in place — whenever
167
+ // DEFAULT_GITHUB_EVENT_ALLOWLIST changes, or configs from the old version stay
168
+ // frozen and the migration starts eating user edits.
169
+ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
170
+ GITHUB_EVENT_ALLOWLIST_V1,
171
+ GITHUB_EVENT_ALLOWLIST_V2,
172
+ DEFAULT_GITHUB_EVENT_ALLOWLIST,
173
+ ]
174
+
134
175
  // Which pull_request webhook action triggers an agent code review. The two
135
176
  // event values are GitHub's bare PR action names (the `pull_request.` event
136
177
  // prefix is implied by this field living under the review config); `off` is the
137
178
  // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
138
179
  // - 'review_requested' — review only when the bot is requested (default)
139
- // - 'opened' — review every PR as soon as it opens
180
+ // - 'opened' — review every non-draft PR as soon as it opens; draft
181
+ // PRs are skipped until an explicit review_requested
140
182
  // - 'off' — disable code review entirely
141
183
  export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
142
184
 
@@ -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 => {
@@ -5,7 +5,7 @@ import { isAbsolute, join, resolve } from 'node:path'
5
5
  import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
7
7
 
8
- import { channelsSchema } from '@/channels/schema'
8
+ import { channelsSchema, SEEDED_GITHUB_EVENT_ALLOWLISTS } from '@/channels/schema'
9
9
  import { commitSystemFileSync } from '@/git/system-commit'
10
10
  import { rolesConfigSchema } from '@/permissions/schema'
11
11
  import { secretFieldSchema } from '@/secrets/resolve'
@@ -810,6 +810,7 @@ export type MigrationStep =
810
810
  | { kind: 'strip-permissions-gate-channel-respond' }
811
811
  | { kind: 'model-to-models'; ref: string }
812
812
  | { kind: 'drop-stale-model'; ref: string }
813
+ | { kind: 'drop-github-seeded-event-allowlist' }
813
814
 
814
815
  export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
815
816
 
@@ -830,13 +831,15 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
830
831
  // silently — same precedence rule as the dockerfile/gitignore migrations.
831
832
  const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
832
833
  const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
834
+ const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
833
835
  if (
834
836
  !hasLegacyDockerfile &&
835
837
  !hasLegacyGitignore &&
836
838
  !channelsAllowMigration.found &&
837
839
  !hasLegacyGateChannelRespond &&
838
840
  !hasLegacyModel &&
839
- !hasStaleModelAlongsideModels
841
+ !hasStaleModelAlongsideModels &&
842
+ !hasSeededGithubEventAllowlist
840
843
  ) {
841
844
  return { json, changed: false, applied: [] }
842
845
  }
@@ -897,9 +900,43 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
897
900
  delete next.model
898
901
  applied.push({ kind: 'drop-stale-model', ref })
899
902
  }
903
+ if (hasSeededGithubEventAllowlist) {
904
+ dropSeededGithubEventAllowlist(next)
905
+ applied.push({ kind: 'drop-github-seeded-event-allowlist' })
906
+ }
900
907
  return { json: next, changed: true, applied }
901
908
  }
902
909
 
910
+ // True when channels.github.eventAllowlist deep-equals an allowlist that
911
+ // `channel add` / `init` has previously seeded verbatim. Such a value is
912
+ // indistinguishable from "the default at that time", so stripping it lets the
913
+ // config re-track the shipped default. A user who hand-edited to any other set
914
+ // (added/removed/reordered an event) fails this check and is preserved.
915
+ function isSeededGithubEventAllowlist(obj: Record<string, unknown>): boolean {
916
+ const github = isPlainObject(obj.channels) ? obj.channels.github : undefined
917
+ if (!isPlainObject(github)) return false
918
+ const list = github.eventAllowlist
919
+ if (!Array.isArray(list)) return false
920
+ return SEEDED_GITHUB_EVENT_ALLOWLISTS.some((seeded) => arraysEqual(list, seeded))
921
+ }
922
+
923
+ function dropSeededGithubEventAllowlist(next: Record<string, unknown>): void {
924
+ const channels = next.channels
925
+ if (!isPlainObject(channels)) return
926
+ const github = channels.github
927
+ if (!isPlainObject(github)) return
928
+ const { eventAllowlist: _dropped, ...rest } = github
929
+ next.channels = { ...channels, github: rest }
930
+ }
931
+
932
+ function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
933
+ if (a.length !== b.length) return false
934
+ for (let i = 0; i < a.length; i++) {
935
+ if (a[i] !== b[i]) return false
936
+ }
937
+ return true
938
+ }
939
+
903
940
  // Builds a meaningful one-line git commit subject for a typeclaw.json
904
941
  // migration. Single-step migrations get a specific subject; multi-step ones
905
942
  // fall back to a stable summary subject with the count. The body (after the
@@ -949,6 +986,8 @@ function shortStepLabel(step: MigrationStep): string {
949
986
  return 'lift model → models.default'
950
987
  case 'drop-stale-model':
951
988
  return 'drop stale legacy model alongside models'
989
+ case 'drop-github-seeded-event-allowlist':
990
+ return 'drop seeded channels.github.eventAllowlist'
952
991
  }
953
992
  }
954
993
 
@@ -972,6 +1011,8 @@ function describeStep(step: MigrationStep): string {
972
1011
  return step.ref !== ''
973
1012
  ? `drop stale top-level model (${step.ref}) — models block takes precedence`
974
1013
  : 'drop stale top-level model — models block takes precedence'
1014
+ case 'drop-github-seeded-event-allowlist':
1015
+ return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
975
1016
  }
976
1017
  }
977
1018
 
@@ -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
+ }