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
@@ -0,0 +1,167 @@
1
+ import type { SlackBotClient } from 'agent-messenger/slackbot'
2
+
3
+ import type {
4
+ ReactionCallback,
5
+ ReactionErrorCode,
6
+ ReactionRef,
7
+ ReactionResult,
8
+ RemoveReactionCallback,
9
+ } from '@/channels/types'
10
+
11
+ // The reactable target on Slack: a message is addressed by its channel id plus
12
+ // the message `ts`. The classifier stamps this because `ts` is the inbound's
13
+ // own message timestamp — the same value that becomes `externalMessageId`
14
+ // downstream, but kept in an opaque ref so the router/tool never have to know
15
+ // Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
16
+ export type SlackReactionTarget = { channel: string; ts: string }
17
+
18
+ // Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
19
+ // (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
20
+ // was added into the removal ref so `RemoveReactionCallback` can reconstruct
21
+ // the exact call without the caller tracking it.
22
+ export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
23
+
24
+ export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
25
+ return { adapter: 'slack-bot', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
29
+ if (ref.adapter !== 'slack-bot') return null
30
+ const parsed = parseRecord(ref.value)
31
+ if (parsed === null) return null
32
+ if (parsed.op !== undefined) return null
33
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
34
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
35
+ if (channel === null || ts === null) return null
36
+ return { channel, ts }
37
+ }
38
+
39
+ export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
40
+ return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
41
+ }
42
+
43
+ export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
44
+ if (ref.adapter !== 'slack-bot') return null
45
+ const parsed = parseRecord(ref.value)
46
+ if (parsed === null || parsed.op !== 'remove') return null
47
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
48
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
49
+ const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
50
+ if (channel === null || ts === null || emoji === null) return null
51
+ return { channel, ts, emoji }
52
+ }
53
+
54
+ // Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
55
+ // is no fixed allow-list to validate against up front — an unknown name comes
56
+ // back as `invalid_name` from the API, which we map to `unsupported`. We only
57
+ // strip surrounding colons here; the SDK does the same, but normalizing first
58
+ // keeps the removal ref's stored name canonical.
59
+ function normalizeEmoji(emoji: string): string {
60
+ return emoji.replace(/^:|:$/g, '')
61
+ }
62
+
63
+ export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
64
+ return async (req): Promise<ReactionResult> => {
65
+ if (req.adapter !== 'slack-bot') {
66
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
67
+ }
68
+ const target = decodeSlackReactionRef(req.reactionRef)
69
+ if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
70
+ const emoji = normalizeEmoji(req.emoji)
71
+ try {
72
+ await deps.client.addReaction(target.channel, target.ts, emoji)
73
+ } catch (err) {
74
+ // `already_reacted` is the desired end state, not a failure: a duplicate
75
+ // engage (or a retried tool call) that re-adds the same emoji must read
76
+ // as success so the model/runtime don't surface a spurious error.
77
+ const code = slackErrorCode(err)
78
+ if (code === 'already_reacted') {
79
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
80
+ }
81
+ return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
82
+ }
83
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
84
+ }
85
+ }
86
+
87
+ export function createSlackRemoveReactionCallback(deps: {
88
+ client: Pick<SlackBotClient, 'removeReaction'>
89
+ }): RemoveReactionCallback {
90
+ return async (req): Promise<ReactionResult> => {
91
+ if (req.adapter !== 'slack-bot') {
92
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
93
+ }
94
+ const target = decodeSlackRemovalRef(req.reactionRef)
95
+ if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
96
+ try {
97
+ await deps.client.removeReaction(target.channel, target.ts, target.emoji)
98
+ } catch (err) {
99
+ // `no_reaction` means the reaction is already gone — the desired end
100
+ // state for a removal, so treat it as success (idempotent), mirroring the
101
+ // `already_reacted` handling on the add path.
102
+ const code = slackErrorCode(err)
103
+ if (code === 'no_reaction') return { ok: true }
104
+ return { ok: false, error: describe(err), code: classifySlackError(code) }
105
+ }
106
+ return { ok: true }
107
+ }
108
+ }
109
+
110
+ // SlackBotError carries the raw Slack API error string on `.code`. We read it
111
+ // structurally (not by instanceof) so a re-thrown or wrapped error still maps
112
+ // correctly, falling back to the message when no code is present.
113
+ function slackErrorCode(err: unknown): string | null {
114
+ if (typeof err === 'object' && err !== null && 'code' in err) {
115
+ const code = (err as { code: unknown }).code
116
+ if (typeof code === 'string') return code
117
+ }
118
+ return null
119
+ }
120
+
121
+ // `reactions:write` is the scope the bot token needs for both add and remove.
122
+ // On `missing_scope` the bare Slack error is uninformative, so append the
123
+ // concrete operator fix — mirroring GitHub's permission-guidance precedent —
124
+ // since autoReactOnEngage surfaces this to host logs on every engaged inbound
125
+ // until the scope is granted.
126
+ function withScopeHint(code: string | null, error: string): string {
127
+ if (code !== 'missing_scope') return error
128
+ return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
129
+ }
130
+
131
+ function classifySlackError(code: string | null): ReactionErrorCode {
132
+ switch (code) {
133
+ case 'invalid_name':
134
+ case 'no_item_specified':
135
+ return 'unsupported'
136
+ case 'missing_scope':
137
+ case 'not_in_channel':
138
+ case 'is_archived':
139
+ case 'not_authed':
140
+ case 'invalid_auth':
141
+ return 'permission-denied'
142
+ case 'message_not_found':
143
+ case 'channel_not_found':
144
+ return 'not-found'
145
+ case 'ratelimited':
146
+ case 'rate_limited':
147
+ return 'rate-limited'
148
+ default:
149
+ return 'transient'
150
+ }
151
+ }
152
+
153
+ function parseRecord(value: string): Record<string, unknown> | null {
154
+ let parsed: unknown
155
+ try {
156
+ parsed = JSON.parse(value)
157
+ } catch {
158
+ return null
159
+ }
160
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
161
+ ? (parsed as Record<string, unknown>)
162
+ : null
163
+ }
164
+
165
+ function describe(err: unknown): string {
166
+ return err instanceof Error ? err.message : String(err)
167
+ }
@@ -43,6 +43,7 @@ import {
43
43
  type SlackInboundMessageEvent,
44
44
  } from './slack-bot-classify'
