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.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- 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
|
+
}
|
package/src/inspect/loop.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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. **
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
25
|
+
type TuiCommandName = 'quit' | 'reload' | 'restart'
|
|
26
|
+
|
|
27
|
+
function parseBareTuiCommand(text: string): TuiCommandName | null {
|
|
25
28
|
const parsed = parseCommand(text)
|
|
26
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|