typeclaw 0.23.0 → 0.24.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/subagent-completion-reminder.ts +3 -1
  7. package/src/agent/subagents.ts +44 -1
  8. package/src/agent/system-prompt.ts +4 -0
  9. package/src/agent/todo/continuation-policy.ts +242 -0
  10. package/src/agent/todo/continuation-state.ts +87 -0
  11. package/src/agent/todo/continuation-wiring.ts +113 -0
  12. package/src/agent/todo/continuation.ts +71 -0
  13. package/src/agent/todo/scope.ts +77 -0
  14. package/src/agent/todo/store.ts +98 -0
  15. package/src/agent/tool-not-found-nudge.ts +119 -0
  16. package/src/agent/tools/channel-reply.ts +51 -0
  17. package/src/agent/tools/restart.ts +11 -4
  18. package/src/agent/tools/todo/index.ts +119 -0
  19. package/src/bundled-plugins/backup/runner.ts +1 -1
  20. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  21. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  22. package/src/channels/adapters/discord-bot.ts +25 -3
  23. package/src/channels/adapters/github/inbound.ts +161 -10
  24. package/src/channels/adapters/github/index.ts +10 -0
  25. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  27. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  28. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  29. package/src/channels/adapters/slack-bot.ts +67 -8
  30. package/src/channels/manager.ts +8 -2
  31. package/src/channels/router.ts +445 -22
  32. package/src/channels/schema.ts +20 -4
  33. package/src/channels/types.ts +68 -0
  34. package/src/cli/inspect-controller.ts +7 -0
  35. package/src/cli/inspect.ts +2 -1
  36. package/src/commands/index.ts +9 -0
  37. package/src/init/gitignore.ts +5 -2
  38. package/src/inspect/index.ts +22 -0
  39. package/src/run/index.ts +60 -5
  40. package/src/sandbox/build.ts +10 -0
  41. package/src/sandbox/index.ts +2 -0
  42. package/src/sandbox/policy.ts +10 -0
  43. package/src/sandbox/writable-zones.ts +78 -0
  44. package/src/server/index.ts +118 -4
  45. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  46. package/typeclaw.schema.json +10 -0
@@ -17,6 +17,14 @@ import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-hand
17
17
  import type { SessionOrigin } from '@/agent/session-origin'
18
18
  import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
19
19
  import type { CreateSessionForSubagent } from '@/agent/subagents'
20
+ import { TODO_CONTINUATION_SOURCE } from '@/agent/todo/continuation'
21
+ import {
22
+ armRestartKickForOrigin,
23
+ extractTurnUsage,
24
+ recordTurnOutcome,
25
+ recordTurnStart,
26
+ runIdleContinuation,
27
+ } from '@/agent/todo/continuation-wiring'
20
28
  import type { ChannelRouter } from '@/channels/router'
21
29
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
22
30
  import type { McpManager } from '@/mcp'
@@ -155,6 +163,7 @@ type QueuedPrompt = {
155
163
  text: string
156
164
  delivery: PromptDelivery
157
165
  ts: number
166
+ source?: string
158
167
  }
159
168
 
