typeclaw 0.19.0 → 0.21.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
@@ -0,0 +1,155 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import type { DreamCategory, DreamEntry, DreamEntryDetail } from './types'
4
+
5
+ export type RenderOptions = { color: boolean }
6
+
7
+ type ColorName = 'dim' | 'cyan' | 'green' | 'yellow' | 'magenta' | 'gray'
8
+
9
+ function tint(opts: RenderOptions, color: ColorName, text: string): string {
10
+ if (!opts.color) return text
11
+ return styleText(color, text)
12
+ }
13
+
14
+ const CATEGORY_LABELS: Record<DreamCategory, string> = {
15
+ fragments: 'frag',
16
+ skills: 'skill',
17
+ 'watermarks-only': 'watermarks',
18
+ snapshot: 'snapshot',
19
+ other: 'other',
20
+ }
21
+
22
+ export function renderListRow(entry: DreamEntry, opts: RenderOptions): string {
23
+ const emoji = entry.emoji ?? '·'
24
+ const sha = tint(opts, 'cyan', entry.shortSha)
25
+ const date = tint(opts, 'dim', formatShortDate(entry.committedAt))
26
+ const when = tint(opts, 'dim', `(${formatRelative(entry.committedAt)})`)
27
+ const summary = entry.summary ?? entry.subject
28
+ const badges = renderCategoryBadges(entry.categories, opts)
29
+ const head = `${emoji} ${sha} ${date} ${when} ${summary}`
30
+ return badges.length > 0 ? `${head} ${badges}` : head
31
+ }
32
+
33
+ function renderCategoryBadges(categories: DreamCategory[], opts: RenderOptions): string {
34
+ if (categories.length === 0) return ''
35
+ const meaningful = categories.filter((c) => c !== 'other')
36
+ const shown = meaningful.length > 0 ? meaningful : categories
37
+ const labels = shown.map((c) => CATEGORY_LABELS[c])
38
+ return tint(opts, 'magenta', labels.map((l) => `[${l}]`).join(' '))
39
+ }
40
+
41
+ export function renderDetail(entry: DreamEntry, opts: RenderOptions): string {
42
+ const lines: string[] = []
43
+ const emoji = entry.emoji ?? '·'
44
+ lines.push(`${emoji} ${entry.subject}`)
45
+ lines.push(
46
+ tint(
47
+ opts,
48
+ 'dim',
49
+ `${entry.shortSha} · ${formatTimestamp(entry.committedAt)} · ${formatRelative(entry.committedAt)}`,
50
+ ),
51
+ )
52
+
53
+ const detail = entry.detail
54
+ if (detail === undefined) {
55
+ lines.push('', tint(opts, 'dim', '(no detail loaded)'))
56
+ return lines.join('\n')
57
+ }
58
+
59
+ renderFragments(lines, detail, opts)
60
+ renderTopics(lines, detail, opts)
61
+ renderSkills(lines, detail, opts)
62
+
63
+ if (detail.stateChanged) lines.push('', tint(opts, 'dim', 'state: .dreaming-state.json advanced'))
64
+ for (const warning of detail.parseWarnings) lines.push(tint(opts, 'yellow', `⚠ ${warning}`))
65
+
66
+ if (isQuietDream(detail)) {
67
+ lines.push('', tint(opts, 'dim', 'No fragments promoted, no shards changed this run.'))
68
+ }
69
+ return lines.join('\n')
70
+ }
71
+
72
+ function renderFragments(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
73
+ if (detail.addedFragments.length === 0) return
74
+ lines.push('', section(opts, `fragments folded in (${detail.addedFragments.length})`))
75
+ for (const f of detail.addedFragments) {
76
+ const id = tint(opts, 'dim', `${f.streamDate ?? '????'}#${f.id}`)
77
+ const topic = f.topic !== null ? tint(opts, 'magenta', ` [${f.topic}]`) : ''
78
+ lines.push(`• ${id}${topic}`)
79
+ if (f.bodyPreview !== null) lines.push(` ${tint(opts, 'gray', `"${f.bodyPreview}"`)}`)
80
+ }
81
+ }
82
+
83
+ function renderTopics(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
84
+ if (detail.changedTopics.length === 0) return
85
+ lines.push('', section(opts, `topic shards changed (${detail.changedTopics.length})`))
86
+ for (const t of detail.changedTopics) {
87
+ const counts =
88
+ t.additions !== null && t.deletions !== null ? tint(opts, 'dim', ` (+${t.additions} −${t.deletions})`) : ''
89
+ lines.push(`${statusGlyph(t.status, opts)} ${t.slug}${counts}`)
90
+ }
91
+ }
92
+
93
+ function renderSkills(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
94
+ if (detail.createdSkills.length === 0) return
95
+ lines.push('', section(opts, `skills distilled (${detail.createdSkills.length})`))
96
+ for (const s of detail.createdSkills)
97
+ lines.push(`${tint(opts, 'green', '✦')} ${s.name} ${tint(opts, 'dim', s.path)}`)
98
+ }
99
+
100
+ export function toJsonShape(entry: DreamEntry): Record<string, unknown> {
101
+ const base: Record<string, unknown> = {
102
+ sha: entry.sha,
103
+ shortSha: entry.shortSha,
104
+ committedAt: entry.committedAt,
105
+ subject: entry.subject,
106
+ isDreamCommit: entry.isDreamCommit,
107
+ summary: entry.summary,
108
+ emoji: entry.emoji,
109
+ categories: entry.categories,
110
+ }
111
+ if (entry.detail !== undefined) base.detail = entry.detail
112
+ return base
113
+ }
114
+
115
+ function isQuietDream(detail: DreamEntryDetail): boolean {
116
+ return detail.addedFragments.length === 0 && detail.changedTopics.length === 0 && detail.createdSkills.length === 0
117
+ }
118
+
119
+ function section(opts: RenderOptions, label: string): string {
120
+ return tint(opts, 'dim', `── ${label} ──`)
121
+ }
122
+
123
+ function statusGlyph(status: string, opts: RenderOptions): string {
124
+ if (status === 'added') return tint(opts, 'green', '✚ added ')
125
+ if (status === 'modified') return tint(opts, 'yellow', '✎ modified')
126
+ if (status === 'deleted') return tint(opts, 'dim', '✖ deleted ')
127
+ if (status === 'renamed') return tint(opts, 'cyan', '→ renamed ')
128
+ return '? unknown '
129
+ }
130
+
131
+ function formatRelative(iso: string): string {
132
+ const ms = Date.parse(iso)
133
+ if (Number.isNaN(ms)) return iso
134
+ const diff = Date.now() - ms
135
+ if (diff < 60_000) return 'just now'
136
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
137
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
138
+ return `${Math.floor(diff / 86_400_000)}d ago`
139
+ }
140
+
141
+ function formatTimestamp(iso: string): string {
142
+ const ms = Date.parse(iso)
143
+ if (Number.isNaN(ms)) return iso
144
+ const d = new Date(ms)
145
+ const pad = (n: number): string => String(n).padStart(2, '0')
146
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
147
+ }
148
+
149
+ function formatShortDate(iso: string): string {
150
+ const ms = Date.parse(iso)
151
+ if (Number.isNaN(ms)) return iso
152
+ const d = new Date(ms)
153
+ const pad = (n: number): string => String(n).padStart(2, '0')
154
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
155
+ }
@@ -0,0 +1,50 @@
1
+ // Mirrored from the bundled memory plugin's dreaming subagent rather than
2
+ // imported: this host-stage viewer must stay decoupled from runtime plugin
3
+ // internals, and only needs to RECOGNIZE the emoji set, not own it. The
4
+ // grammar test asserts this list stays in sync with the runtime's pool.
5
+ export const DREAM_EMOJI_POOL = ['💤', '🌙', '⭐', '🛌', '😴', '🧠', '💭', '🔮'] as const
6
+ export type DreamEmoji = (typeof DREAM_EMOJI_POOL)[number]
7
+
8
+ export type DreamCategory = 'fragments' | 'skills' | 'watermarks-only' | 'snapshot' | 'other'
9
+
10
+ export type DreamEntry = {
11
+ sha: string
12
+ shortSha: string
13
+ subject: string
14
+ committedAt: string
15
+ isDreamCommit: boolean
16
+ summary: string | null
17
+ emoji: DreamEmoji | null
18
+ categories: DreamCategory[]
19
+ detail?: DreamEntryDetail
20
+ }
21
+
22
+ export type DreamEntryDetail = {
23
+ addedFragments: FragmentEventSummary[]
24
+ changedTopics: TopicShardChange[]
25
+ createdSkills: SkillCreation[]
26
+ stateChanged: boolean
27
+ parseWarnings: string[]
28
+ }
29
+
30
+ export type FragmentEventSummary = {
31
+ id: string
32
+ streamDate: string | null
33
+ topic: string | null
34
+ bodyPreview: string | null
35
+ }
36
+
37
+ export type ShardChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'unknown'
38
+
39
+ export type TopicShardChange = {
40
+ path: string
41
+ slug: string
42
+ status: ShardChangeStatus
43
+ additions: number | null
44
+ deletions: number | null
45
+ }
46
+
47
+ export type SkillCreation = {
48
+ name: string
49
+ path: string
50
+ }
@@ -2,6 +2,12 @@ import { runInspect, type RunInspectOptions, type RunInspectResult } from './ind
2
2
 
