typeclaw 0.25.0 → 0.27.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/session-origin.ts +36 -5
  3. package/src/agent/subagent-completion-reminder.ts +16 -1
  4. package/src/agent/tools/channel-react.ts +11 -4
  5. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  6. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  7. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  8. package/src/channels/adapters/discord-bot.ts +23 -0
  9. package/src/channels/adapters/github/inbound.ts +60 -13
  10. package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
  11. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  12. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  13. package/src/channels/adapters/slack-bot.ts +24 -0
  14. package/src/channels/router.ts +191 -7
  15. package/src/channels/schema.ts +41 -0
  16. package/src/cli/inspect.ts +216 -36
  17. package/src/cli/logs.ts +15 -0
  18. package/src/cli/tui.ts +33 -39
  19. package/src/compose/logs.ts +1 -1
  20. package/src/config/config.ts +43 -2
  21. package/src/container/logs.ts +70 -22
  22. package/src/init/index.ts +3 -3
  23. package/src/inspect/index.ts +128 -42
  24. package/src/inspect/item-list.ts +44 -0
  25. package/src/inspect/item.ts +17 -0
  26. package/src/inspect/label.ts +1 -1
  27. package/src/inspect/logs-item.ts +79 -0
  28. package/src/inspect/loop.ts +74 -3
  29. package/src/inspect/open-item.ts +100 -0
  30. package/src/inspect/preview.ts +106 -0
  31. package/src/inspect/session-list.ts +15 -3
  32. package/src/inspect/transcript-view.ts +182 -0
  33. package/src/inspect/tui-item.ts +97 -0
  34. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  35. package/src/tui/index.ts +72 -32
  36. package/typeclaw.schema.json +1 -0
@@ -2,7 +2,19 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import {
6
+ listViewerItems,
7
+ openViewerItem,
8
+ parseDuration,
9
+ parseFilter,
10
+ resolveSession,
11
+ runInspectLoop,
12
+ runViewerLoop,
13
+ streamLive,
14
+ type LiveSourceFactory,
15
+ type SessionSummary,
16
+ type ViewerItem,
17
+ } from '@/inspect'
6
18
  import { originLabel, shortSessionId } from '@/inspect/label'
7
19
 
8
20
  import { createTailScope } from './inspect-controller'