45
45
  import { createSlackDedupe } from './slack-bot-dedupe'
46
+ import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
46
47
  import { enrichSlackReferenceContext } from './slack-bot-reference'
47
48
  import {
48
49
  buildSlashAckPayload,
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
997
998
 
998
999
  const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
999
1000
 
1001
+ const reactionCallback = createSlackReactionCallback({ client })
1002
+ const removeReactionCallback = createSlackRemoveReactionCallback({ client })
1003
+
1000
1004
  const dedupe = createSlackDedupe()
1001
1005
 
1002
1006
  const handleSlashCommand = createSlashCommandHandler({
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1206
1210
  })
1207
1211
 
1208
1212
  options.router.registerOutbound('slack-bot', outboundCallback)
1213
+ options.router.registerReaction('slack-bot', reactionCallback)
1214
+ options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
1209
1215
  options.router.registerTyping('slack-bot', typingCallback)
1210
1216
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1211
1217
  options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1216
1222
  try {
1217
1223
  await listener.start()
1218
1224
  } catch (err) {
1225
+ // Listener failed after registration — roll back every callback so a
1226
+ // failed start leaves no router state behind (stop() returns early on
1227
+ // !started and would otherwise skip cleanup), mirroring the github
1228
+ // adapter's rollback path.
1229
+ options.router.unregisterOutbound('slack-bot', outboundCallback)
1230
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1231
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1232
+ options.router.unregisterTyping('slack-bot', typingCallback)
1233
+ options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1234
+ options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1235
+ options.router.unregisterHistory('slack-bot', historyCallback)
1236
+ options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
1237
+ options.router.unregisterMembership('slack-bot', membershipResolver)
1238
+ listener = null
1239
+ botUserId = null
1240
+ teamId = null
1219
1241
  started = false
1220
1242
  logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
1221
1243
  throw err
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1226
1248
  if (!started) return
1227
1249
  started = false
1228
1250
  options.router.unregisterOutbound('slack-bot', outboundCallback)
1251
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1252
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1229
1253
  options.router.unregisterTyping('slack-bot', typingCallback)
1230
1254
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1231
1255
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
@@ -2854,7 +2854,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2854
2854
  return
2855
2855
  }
2856
2856
 
2857
- const { text: assistantText, source } = candidate
2857
+ const { text: candidateText, source } = candidate
2858
+ let assistantText = candidateText
2858
2859
 
2859
2860
  if (endsWithNoReplySignal(assistantText)) {
2860
2861
  const leakedReasoning = !isNoReplySignal(assistantText)
@@ -2874,10 +2875,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2874
2875
  return
2875
2876
  }
2876
2877
 
2877
- if (isLikelyPlainTextChannelToolCall(assistantText)) {
2878
- logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
2878
+ // Plain-text tool-call leak: the model serialized a channel tool call as
2879
+ // ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
2880
+ // failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
2881
+ // raw `channel_reply({...})` serialization to the channel, but for
2882
+ // reply/send the model's *intent* is unambiguous: deliver the `text` arg.
2883
+ // Extract it and recover the actual message. `skip_response` is the
2884
+ // opposite — a genuine decline — so it stays suppressed.
2885
+ const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
2886
+ if (plainTextToolCallKind === 'skip') {
2887
+ logger.warn(
2888
+ `[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
2889
+ )
2879
2890
  return
2880
2891
  }
2892
+ if (plainTextToolCallKind !== null) {
2893
+ const extracted = extractPlainTextChannelToolCallText(assistantText)
2894
+ // Unextractable (no `text` arg, empty value, or fully-truncated): fall
2895
+ // back to the historical safe behavior — drop it rather than leak plumbing.
2896
+ if (extracted === null) {
2897
+ logger.warn(
2898
+ `[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
2899
+ )
2900
+ return
2901
+ }
2902
+ // The extracted value is still untrusted model output: if it is itself a
2903
+ // no-reply signal, an empty-response sentinel, or another (nested) leaked
2904
+ // tool call, suppress it through the same guards rather than re-leaking.
2905
+ if (
2906
+ endsWithNoReplySignal(extracted) ||
2907
+ isUpstreamEmptyResponseSentinel(extracted) ||
2908
+ isLikelyKimiChannelToolLeak(extracted) ||
2909
+ isLikelyPlainTextChannelToolCall(extracted)
2910
+ ) {
2911
+ logger.warn(
2912
+ `[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
2913
+ )
2914
+ return
2915
+ }
2916
+ logger.warn(
2917
+ `[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
2918
+ )
2919
+ assistantText = extracted
2920
+ }
2881
2921
 
2882
2922
  // `source` distinguishes the three recovery shapes for log triage:
2883
2923
  // - 'leaf': the assistant message IS the leaf with stopReason 'stop'
@@ -3233,6 +3273,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3233
3273
  error?: string
3234
3274
  },
3235
3275
  ): { kind: 'delivered'; keyId: string } => {
3276
+ const adapter = live.keyId.split(':', 1)[0] ?? ''
3236
3277
  const text = renderSubagentCompletionReminder({
3237
3278
  subagent: args.subagent,
3238
3279
  taskId: args.taskId,
@@ -3240,6 +3281,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3240
3281
  durationMs: args.durationMs,
3241
3282
  ...(args.error !== undefined ? { error: args.error } : {}),
3242
3283
  channel: true,
3284
+ adapter,
3243
3285
  })
3244
3286
  live.pendingSystemReminders.push(text)
3245
3287
  // The reminder tells the agent to fetch this result now; clear the
@@ -4231,8 +4273,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4231
4273
  //
4232
4274
  // Structural-only detection (NOT a substring search): the trimmed text must
4233
4275
  // *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
4234
- // that opening paren must enclose at least one `"` (the serialized argument).
4235
- // This deliberately matches the leak shape while letting prose that merely
4276
+ // that opening paren must enclose at least one quote — `"` or `'` (the
4277
+ // serialized argument). The single-quote arm matters because the extractor
4278
+ // recovers single-quoted values too; if the classifier only matched `"`, a
4279
+ // single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
4280
+ // extractor and post raw plumbing. This deliberately matches the leak shape
4281
+ // while letting prose that merely
4236
4282
  // *mentions* a tool name (e.g. "I would normally call channel_reply here
4237
4283
  // but...") reach the user — that false-positive class is already locked in by
4238
4284
  // the `still recovers prose that mentions channel_reply` test.
@@ -4240,12 +4286,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4240
4286
  // The trailing close paren is NOT required: the model sometimes truncates
4241
4287
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
4242
4288
  // just as user-hostile as the full shape.
4243
- const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(?:channel_(?:reply|send)|skip_response)\s*\(\s*[^)]*"/
4289
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
4290
+
4291
+ export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
4292
+
4293
+ export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
4294
+ const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
4295
+ if (match === null) return null
4296
+ switch (match[1]) {
4297
+ case 'channel_reply':
4298
+ return 'reply'
4299
+ case 'channel_send':
4300
+ return 'send'
4301
+ case 'skip_response':
4302
+ return 'skip'
4303
+ default:
4304
+ return null
4305
+ }
4306
+ }
4244
4307
 
4245
4308
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
4246
- return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
4309
+ return getPlainTextChannelToolCallKind(text) !== null
4310
+ }
4311
+
4312
+ // Tolerant single-purpose scanner that pulls the `text` argument out of a
4313
+ // plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
4314
+ // single regex covering every shape (double/single/unquoted keys, escaped
4315
+ // quotes, mid-serialization truncation) is fragile, so this walks the string
4316
+ // once and extracts only the first string-valued `text` property. `channel_send`
4317
+ // also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
4318
+ // recovery always routes back through the current channel, never a
4319
+ // model-supplied destination. Returns null when no recoverable, non-empty
4320
+ // `text` value is present so the caller can fall back to suppression.
4321
+ export function extractPlainTextChannelToolCallText(text: string): string | null {
4322
+ const trimmed = text.trim()
4323
+ if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
4324
+
4325
+ // Walk the serialization once, honoring a `text` key only at the top level of
4326
+ // the argument object (braceDepth 1, outside any array). Two failure classes
4327
+ // motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
4328
+ // `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
4329
+ // inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
4330
+ // "real" })`. Skipping string literals defeats the first; tracking
4331
+ // brace/bracket depth and matching keys only at top level defeats the second.
4332
+ // Either way the scanner lands on the real reply instead of leaking the wrong
4333
+ // value or dropping the message.
4334
+ let braceDepth = 0
4335
+ let bracketDepth = 0
4336
+ for (let i = 0; i < trimmed.length; i++) {
4337
+ const ch = trimmed[i]!
4338
+
4339
+ if (ch === '"' || ch === "'") {
4340
+ i = skipStringLiteral(trimmed, i, ch)
4341
+ continue
4342
+ }
4343
+
4344
+ if (ch === '{') {
4345
+ braceDepth++
4346
+ if (braceDepth === 1 && bracketDepth === 0) {
4347
+ const value = readTextKeyValueAt(trimmed, i + 1)
4348
+ if (value !== undefined) return value
4349
+ }
4350
+ continue
4351
+ }
4352
+ if (ch === '}') {
4353
+ if (braceDepth > 0) braceDepth--
4354
+ continue
4355
+ }
4356
+ if (ch === '[') {
4357
+ bracketDepth++
4358
+ continue
4359
+ }
4360
+ if (ch === ']') {
4361
+ if (bracketDepth > 0) bracketDepth--
4362
+ continue
4363
+ }
4364
+
4365
+ if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
4366
+ const value = readTextKeyValueAt(trimmed, i + 1)
4367
+ if (value !== undefined) return value
4368
+ }
4369
+ }
4370
+
4371
+ return null
4372
+ }
4373
+
4374
+ // Returns the recovered value (string or null) when a `text` key starts at
4375
+ // `from`, or undefined when no `text` key is present there so the scanner keeps
4376
+ // walking. The null/undefined split lets a malformed `text` value short-circuit
4377
+ // to suppression while a non-`text` delimiter is simply skipped.
4378
+ function readTextKeyValueAt(s: string, from: number): string | null | undefined {
4379
+ const afterKey = matchTextKey(s, from)
4380
+ if (afterKey === null) return undefined
4381
+
4382
+ const quote = s[afterKey]
4383
+ if (quote !== '"' && quote !== "'") return null
4384
+ return readStringValue(s, afterKey + 1, quote)
4385
+ }
4386
+
4387
+ // Returns the closing-quote index, or the last index when the literal is
4388
+ // truncated, so the caller's `i++` resumes past the consumed string.
4389
+ function skipStringLiteral(s: string, openIdx: number, quote: string): number {
4390
+ let escaped = false
4391
+ for (let i = openIdx + 1; i < s.length; i++) {
4392
+ const ch = s[i]!
4393
+ if (escaped) {
4394
+ escaped = false
4395
+ continue
4396
+ }
4397
+ if (ch === '\\') {
4398
+ escaped = true
4399
+ continue
4400
+ }
4401
+ if (ch === quote) return i
4402
+ }
4403
+ return s.length
4404
+ }
4405
+
4406
+ function matchTextKey(s: string, from: number): number | null {
4407
+ const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
4408
+ return m === null ? null : from + m[0].length
4247
4409
  }
4248
4410
 
4411
+ function readStringValue(s: string, from: number, quote: string): string | null {
4412
+ let value = ''
4413
+ let escaped = false
4414
+ for (let i = from; i < s.length; i++) {
4415
+ const ch = s[i]!
4416
+ if (escaped) {
4417
+ value += ESCAPE_REPLACEMENTS[ch] ?? ch
4418
+ escaped = false
4419
+ continue
4420
+ }
4421
+ if (ch === '\\') {
4422
+ escaped = true
4423
+ continue
4424
+ }
4425
+ if (ch === quote) break
4426
+ value += ch
4427
+ }
4428
+ return value.trim().length > 0 ? value : null
4429
+ }
4430
+
4431
+ const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
4432
+
4249
4433
  function describe(err: unknown): string {
4250
4434
  return err instanceof Error ? err.message : String(err)
4251
4435
  }
@@ -125,12 +125,53 @@ 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