160
169
  type SessionState = {
@@ -172,6 +181,7 @@ type SessionState = {
172
181
  // generation that ran session.start. A plugin reload mid-connection does
173
182
  // not re-target this session's lifecycle hooks.
174
183
  runtimeSnapshot: PluginRuntimeState | null
184
+ unsubTurnOutcome: Unsubscribe | null
175
185
  dispose: () => Promise<void>
176
186
  }
177
187
 
@@ -257,7 +267,7 @@ export function createServer({
257
267
  handoffPending = false
258
268
  return null
259
269
  }
260
- handoffInFlight = consumeRestartHandoff(agentDir).catch(() => null)
270
+ handoffInFlight = consumeRestartHandoff(agentDir, { accept: (h) => h.origin.kind === 'tui' }).catch(() => null)
261
271
  const result = await handoffInFlight
262
272
  handoffPending = false
263
273
  handoffInFlight = null
@@ -497,6 +507,7 @@ export function createServer({
497
507
  unsubClaim: null,
498
508
  activeClaimCode: null,
499
509
  runtimeSnapshot: runtimeSnapshot ?? null,
510
+ unsubTurnOutcome: null,
500
511
  dispose,
501
512
  }
502
513
  sessionStates.set(ws, state)
@@ -505,12 +516,16 @@ export function createServer({
505
516
  await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
506
517
  }
507
518
 
519
+ if (agentDir !== undefined) {
520
+ state.unsubTurnOutcome = subscribeTurnOutcome(session, agentDir, origin, sessionFileId, logger)
521
+ }
522
+
508
523
  liveSessionRegistry?.register({ sessionId: sessionFileId, session })
509
524
  forwardSessionEvents(ws, session, logger, sessionFileId)
510
525
 
511
526
  if (stream) {
512
527
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
513
- enqueuePrompt(ws, state, msg, agentDir, logger),
528
+ enqueuePrompt(ws, state, msg, agentDir, logger, stream),
514
529
  )
515
530
 
516
531
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -543,6 +558,17 @@ export function createServer({
543
558
  // wired (state.unsubPrompts above) so the kick is enqueued, not
544
559
  // dropped on the floor.
545
560
  if (resumed !== null && stream) {
561
+ // Arm the one-shot restart-kick suppressor BEFORE publishing the
562
+ // kick: the kick owns the first post-restart turn ("I'm back"),
563
+ // so the first idle after it must not also fire a todo
564
+ // continuation. The flag is consumed by that first idle. Best-
565
+ // effort: a failure here only risks one redundant nudge, which
566
+ // the episode budget still bounds.
567
+ if (agentDir !== undefined) {
568
+ await armRestartKickForOrigin(agentDir, origin).catch((err) =>
569
+ logger.error(`[server] ${sessionFileId}: arm restart-kick suppression failed: ${describeErr(err)}`),
570
+ )
571
+ }
546
572
  stream.publish({
547
573
  target: { kind: 'session', sessionId: sessionFileId },
548
574
  payload: { kind: 'prompt', text: ' ', delivery: 'queue' },
@@ -798,6 +824,7 @@ export function createServer({
798
824
  }
799
825
  } finally {
800
826
  if (state) {
827
+ state.unsubTurnOutcome?.()
801
828
  state.session.dispose()
802
829
  await state.dispose()
803
830
  liveSessionRegistry?.unregister(state.sessionFileId)
@@ -867,6 +894,31 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
867
894
  })
868
895
  }
869
896
 
897
+ // Record each completed turn's stopReason for the todo-continuation guard.
898
+ // Ordering-independent by design: this writes the outcome from `message_end`,
899
+ // and the idle path only reads the stored outcome — it never assumes the
900
+ // event arrived before idle fired. An unrecognized stopReason classifies as
901
+ // 'unknown', which the idle path treats as not-safe-to-continue (fail closed).
902
+ function subscribeTurnOutcome(
903
+ session: AgentSession,
904
+ agentDir: string,
905
+ origin: SessionOrigin,
906
+ sessionFileId: string,
907
+ logger: ServerLogger,
908
+ ): Unsubscribe {
909
+ return session.subscribe((event) => {
910
+ const usage = extractTurnUsage(event)
911
+ if (usage === null) return
912
+ void recordTurnOutcome({
913
+ agentDir,
914
+ origin,
915
+ turnId: sessionFileId,
916
+ stopReason: usage.stopReason,
917
+ ...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
918
+ }).catch((err) => logger.error(`[server] ${sessionFileId}: todo outcome capture failed: ${describeErr(err)}`))
919
+ })
920
+ }
921
+
870
922
  function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
871
923
  const detected = detectProviderError(message)
872
924
  if (detected === null) return
@@ -895,6 +947,7 @@ function enqueuePrompt(
895
947
  msg: StreamMessage,
896
948
  agentDir: string | undefined,
897
949
  logger: ServerLogger,
950
+ stream: Stream | undefined,
898
951
  ): void {
899
952
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
900
953
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
@@ -904,14 +957,16 @@ function enqueuePrompt(
904
957
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
905
958
  })
906
959
  }
960
+ const source = (msg.meta as { source?: unknown } | undefined)?.source
907
961
  state.drainQueue.push({
908
962
  streamMessageId: msg.id,
909
963
  text: payload.text,
910
964
  delivery,
911
965
  ts: msg.ts,
966
+ ...(typeof source === 'string' ? { source } : {}),
912
967
  })
913
968
  pushQueueState(ws, state)
914
- void drain(ws, state, agentDir, logger)
969
+ void drain(ws, state, agentDir, logger, stream)
915
970
  }
916
971
 
917
972
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -948,7 +1003,13 @@ function makeTurnHookCallers(
948
1003
  }
949
1004
  }
950
1005
 
951
- async function drain(ws: Ws, state: SessionState, agentDir: string | undefined, logger: ServerLogger): Promise<void> {
1006
+ async function drain(
1007
+ ws: Ws,
1008
+ state: SessionState,
1009
+ agentDir: string | undefined,
1010
+ logger: ServerLogger,
1011
+ stream: Stream | undefined,
1012
+ ): Promise<void> {
952
1013
  if (state.draining) return
953
1014
  state.draining = true
954
1015
  const fireIdle = makeIdleHookCaller(state)
@@ -960,6 +1021,14 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
960
1021
  pushQueueState(ws, state)
961
1022
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
962
1023
 
1024
+ if (agentDir !== undefined) {
1025
+ await recordTurnStart({
1026
+ agentDir,
1027
+ origin: state.origin,
1028
+ isRealUserTurn: item.source !== TODO_CONTINUATION_SOURCE,
1029
+ }).catch((err) => logger.error(`[server] ${state.sessionFileId}: todo turn-start failed: ${describeErr(err)}`))
1030
+ }
1031
+
963
1032
  await fireTurnStart(item.text)
964
1033
  try {
965
1034
  await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
@@ -971,12 +1040,57 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
971
1040
  }
972
1041
  await fireTurnEnd()
973
1042
  await fireIdle()
1043
+
1044
+ // Idle-continuation runs INSIDE the loop and enqueues directly onto
1045
+ // drainQueue (not via stream.publish). Publishing would re-enter drain()
1046
+ // through the session subscriber while `state.draining` is still true, so
1047
+ // the nested call would no-op and the continuation would stall until some
1048
+ // unrelated event woke the loop again. Enqueuing here lets the same `while`
1049
+ // consume it on the next iteration. Only fires when the queue is otherwise
1050
+ // empty so a real user turn is never preempted by a continuation.
1051
+ if (state.drainQueue.length === 0) {
1052
+ await maybeContinueTodos(state, agentDir, logger)
1053
+ }
974
1054
  }
975
1055
  } finally {
976
1056
  state.draining = false
977
1057
  }
978
1058
  }
979
1059
 
1060
+ // If incomplete todos remain and all guards pass, push a single continuation
1061
+ // prompt directly onto this session's drainQueue, tagged TODO_CONTINUATION_SOURCE
1062
+ // so the next drain iteration treats it as an injected (non-user) turn that does
1063
+ // not reset the episode budget. The enclosing drain loop consumes it; this never
1064
+ // calls drain() itself.
1065
+ async function maybeContinueTodos(
1066
+ state: SessionState,
1067
+ agentDir: string | undefined,
1068
+ logger: ServerLogger,
1069
+ ): Promise<void> {
1070
+ if (agentDir === undefined) return
1071
+ try {
1072
+ await runIdleContinuation({
1073
+ agentDir,
1074
+ origin: state.origin,
1075
+ deliver: (text) => {
1076
+ state.drainQueue.push({
1077
+ streamMessageId: `todo-continuation-${crypto.randomUUID()}` as StreamMessageId,
1078
+ text,
1079
+ delivery: 'queue',
1080
+ ts: Date.now(),
1081
+ source: TODO_CONTINUATION_SOURCE,
1082
+ })
1083
+ },
1084
+ })
1085
+ } catch (err) {
1086
+ logger.error(`[server] ${state.sessionFileId}: todo continuation failed: ${describeErr(err)}`)
1087
+ }
1088
+ }
1089
+
1090
+ function describeErr(err: unknown): string {
1091
+ return err instanceof Error ? err.message : String(err)
1092
+ }
1093
+
980
1094
  function pushQueueState(ws: Ws, state: SessionState): void {
981
1095
  const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
982
1096
  id: q.streamMessageId,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-channel-github
3
- description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. When a review comment **you authored** gets addressed — the author pushed a fix or replied that resolves it — verify the fix at the PR's head SHA and then resolve the thread with the `resolveReviewThread` GraphQL mutation (see "Resolving review threads you authored" below); resolving is the close-out that tells the author the concern is settled. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. When a review comment **you authored** gets addressed — the author pushed a fix or replied that resolves it — verify the fix at the PR's head SHA and then resolve the thread by acknowledging with `channel_reply({ …, resolve_review_thread: true })`, which resolves the thread before posting the reply (see "Resolving review threads you authored" below); resolving is the close-out that tells the author the concern is settled. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
4
4
  ---
5
5
 
6
6
  GitHub renders normal Markdown in issues, PRs, discussions, and review comments. Use headings, lists, tables, fenced code blocks, links, and inline code when they improve clarity.
@@ -8,7 +8,7 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
8
8
  - Do not send attachments on GitHub chats; the adapter rejects them.
9
9
  - There is no typing indicator.
10
10
  - For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
11
- - When a review comment **you authored** has been addressed, resolve its thread — see "Resolving review threads you authored" below. The base principle is **whoever opened the thread closes it**: you resolve only the threads you started, never a human's.
11
+ - When a review comment **you authored** has been addressed, resolve its thread by replying with `channel_reply({ …, resolve_review_thread: true })` — see "Resolving review threads you authored" below. The base principle is **whoever opened the thread closes it**: you resolve only the threads you started, never a human's (the runtime enforces this).
12
12
 
13
13
  ## Mid-turn status replies need `continue: true`
14
14
 
@@ -43,6 +43,10 @@ A `review_request_removed` inbound ("removed your review request on PR #N") is t
43
43
 
44
44
  The `reviewer` subagent is the analyst; you are the integration layer between its output and GitHub's review API. It loads the `code-review` skill on demand and returns line-anchored findings inside a `<review>` block. Your job is mechanics: spawn, wait, translate, post.
45
45
 
46
+ **The reviewer's `<review>` block is the only source of the verdict and the findings.** You do not review the PR yourself. Between spawning the reviewer and reading its result you do **no analysis of this PR** — do not run `gh pr diff`, do not read the changed files to form an opinion, do not draft a verdict. The reviewer runs on the `deep` model precisely so this judgment is not yours to make on the parent model. If you analyze the diff and post your own assessment while the reviewer is still running, you will post one verdict now and the reviewer's (often different) verdict when it completes — **two contradictory reviews on the same PR**, the exact failure this flow exists to prevent. Wait for the reviewer; post what it returns; nothing before that.
47
+
48
+ **HARD RULE — a review with actionable findings is a formal review, never a flat comment.** If the reviewer returns **one or more** actionable findings (`blocker`/`concern`/`nit`), the ONLY acceptable way to deliver them is a formal review via `POST /pulls/<N>/reviews` (step 4) — `REQUEST_CHANGES`, `COMMENT`, or `APPROVE` per the verdict, with the line-anchored findings in `comments[]`. You may **never** flatten those findings into a `channel_reply` or a top-level issue comment, **even when** the `gh api` call fails. A 422 means an anchor is wrong (almost always a `line` not in the diff): re-anchor it or move that one finding into the top-level review `body`, then resubmit the formal review — do **not** abandon the formal review and post prose instead. A flat "## Review … two blockers" comment is a bug, not a fallback: it strands the findings without line anchors and is the exact failure this rule exists to prevent. The flat/issue-comment path is reserved for the **zero-actionable-findings** branch only (see below). If you genuinely cannot land a formal review after fixing anchors, say so plainly and post nothing that claims a review happened — silence beats a false receipt.
49
+
46
50
  1. **Confirm the target, and check whether you already reviewed it.** Capture the PR number, the repo, and the head SHA — you may need the SHA to read files at the revision the reviewer analyzed.
47
51
 
48
52
  ```sh
@@ -65,9 +69,15 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
65
69
 
66
70
  **If step 1 found a prior `CHANGES_REQUESTED` review, say so in the spawn payload** — e.g. _"This is a re-review: you previously requested changes on this PR (the prior blockers were …). Verify they are resolved and return `approve` or `request-changes` — a re-review must re-decide the blocking state, not return `comment`."_ The reviewer's `code-review` skill enforces the same rule, but telling it the prior verdict is what lets it apply that rule; a fresh reviewer session has no memory of your earlier review.
67
71
 
68
- Do **not** post an "on it" acknowledgement comment before spawning the reviewer — the runtime already adds an :eyes: reaction to the PR the moment it engages, so a "looking into this" comment is redundant noise. Just spawn the reviewer with `run_in_background: true` and keep working; the formal review is your reply. If you want to acknowledge explicitly, use `channel_react({ emoji: "eyes" })`, which reacts without posting a comment.
72
+ Do **not** post an "on it" acknowledgement comment before spawning the reviewer — the runtime already adds an :eyes: reaction to the PR the moment it engages, so a "looking into this" comment is redundant noise. Just spawn the reviewer with `run_in_background: true`; the formal review is your reply. If you want to acknowledge explicitly, use `channel_react({ emoji: "eyes" })`, which reacts without posting a comment.
73
+
74
+ After spawning, **end your turn** — the background reviewer wakes you with a completion `<system-reminder>` (step 3). "Stay responsive" means you remain free to handle _other_ chats meanwhile; it does **not** license you to keep working _this_ PR. Do not poll `subagent_output` in a busy-wait, and do not fill the wait by reviewing the diff yourself (see the exclusivity rule at the top of this flow). The next thing you do on this PR is read the reviewer's `<review>` block when the reminder arrives.
75
+
76
+ 3. **On the completion `<system-reminder>`, first check you have not already posted — then** call `subagent_output({ task_id })` to read the reviewer's final assistant message.
69
77
 
70
- 3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
78
+ **One verdict per PR per request — guard this before you read or post anything.** The completion reminder is not a license to post; it is a wake-up. The very first thing you do on this turn is ask: have I **already posted a review or verdict on this PR during this engagement**? If yes, stop here — do not fetch the reviewer output, do not translate, do not post. Call `skip_response({ reason: "review already posted for this PR" })` and end the turn. Posting the reminder's result on top of a verdict you already shipped is how a PR ends up with two reviews — and if the two disagree (because the earlier one was your own premature take), it contradicts you in public. Only when no review has gone out yet do you proceed to read and post below — which is the normal path, since you waited for the reviewer instead of reviewing it yourself.
79
+
80
+ With that confirmed, read the reviewer's final assistant message. The structured payload looks like:
71
81
 
72
82
  ```xml
73
83
  <review>
@@ -174,11 +184,28 @@ Do not resolve on a bare "done" claim. A reply that says "fixed" is a prompt to
174
184
  2. Read the lines your comment anchored to, at that SHA: `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>` (or `gh pr diff <N>` to see what the new push changed). Confirm the change actually addresses the concern your comment raised — not a different line, not a partial fix.
175
185
  3. Only when the code at head genuinely resolves the finding do you resolve the thread. If the fix is partial or misses the point, **reply in the thread** explaining what's still open and leave it unresolved.
176
186
 
177
- If the author merely **replied** without pushing (e.g. "this is intentional because …") and their reasoning settles it, that is also "addressed" — **resolve first, then optionally leave a one-line acknowledgement.** Order matters: a bare `channel_reply` ends your turn (see "Mid-turn status replies need `continue: true`" above), so acknowledging _before_ you resolve would stop the turn and the `resolveReviewThread` mutation would never run, leaving the thread open. Resolve, then reply. If you genuinely want to acknowledge before resolving, the acknowledgement must use `channel_reply({ …, continue: true })` so the turn survives long enough to resolve. If their reasoning does **not** settle it, keep the thread open and answer.
187
+ If the author merely **replied** without pushing (e.g. "this is intentional because …") and their reasoning settles it, that is also "addressed". If their reasoning does **not** settle it, keep the thread open and answer instead.
188
+
189
+ ### How to resolve — `channel_reply({ resolve_review_thread: true })`
190
+
191
+ Once you have verified the fix, **acknowledge and resolve in one call**: pass `resolve_review_thread: true` to your `channel_reply`. The runtime resolves the thread you're replying in **before** it posts your acknowledgement, then posts the reply:
192
+
193
+ ```
194
+ channel_reply({ text: "Verified — the fix addresses the concern. Thanks!", resolve_review_thread: true })
195
+ ```
196
+
197
+ This is the correct path and it removes a footgun. A bare `channel_reply` ends your turn the moment it lands, so a resolve attempted _after_ the acknowledgement would never run — the thread would stay open even though you "handled" it. The flag resolves first, so a normal final reply still closes the thread. You do **not** need `continue: true` for this: resolution happens inside the same call, before the turn ends.
198
+
199
+ Two guarantees make the flag safe to use as your default:
200
+
201
+ - **Author check is enforced in code.** The runtime only resolves a thread whose root comment **you** authored; a request to resolve a human reviewer's thread is refused, and the reply is **not** posted. You cannot accidentally close someone else's open question.
202
+ - **A failed resolve blocks the reply.** If the resolve fails (permission denied, wrong author, the fix doesn't verify on the API side), `channel_reply` is denied and posts nothing — so you never end up with a cheerful "looks resolved" comment sitting next to a still-open thread. Read the denial, fix the cause, and retry.
203
+
204
+ The flag is valid only on a github session replying inside a thread (`thread` set on the origin). It is ignored — and denied — elsewhere. If the thread is already resolved or already gone, the reply still posts (nothing left to close).
178
205
 
179
- ### How to resolve `resolveReviewThread` GraphQL mutation
206
+ ### Fallback the raw `resolveReviewThread` GraphQL mutation
180
207
 
181
- There is no REST endpoint for this. Resolution is a GraphQL mutation that takes the thread's **node id** (`PRRT_…`), not the comment's numeric id. Two steps: find the thread id, then resolve it.
208
+ Prefer the flag above. Reach for the raw mutation only when you need to resolve a thread you are **not** currently replying in, or to debug. There is no REST endpoint for this. Resolution is a GraphQL mutation that takes the thread's **node id** (`PRRT_…`), not the comment's numeric id. Two steps: find the thread id, then resolve it.
182
209
 
183
210
  1. **Find the node id of the thread you authored.** Query the PR's review threads and pick the one whose root comment is yours and matches the `thread` you're replying in:
184
211
 
@@ -548,10 +548,20 @@
548
548
  },
549
549
  "review": {
550
550
  "default": {
551
+ "on": "review_requested",
551
552
  "approve": true
552
553
  },
553
554
  "type": "object",
554
555
  "properties": {
556
+ "on": {
557
+ "default": "review_requested",
558
+ "type": "string",
559
+ "enum": [
560
+ "review_requested",
561
+ "opened",
562
+ "off"
563
+ ]
564
+ },
555
565
  "approve": {
556
566
  "default": true,
557
567
  "type": "boolean"