@@ -13,12 +25,12 @@ const ESC_DEBOUNCE_MS = 50
13
25
  export const inspectCommand = defineCommand({
14
26
  meta: {
15
27
  name: 'inspect',
16
- description: 'observe a session: replay the transcript, then tail live activity (host stage)',
28
+ description: 'session viewer: pick a session, the live TUI, or container logs to observe (host stage)',
17
29
  },
18
30
  args: {
19
31
  session: {
20
32
  type: 'positional',
21
- description: 'session id or short prefix (omit to pick from a list)',
33
+ description: 'session id or short prefix (omit to pick from the list)',
22
34
  required: false,
23
35
  },
24
36
  filter: {
@@ -42,42 +54,175 @@ export const inspectCommand = defineCommand({
42
54
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
43
55
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
44
56
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
45
-
46
57
  const isJson = args.json === true
47
- const liveSource = isJson ? undefined : await buildLiveSource(cwd)
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
- })
66
58
 
67
- if (!result.ok) {
68
- process.stderr.write(`${errorLine(result.reason)}\n`)
69
- process.exit(result.exitCode)
59
+ // JSON mode stays the scriptable, session-only path: no list, no logs/tui
60
+ // rows, explicit session id required. Behavior is unchanged from before the
61
+ // viewer merge.
62
+ if (isJson) {
63
+ const result = await runInspectLoop({
64
+ agentDir: cwd,
65
+ ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
66
+ ...(filterArg !== undefined ? { filter: filterArg } : {}),
67
+ ...(sinceArg !== undefined ? { since: sinceArg } : {}),
68
+ json: true,
69
+ color,
70
+ selectSession: (sessions, selectOpts) => clackSelectSession(sessions, selectOpts?.initialSessionId),
71
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
72
+ stdout: (line) => process.stdout.write(`${line}\n`),
73
+ stderr: (line) => process.stderr.write(`${line}\n`),
74
+ })
75
+ finish(result)
76
+ return
70
77
  }
71
- process.exit(result.exitCode)
78
+
79
+ const exitCode = await runInspectViewer({
80
+ cwd,
81
+ ...(sessionArg !== undefined ? { sessionArg } : {}),
82
+ ...(filterArg !== undefined ? { filterArg } : {}),
83
+ ...(sinceArg !== undefined ? { sinceArg } : {}),
84
+ color,
85
+ })
86
+ process.exit(exitCode)
72
87
  },
73
88
  })
74
89
 
75
- async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
76
- const precheck = await requireContainerRunning({ cwd })
77
- if (!precheck.ok) {
78
- process.stderr.write(`${c.yellow('⚠')} ${precheck.reason}; tailing live events disabled\n`)
79
- return undefined
90
+ export type RunInspectViewerOptions = {
91
+ cwd: string
92
+ sessionArg?: string
93
+ filterArg?: string
94
+ sinceArg?: string
95
+ color?: boolean
96
+ // Set false by the `tui` detach handoff: the live session was just ended, so
97
+ // no row should be offered as writable (see listViewerItems).
98
+ allowWritable?: boolean
99
+ }
100
+
101
+ // The interactive session-viewer: list → open → back to list. Shared by the
102
+ // `inspect` command and `tui`'s esc-detach fallthrough. Returns an exit code
103
+ // instead of calling process.exit so callers can chain (e.g. tui drops here).
104
+ export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<number> {
105
+ const { cwd } = opts
106
+ const color = opts.color ?? useColor()
107
+
108
+ const filterResult = parseFilter(opts.filterArg)
109
+ if (!filterResult.ok) {
110
+ process.stderr.write(`${errorLine(filterResult.reason)}\n`)
111
+ return 2
112
+ }
113
+ let sinceMs: number | undefined
114
+ if (opts.sinceArg !== undefined) {
115
+ const d = parseDuration(opts.sinceArg)
116
+ if (!d.ok) {
117
+ process.stderr.write(`${errorLine(d.reason)}\n`)
118
+ return 2
119
+ }
120
+ sinceMs = Date.now() - d.ms
121
+ }
122
+
123
+ const containerRunning = (await requireContainerRunning({ cwd })).ok
124
+ if (!containerRunning) {
125
+ process.stderr.write(`${c.yellow('⚠')} container not running; showing read-only history and logs only\n`)
126
+ }
127
+
128
+ const sessionsDir = `${cwd}/sessions`
129
+
130
+ // Resolve a session arg (id or short prefix) to a full session id BEFORE the
131
+ // loop: runViewerLoop matches preselectKey against exact itemKeys, so a bare
132
+ // prefix would otherwise miss every row and report "no sessions". 'logs' is a
133
+ // reserved key, not a session, so it bypasses resolution.
134
+ let preselectKey: string | undefined
135
+ if (opts.sessionArg !== undefined && opts.sessionArg !== 'logs') {
136
+ const resolved = await resolveSession(sessionsDir, opts.sessionArg, (l) => process.stderr.write(`${l}\n`))
137
+ if (!resolved.ok) {
138
+ const reason =
139
+ resolved.reason === 'ambiguous'
140
+ ? `Ambiguous session prefix "${opts.sessionArg}" matches ${resolved.matches.length} sessions. Use a longer prefix or run \`typeclaw inspect\` without args.`
141
+ : `No session matching "${opts.sessionArg}" in ${sessionsDir}/`
142
+ process.stderr.write(`${errorLine(reason)}\n`)
143
+ return resolved.reason === 'ambiguous' ? 2 : 1
144
+ }
145
+ preselectKey = resolved.summary.sessionId
146
+ } else if (opts.sessionArg === 'logs') {
147
+ preselectKey = 'logs'
148
+ }
149
+
150
+ const interactive = Boolean(process.stdin.isTTY)
151
+ const liveHint = interactive ? escHintLine(color) : undefined
152
+ const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
153
+
154
+ const stdout = (line: string): void => {
155
+ process.stdout.write(`${line}\n`)
156
+ }
157
+ const stderr = (line: string): void => {
158
+ process.stderr.write(`${line}\n`)
159
+ }
160
+
161
+ const open = openViewerItem({
162
+ cwd,
163
+ filter: filterResult.filter,
164
+ sinceMs,
165
+ json: false,
166
+ color,
167
+ interactive,
168
+ stdout,
169
+ stderr,
170
+ resolveTuiUrl: () => resolveTuiUrl(cwd),
171
+ ...(liveSource !== undefined ? { liveSource } : {}),
172
+ ...(liveHint !== undefined ? { liveHint } : {}),
173
+ })
174
+
175
+ const cliAllowWritable = opts.allowWritable !== false
176
+ const result = await runViewerLoop<ViewerItem>({
177
+ listItems: async ({ allowWritable: loopAllowWritable }) => {
178
+ const listOpts: Parameters<typeof listViewerItems>[0] = {
179
+ sessionsDir,
180
+ containerRunning,
181
+ // Compose the CLI-level permission (false on tui detach handoff) with
182
+ // the loop-level one (false after returning to the picker from a viewer).
183
+ allowWritable: cliAllowWritable && loopAllowWritable,
184
+ limit: 20,
185
+ onWarn: stderr,
186
+ }
187
+ if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
188
+ return (await listViewerItems(listOpts)).items
189
+ },
190
+ keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
191
+ ...(preselectKey !== undefined ? { preselectKey } : {}),
192
+ selectItem: (items, selectOpts) => clackSelectItem(items, selectOpts.initialKey),
193
+ openItem: open,
194
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
195
+ onEmpty: () => ({
196
+ ok: false,
197
+ exitCode: 1,
198
+ reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
199
+ }),
200
+ })
201
+
202
+ if (!result.ok && result.reason !== undefined) {
203
+ process.stderr.write(`${errorLine(result.reason)}\n`)
204
+ }
205
+ return result.exitCode
206
+ }
207
+
208
+ function finish(result: { ok: boolean; exitCode: number; reason?: string }): void {
209
+ if (!result.ok && result.reason !== undefined) {
210
+ process.stderr.write(`${errorLine(result.reason)}\n`)
80
211
  }
212
+ process.exit(result.exitCode)
213
+ }
214
+
215
+ async function resolveTuiUrl(cwd: string): Promise<string> {
216
+ const precheck = await requireContainerRunning({ cwd })
217
+ if (!precheck.ok) throw new Error(precheck.reason)
218
+ const port = await resolveHostPort({ cwd })
219
+ const token = await resolveTuiToken({ cwd })
220
+ const url = new URL(`ws://127.0.0.1:${port}`)
221
+ if (token !== null) url.searchParams.set('token', token)
222
+ return url.toString()
223
+ }
224
+
225
+ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
81
226
  const port = await resolveHostPort({ cwd })
82
227
  const token = await resolveTuiToken({ cwd })
83
228
  const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
@@ -94,7 +239,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
94
239
  }
95
240
 
96
241
  function escHintLine(color: boolean): string {
97
- const text = '(esc to return to session list · q to quit)'
242
+ const text = '(esc to return to the list · q to quit)'
98
243
  return color ? `\u001b[2m${text}\u001b[0m` : text
99
244
  }
100
245
 
@@ -105,7 +250,29 @@ function useColor(): boolean {
105
250
  return Boolean(process.stdout.isTTY)
106
251
  }
107
252
 
108
- async function clackSelect(
253
+ async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
254
+ const { select } = await import('@clack/prompts')
255
+ prepareStdinForClack()
256
+ const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
257
+ const preferred =
258
+ initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
259
+ const picked = await select<string>({
260
+ message: `Pick what to view (showing ${items.length})`,
261
+ options: items.map((item) => ({
262
+ value: keyOf(item),
263
+ label: itemLabel(item),
264
+ ...itemHint(item),
265
+ })),
266
+ initialValue: preferred,
267
+ })
268
+ if (isCancel(picked)) {
269
+ cancel('Cancelled.')
270
+ return null
271
+ }
272
+ return items.find((i) => keyOf(i) === picked) ?? null
273
+ }
274
+
275
+ async function clackSelectSession(
109
276
  sessions: SessionSummary[],
110
277
  initialSessionId: string | undefined,
111
278
  ): Promise<SessionSummary | null> {
@@ -119,7 +286,7 @@ async function clackSelect(
119
286
  message: `Pick a session to inspect (showing ${sessions.length})`,
120
287
  options: sessions.map((s) => ({
121
288
  value: s.sessionId,
122
- label: formatRowLabel(s),
289
+ label: sessionRowLabel(s),
123
290
  ...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
124
291
  })),
125
292
  initialValue: preferred,
@@ -131,7 +298,20 @@ async function clackSelect(
131
298
  return sessions.find((s) => s.sessionId === picked) ?? null
132
299
  }
133
300
 
134
- function formatRowLabel(s: SessionSummary): string {
301
+ function itemLabel(item: ViewerItem): string {
302
+ if (item.kind === 'logs') return `${c.dim('▤')} container logs`
303
+ if (item.kind === 'tui') return `${c.green('●')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
304
+ return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
305
+ }
306
+
307
+ function itemHint(item: ViewerItem): { hint: string } {
308
+ if (item.kind === 'logs') return { hint: 'read-only · works offline' }
309
+ if (item.kind === 'tui') return { hint: 'read+write · esc detaches and ends the live session' }
310
+ if (item.summary.firstPrompt !== null) return { hint: truncate(item.summary.firstPrompt, 60) }
311
+ return { hint: '(no prompt)' }
312
+ }
313
+
314
+ function sessionRowLabel(s: SessionSummary): string {
135
315
  const id = shortSessionId(s.sessionId)
136
316
  const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
137
317
  const when = formatRelative(s.mtimeMs)
package/src/cli/logs.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
3
3
  import { logs, parseTailValue } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
+ import { runInspectViewer } from './inspect'
6
7
  import { c, errorLine } from './ui'
7
8
 
8
9
  export const logsCommand = defineCommand({
@@ -22,6 +23,11 @@ export const logsCommand = defineCommand({
22
23
  alias: 'n',
23
24
  description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
24
25
  },
26
+ list: {
27
+ type: 'boolean',
28
+ description: 'open the session viewer on the logs entry instead of dumping logs',
29
+ default: false,
30
+ },
25
31
  },
26
32
  async run({ args }) {
27
33
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -36,6 +42,15 @@ export const logsCommand = defineCommand({
36
42
  tail = parsed.value
37
43
  }
38
44
 
45
+ // The viewer is strictly opt-in via --list, so the default `typeclaw logs`
46
+ // (piped, redirected, -f, or a plain TTY dump) keeps the raw `docker logs`
47
+ // pump that `typeclaw logs | grep` and CI depend on. --list drops into the
48
+ // session viewer pre-opened on the logs entry, where esc returns to the list.
49
+ if (args.list) {
50
+ const exitCode = await runInspectViewer({ cwd, sessionArg: 'logs' })
51
+ process.exit(exitCode)
52
+ }
53
+
39
54
  if (args.follow) {
40
55
  console.log(c.cyan('Streaming container logs...'))
41
56
  } else {
package/src/cli/tui.ts CHANGED
@@ -3,14 +3,16 @@ import { defineCommand } from 'citty'
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { CLI_VERSION } from '@/init/cli-version'
6
- import { createTui, formatVersionMismatchWarning } from '@/tui'
6
+ import { runTuiViewer } from '@/inspect'
7
+ import { formatVersionMismatchWarning } from '@/tui'
7
8
 
9
+ import { runInspectViewer } from './inspect'
8
10
  import { errorLine } from './ui'
9
11
 
10
12
  export const tui = defineCommand({
11
13
  meta: {
12
14
  name: 'tui',
13
- description: 'start the tui client',
15
+ description: 'open the live agent session in the read+write viewer (host stage)',
14
16
  },
15
17
  args: {
16
18
  prompt: {
@@ -25,50 +27,42 @@ export const tui = defineCommand({
25
27
  },
26
28
  },
27
29
  async run({ args }) {
28
- const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
30
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
31
+ const resolveUrl: () => Promise<string> =
32
+ args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
29
33
 
30
- let initialPrompt: string | undefined = args.prompt
31
- let attempt = 0
32
- const RECONNECT_MAX_ATTEMPTS = 30
33
- const RECONNECT_BACKOFF_MS = 1_000
34
+ const result = await runTuiViewer({
35
+ resolveUrl,
36
+ ...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
37
+ expectedVersion: CLI_VERSION,
38
+ onVersionMismatch: (info) => {
39
+ process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
40
+ },
41
+ stderr: (line) => process.stderr.write(`${line}\n`),
42
+ })
34
43
 
35
- while (true) {
36
- const url = await resolveUrl()
37
- const tui = createTui({
38
- url,
39
- ...(initialPrompt !== undefined ? { initialPrompt } : {}),
40
- expectedVersion: CLI_VERSION,
41
- onVersionMismatch: (info) => {
42
- process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
43
- },
44
- })
45
- const outcome = await tui.run()
46
- if (!outcome.lostConnection) return
47
- // The TUI lost its WS post-handshake (container restart, network blip,
48
- // hostd hiccup). Re-resolve the URL because the host port can change
49
- // across container lifecycles (see resolveHostPort), then reconnect.
50
- // The initial prompt is intentionally cleared after the first cycle:
51
- // on a reconnect, the agent is resuming the same session — replaying
52
- // the prompt would re-send it to the LLM.
53
- initialPrompt = undefined
54
- attempt += 1
55
- if (attempt > RECONNECT_MAX_ATTEMPTS) {
56
- console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
57
- process.exit(1)
58
- }
59
- process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
60
- await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
44
+ // Esc detached from the live session: drop into the viewer list so the user
45
+ // can pick another session or the container logs — `tui` is just a deep-link
46
+ // into the session viewer, pre-opened on the live session. allowWritable
47
+ // is false because detaching ended the live session, so no row may be
48
+ // offered as a writable "live TUI" anymore.
49
+ if (result.ok && result.escToPicker === true) {
50
+ const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
51
+ process.exit(viewerExit)
52
+ return
61
53
  }
54
+
55
+ if (!result.ok) {
56
+ process.stderr.write(`${errorLine(result.reason)}\n`)
57
+ process.exit(result.exitCode)
58
+ }
59
+ process.exit(result.exitCode)
62
60
  },
63
61
  })
64
62
 
65
- async function defaultUrl(): Promise<string> {
66
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
63
+ async function defaultUrl(cwd: string): Promise<string> {
67
64
  const precheck = await requireContainerRunning({ cwd })
68
- if (!precheck.ok) {
69
- console.error(errorLine(precheck.reason))
70
- process.exit(1)
71
- }
65
+ if (!precheck.ok) throw new Error(precheck.reason)
72
66
  const port = await resolveHostPort({ cwd })
73
67
  const token = await resolveTuiToken({ cwd })
74
68
  const url = new URL(`ws://127.0.0.1:${port}`)
@@ -100,7 +100,7 @@ export async function composeLogs({
100
100
  follow,
101
101
  ...(tail !== undefined ? { tail } : {}),
102
102
  })
103
- const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
103
+ const proc = bun.spawn({ cmd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
104
104
  return { agent, proc }
105
105
  })
106
106
 
@@ -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