3
3
  export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
4
  newEscSignal: () => AbortSignal
5
+ // Runs after every runInspect attempt settles. The caller disarms the raw-mode
6
+ // ESC listener here so the live tail releases stdin before clack re-opens the
7
+ // picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
8
+ // handler attached), and handing clack that flowing stream freezes the picker
9
+ // on SSH/Bun pseudo-TTYs.
10
+ afterEscStream?: () => void
5
11
  }
6
12
 
7
13
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
@@ -23,7 +29,12 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
23
29
  if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
24
30
  else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
25
31
 
26
- const result = await runInspect(callOpts)
32
+ let result: RunInspectResult
33
+ try {
34
+ result = await runInspect(callOpts)
35
+ } finally {
36
+ opts.afterEscStream?.()
37
+ }
27
38
  if (!result.ok) return result
28
39
  if (result.escToPicker !== true) return result
29
40
  sessionArg = undefined
@@ -9,6 +9,12 @@ export type PermissionService = {
9
9
  has(origin: SessionOrigin | undefined, permission: string): boolean
10
10
  resolveRole(origin: SessionOrigin | undefined): string
11
11
  describe(origin: SessionOrigin | undefined): { role: string; permissions: readonly string[] }
12
+ // Orders two role names on the severity tower so callers can cap an
13
+ // action to the requester's role (a guest turn must not read the output
14
+ // of a member-spawned subagent). `undefined` means an unknown role on
15
+ // either side and MUST be treated as deny, never allow — mistreating it
16
+ // as allow reopens the privilege-escalation hole this gate closes.
17
+ compareRoleSeverity(a: string, b: string): -1 | 0 | 1 | undefined
12
18
  // Rebuilds the resolved role table from the given roles config, preserving
13
19
  // the same plugin-permission set captured at construction time. Used by
14
20
  // the config reloadable so role match-rule edits (typeclaw role claim,
@@ -25,6 +31,7 @@ export type UnknownPermissionWarning = {
25
31
  export const noopPermissionService: PermissionService = {
26
32
  has: () => false,
27
33
  resolveRole: () => 'guest',
34
+ compareRoleSeverity: () => undefined,
28
35
  describe: () => ({ role: 'guest', permissions: [] }),
29
36
  replaceRoles: () => {},
30
37
  }
@@ -139,6 +146,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
139
146
  return 'guest'
140
147
  }
141
148
 
149
+ function roleSeverity(name: string): number | undefined {
150
+ if (name === 'owner') return 4
151
+ if (name === 'trusted') return 3
152
+ if (name === 'member') return 1
153
+ if (name === 'guest') return 0
154
+ if (byName.has(name)) return 2
155
+ return undefined
156
+ }
157
+
142
158
  return {
143
159
  has(origin, permission) {
144
160
  // Fail-safe floor: an undefined origin holds nothing, regardless of
@@ -156,6 +172,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
156
172
  return role.permissions.includes(permission)
157
173
  },
158
174
  resolveRole,
175
+ compareRoleSeverity(a, b) {
176
+ const aRank = roleSeverity(a)
177
+ const bRank = roleSeverity(b)
178
+ if (aRank === undefined || bRank === undefined) return undefined
179
+ if (aRank < bRank) return -1
180
+ if (aRank > bRank) return 1
181
+ return 0
182
+ },
159
183
  describe(origin) {
160
184
  const name = resolveRole(origin)
161
185
  const role = byName.get(name)
@@ -12,6 +12,7 @@ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
12
12
  import type { LiveSessionRegistry } from '@/agent/live-sessions'
13
13
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
14
14
  import { detectProviderError } from '@/agent/provider-error'
15
+ import { requestContainerRestart } from '@/agent/restart'
15
16
  import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-handoff'
16
17
  import type { SessionOrigin } from '@/agent/session-origin'
17
18
  import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
@@ -652,6 +653,11 @@ export function createServer({
652
653
  return
653
654
  }
654
655
 
656
+ if (msg.type === 'restart') {
657
+ await handleRestart(ws, state, containerName, agentDir, stream)
658
+ return
659
+ }
660
+
655
661
  if (msg.type === 'doctor') {
656
662
  await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
657
663
  return
@@ -1437,3 +1443,46 @@ async function handleReload(
1437
1443
  })
1438
1444
  }
1439
1445
  }
1446
+
1447
+ async function handleRestart(
1448
+ ws: Ws,
1449
+ state: SessionState | undefined,
1450
+ containerName: string | undefined,
1451
+ agentDir: string | undefined,
1452
+ stream: Stream | undefined,
1453
+ ): Promise<void> {
1454
+ if (containerName === undefined) {
1455
+ send(ws, {
1456
+ type: 'restart_result',
1457
+ status: 'failed',
1458
+ error: 'restart unavailable: no container name configured',
1459
+ })
1460
+ return
1461
+ }
1462
+
1463
+ // Pass stream so requestContainerRestart fans out the container-restarting
1464
+ // notice — the originating session's subscribeRestartNotice appends the
1465
+ // typeclaw.restart-self entry to its JSONL before the handoff is written, so
1466
+ // the rebooted container resumes with the "I'm back" instruction (same path
1467
+ // the agent restart tool uses).
1468
+ const originatingSessionFile = state?.sessionManager?.getSessionFile()
1469
+ const result = await requestContainerRestart({
1470
+ containerName,
1471
+ ...(agentDir !== undefined ? { agentDir } : {}),
1472
+ ...(state?.sessionFileId !== undefined ? { originatingSessionId: state.sessionFileId } : {}),
1473
+ ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
1474
+ ...(stream !== undefined ? { stream } : {}),
1475
+ })
1476
+ if (!result.ok) {
1477
+ send(ws, { type: 'restart_result', status: 'failed', error: result.reason })
1478
+ return
1479
+ }
1480
+
1481
+ // hostd's supervisor ACKs first, then runs stop+start in the background;
1482
+ // this process should not self-exit or it could race the daemon-owned stop.
1483
+ send(ws, {
1484
+ type: 'restart_result',
1485
+ status: 'accepted',
1486
+ message: 'restart scheduled; reconnecting when the new container is up',
1487
+ })
1488
+ }
@@ -130,6 +130,7 @@ export type InspectServerMessage =
130
130
  export type ClientMessage =
131
131
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
132
132
  | { type: 'reload'; scope?: string }
133
+ | { type: 'restart' }
133
134
  | { type: 'abort' }
134
135
  | { type: 'queue_cancel'; messageId: string }
135
136
  | { type: 'doctor'; requestId: DoctorRequestId }
@@ -213,6 +214,7 @@ export type ServerMessage =
213
214
  | { type: 'done' }
214
215
  | { type: 'error'; message: string }
215
216
  | { type: 'reload_result'; results: ReloadResultPayload[] }
217
+ | { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
216
218
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
217
219
  | { type: 'queue_state'; pending: QueueStateItem[] }
218
220
  | { type: 'prompt_started'; messageId: string; text: string }
@@ -50,7 +50,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
50
50
 
51
51
  2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.). The reviewer fetches the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), loads the `code-review` skill, and returns a `<review>` block whose code findings carry `location="path:line"`.
52
52
 
53
- If you post an "on it" acknowledgement before spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
53
+ 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.
54
54
 
55
55
  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:
56
56
 
@@ -118,7 +118,9 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
118
118
 
119
119
  The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
120
120
 
121
- 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post the zero-actionable-findings branch below uses `channel_reply`/issue comments _as_ the substantive reply.
121
+ 6. **The decoy reviewer is dropped for you no action needed.** Under **GitHub App** auth, the adapter automatically removes the decoy reviewer from the PR's requested-reviewers list the moment your formal review lands (it reacts to your own `pull_request_review.submitted` webhook). Why this matters: GitHub auto-adds **you** (the App account) to the PR's reviewers when your review posts, but the **decoy** account would otherwise stay pinned as a perpetual "review requested", as if the review never happened. You do **not** need to issue a `DELETE /requested_reviewers` yourself — and you should not, since it would race the adapter's own cleanup. The removal is self-loop-safe: the adapter's `DELETE` is authenticated as the App, so the `review_request_removed` webhook carries your bot actor (`slug[bot]`) as `sender`, which the classifier drops (see "Self-loop safety" below). This is a no-op under **PAT** auth (no decoy) and for **plain-language**/**team** requests (no decoy user was placed). See [GitHub decoy reviewer](/docs/internals/github-decoy-reviewer).
122
+
123
+ 7. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branch below uses `channel_reply`/issue comments _as_ the substantive reply.
122
124
 
123
125
  ### Zero actionable findings
124
126
 
@@ -147,3 +149,5 @@ For App auth, `GH_TOKEN` is an installation access token that refreshes automati
147
149
  ## Self-loop safety
148
150
 
149
151
  The adapter will **not** wake you when you assign yourself as a reviewer (e.g., via `gh pr edit --add-reviewer`). It will only wake you when someone else requests your review.
152
+
153
+ The same guard covers **removing** a reviewer: when the adapter drops the decoy after your review lands (step 6 of the PR review flow), the `DELETE` is authenticated as the App, so the `review_request_removed` webhook GitHub emits carries your bot actor (`slug[bot]`) as its `sender`, which the classifier drops. So the cleanup never echoes back as a fresh wake. Both directions — add and remove — are matched on `sender.login` (against either the bot actor or its decoy), so any reviewer-list mutation made under your identity stays silent.
package/src/tui/index.ts CHANGED
@@ -11,19 +11,25 @@ export type TerminalFactory = () => Terminal
11
11
 
12
12
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
13
13
 
14
- // Bare slash-command names (no leading `/`) the TUI intercepts client-side and
15
- // turns into a clean process exit. The hatching ritual tells the agent to point
16
- // users at `/quit` (see src/init/hatching.ts); without an intercept the literal
17
- // text would be shipped to the LLM as a chat message. Grammar (case-insensitive,
18
- // whitespace-tolerant, `//foo` escapes to a literal prompt) comes from
19
- // `parseCommand` in src/commands so channel and TUI slash commands stay
20
- // consistent. Arguments after the name disqualify the match: `/quit me a story`
21
- // is a real prompt, not a command.
14
+ // Bare slash-command names (no leading `/`) the TUI intercepts client-side.
15
+ // The hatching ritual tells the agent to point users at `/quit` (see
16
+ // src/init/hatching.ts); without an intercept the literal text would be shipped
17
+ // to the LLM as a chat message. Grammar (case-insensitive, whitespace-tolerant,
18
+ // `//foo` escapes to a literal prompt) comes from `parseCommand` in
19
+ // src/commands so channel and TUI slash commands stay consistent. Arguments
20
+ // after the name disqualify the match: `/quit me a story` is a real prompt, not
21
+ // a command.
22
22
  const QUIT_COMMAND_NAMES: ReadonlySet<string> = new Set(['quit', 'exit'])
23
+ const TUI_COMMAND_NAMES: ReadonlySet<TuiCommandName> = new Set(['quit', 'reload', 'restart'])
23
24
 
24
- function isQuitCommand(text: string): boolean {
25
+ type TuiCommandName = 'quit' | 'reload' | 'restart'
26
+
27
+ function parseBareTuiCommand(text: string): TuiCommandName | null {
25
28
  const parsed = parseCommand(text)
26
- return parsed !== null && parsed.args.length === 0 && QUIT_COMMAND_NAMES.has(parsed.name)
29
+ if (parsed === null || parsed.args.length > 0) return null
30
+ if (QUIT_COMMAND_NAMES.has(parsed.name)) return 'quit'
31
+ if (TUI_COMMAND_NAMES.has(parsed.name as TuiCommandName)) return parsed.name as TuiCommandName
32
+ return null
27
33
  }
28
34
 
29
35
  export type VersionMismatch = { expected: string; actual: string }
@@ -203,6 +209,25 @@ export function createTui({
203
209
  updateQueuePanel(msg.pending)
204
210
  break
205
211
  }
212
+ case 'reload_result': {
213
+ for (const result of msg.results) {
214
+ const text = result.ok
215
+ ? `${colors.green('●')} ${colors.bold(`[${result.scope}]`)} ${result.summary}`
216
+ : `${colors.red('●')} ${colors.bold(`[${result.scope}]`)} ${result.reason}`
217
+ appendHistory(new Text(text, 0, 0))
218
+ }
219
+ tui.requestRender()
220
+ break
221
+ }
222
+ case 'restart_result': {
223
+ const text =
224
+ msg.status === 'accepted'
225
+ ? colors.green(colors.dim(msg.message ?? 'restart scheduled; reconnecting when the new container is up'))
226
+ : colors.red(`restart failed: ${msg.error ?? 'unknown error'}`)
227
+ appendHistory(new Text(text, 0, 0))
228
+ tui.requestRender()
229
+ break
230
+ }
206
231
  }
207
232
  })
208
233
 
@@ -222,6 +247,25 @@ export function createTui({
222
247
  })
223
248
  }
224
249
 
250
+ function runTuiCommand(command: TuiCommandName): boolean {
251
+ if (command === 'quit') {
252
+ shutdown(0)
253
+ return true
254
+ }
255
+ if (command === 'reload') {
256
+ client.send({ type: 'reload' })
257
+ appendHistory(new Text(colors.dim('reloading...'), 0, 0))
258
+ tui.requestRender()
259
+ return true
260
+ }
261
+ client.send({ type: 'restart' })
262
+ appendHistory(
263
+ new Text(colors.yellow(colors.dim('restart requested... reconnecting when the new container is up')), 0, 0),
264
+ )
265
+ tui.requestRender()
266
+ return true
267
+ }
268
+
225
269
  // Esc aborts an in-flight reply. The Editor does not bind Esc, so a
226
270
  // top-level input listener can intercept it without fighting the editor.
227
271
  tui.addInputListener((data) => {
@@ -252,8 +296,13 @@ export function createTui({
252
296
 
253
297
  editor.onSubmit = (text) => {
254
298
  if (text.trim().length === 0) return
255
- if (isQuitCommand(text)) {
256
- shutdown(0)
299
+ const command = parseBareTuiCommand(text)
300
+ if (command !== null) {
301
+ if (command !== 'quit') {
302
+ editor.setText('')
303
+ editor.addToHistory(text)
304
+ }
305
+ runTuiCommand(command)
257
306
  return
258
307
  }
259
308
  editor.setText('')
@@ -275,13 +324,16 @@ export function createTui({
275
324
 
276
325
  if (initialPrompt) {
277
326
  // initialPrompt bypasses editor.onSubmit, so the quit intercept above
278
- // would never run. Guard the same way so `typeclaw tui /quit` exits
279
- // instead of leaking the command into the agent's chat context.
280
- if (isQuitCommand(initialPrompt)) {
281
- shutdown(0)
282
- return { lostConnection: false }
327
+ // would never run. Guard the same way so `typeclaw tui /quit` exits
328
+ // and `/reload` / `/restart` stay websocket control frames instead of
329
+ // leaking the command into the agent's chat context.
330
+ const command = parseBareTuiCommand(initialPrompt)
331
+ if (command !== null) {
332
+ runTuiCommand(command)
333
+ if (command === 'quit') return { lostConnection: false }
334
+ } else {
335
+ await send(initialPrompt)
283
336
  }
284
- await send(initialPrompt)
285
337
  }
286
338
 
287
339
  const lostConnection = await closed