typeclaw 0.20.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.
@@ -0,0 +1,134 @@
1
+ import { readDreamCommitLog, readDreamCommitShow, resolveGitRepo, type SpawnGit } from './git'
2
+ import { parseDreamDetail, parseDreamSubject } from './parse'
3
+ import { renderDetail, renderListRow, type RenderOptions, toJsonShape } from './render'
4
+ import type { DreamEntry } from './types'
5
+
6
+ export type { SpawnGit } from './git'
7
+ export type { DreamEntry } from './types'
8
+ export { renderDetail, renderListRow, toJsonShape } from './render'
9
+ export { parseDreamDetail, parseDreamSubject } from './parse'
10
+
11
+ export type SelectDreamOptions = {
12
+ initialSha?: string
13
+ }
14
+
15
+ export type SelectDream = (entries: DreamEntry[], opts?: SelectDreamOptions) => Promise<DreamEntry | null>
16
+
17
+ // 'back' re-opens the picker with the just-viewed dream pre-selected.
18
+ export type ViewAction = 'back' | 'exit'
19
+ export type ViewDream = () => Promise<ViewAction>
20
+
21
+ export type RunDreamsOptions = {
22
+ agentDir: string
23
+ json: boolean
24
+ details: boolean
25
+ color: boolean
26
+ limit?: number
27
+ selectDream: SelectDream
28
+ viewDream?: ViewDream
29
+ stdout: (line: string) => void
30
+ spawnGit?: SpawnGit
31
+ }
32
+
33
+ export type RunDreamsResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
34
+
35
+ export async function listDreams(
36
+ agentDir: string,
37
+ opts: { limit?: number } = {},
38
+ spawnGit?: SpawnGit,
39
+ ): Promise<DreamEntry[]> {
40
+ const repo = await resolveGitRepo(agentDir, spawnGit)
41
+ if (!repo.ok) return []
42
+ const commits = await readDreamCommitLog(repo.root, opts.limit !== undefined ? { limit: opts.limit } : {}, spawnGit)
43
+ return commits.map((commit) => {
44
+ const subject = parseDreamSubject(commit.subject)
45
+ return {
46
+ sha: commit.sha,
47
+ shortSha: commit.shortSha,
48
+ subject: commit.subject,
49
+ committedAt: commit.committedAt,
50
+ isDreamCommit: subject.isDreamCommit,
51
+ summary: subject.summary,
52
+ emoji: subject.emoji,
53
+ categories: subject.categories,
54
+ }
55
+ })
56
+ }
57
+
58
+ export async function hydrateDream(agentDir: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
59
+ const repo = await resolveGitRepo(agentDir, spawnGit)
60
+ if (!repo.ok) return entry
61
+ const show = await readDreamCommitShow(repo.root, entry.sha, spawnGit)
62
+ if (show === null) return entry
63
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
64
+ }
65
+
66
+ export async function runDreams(opts: RunDreamsOptions): Promise<RunDreamsResult> {
67
+ const repo = await resolveGitRepo(opts.agentDir, opts.spawnGit)
68
+ if (!repo.ok) {
69
+ if (repo.reason === 'not-a-repo') {
70
+ return {
71
+ ok: false,
72
+ exitCode: 1,
73
+ reason:
74
+ "Not a git repository. Dreams live in the agent folder's git history — run this from your agent folder.",
75
+ }
76
+ }
77
+ return { ok: false, exitCode: 1, reason: 'git failed while resolving the repository root.' }
78
+ }
79
+
80
+ const entries = await listDreams(opts.agentDir, opts.limit !== undefined ? { limit: opts.limit } : {}, opts.spawnGit)
81
+ const renderOpts: RenderOptions = { color: opts.color }
82
+
83
+ if (opts.json) return runJson(opts, repo.root, entries)
84
+
85
+ if (entries.length === 0) {
86
+ opts.stdout('No dreams yet. The dreaming subagent commits here after it consolidates memory.')
87
+ return { ok: true, exitCode: 0 }
88
+ }
89
+
90
+ if (!isInteractive()) {
91
+ for (const entry of entries) opts.stdout(renderListRow(entry, renderOpts))
92
+ return { ok: true, exitCode: 0 }
93
+ }
94
+
95
+ return runInteractiveLoop(opts, entries, renderOpts)
96
+ }
97
+
98
+ async function runInteractiveLoop(
99
+ opts: RunDreamsOptions,
100
+ entries: DreamEntry[],
101
+ renderOpts: RenderOptions,
102
+ ): Promise<RunDreamsResult> {
103
+ let initialSha: string | undefined
104
+ while (true) {
105
+ const picked = await opts.selectDream(entries, initialSha !== undefined ? { initialSha } : {})
106
+ if (picked === null) return { ok: true, exitCode: 0 }
107
+ initialSha = picked.sha
108
+
109
+ const hydrated = await hydrateDream(opts.agentDir, picked, opts.spawnGit)
110
+ opts.stdout(renderDetail(hydrated, renderOpts))
111
+
112
+ if (opts.viewDream === undefined) return { ok: true, exitCode: 0 }
113
+ const action = await opts.viewDream()
114
+ if (action === 'exit') return { ok: true, exitCode: 0 }
115
+ }
116
+ }
117
+
118
+ async function runJson(opts: RunDreamsOptions, root: string, entries: DreamEntry[]): Promise<RunDreamsResult> {
119
+ for (const entry of entries) {
120
+ const final = opts.details ? await hydrateEntryFromRoot(root, entry, opts.spawnGit) : entry
121
+ opts.stdout(JSON.stringify(toJsonShape(final)))
122
+ }
123
+ return { ok: true, exitCode: 0 }
124
+ }
125
+
126
+ async function hydrateEntryFromRoot(root: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
127
+ const show = await readDreamCommitShow(root, entry.sha, spawnGit)
128
+ if (show === null) return entry
129
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
130
+ }
131
+
132
+ function isInteractive(): boolean {
133
+ return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
134
+ }
@@ -0,0 +1,224 @@
1
+ import {
2
+ type DreamCategory,
3
+ type DreamEmoji,
4
+ DREAM_EMOJI_POOL,
5
+ type DreamEntryDetail,
6
+ type FragmentEventSummary,
7
+ type ShardChangeStatus,
8
+ type SkillCreation,
9
+ type TopicShardChange,
10
+ } from './types'
11
+
12
+ const BODY_PREVIEW_MAX = 80
13
+ const EMOJI_SET = new Set<string>(DREAM_EMOJI_POOL)
14
+ const STREAM_PATH = /^memory\/(?:streams\/)?(\d{4}-\d{2}-\d{2})\.jsonl$/
15
+ const TOPIC_PATH = /^memory\/topics\/(.+)\.md$/
16
+ const SKILL_PATH = /^memory\/skills\/([^/]+)\/SKILL\.md$/
17
+ const STATE_PATH = 'memory/.dreaming-state.json'
18
+
19
+ export type DreamSubject = {
20
+ isDreamCommit: boolean
21
+ summary: string | null
22
+ emoji: DreamEmoji | null
23
+ categories: DreamCategory[]
24
+ }
25
+
26
+ export function parseDreamSubject(subject: string): DreamSubject {
27
+ const match = /^dream:\s+(.*)$/.exec(subject)
28
+ if (match === null) {
29
+ return { isDreamCommit: false, summary: null, emoji: null, categories: [] }
30
+ }
31
+
32
+ const rest = (match[1] ?? '').trim()
33
+ const { summary, emoji } = splitTrailingEmoji(rest)
34
+ return {
35
+ isDreamCommit: true,
36
+ summary: summary.length > 0 ? summary : null,
37
+ emoji,
38
+ categories: classifySummary(summary),
39
+ }
40
+ }
41
+
42
+ function splitTrailingEmoji(rest: string): { summary: string; emoji: DreamEmoji | null } {
43
+ const chars = [...rest]
44
+ const last = chars.at(-1)
45
+ if (last !== undefined && EMOJI_SET.has(last)) {
46
+ return { summary: chars.slice(0, -1).join('').trim(), emoji: last as DreamEmoji }
47
+ }
48
+ return { summary: rest, emoji: null }
49
+ }
50
+
51
+ function classifySummary(summary: string): DreamCategory[] {
52
+ const categories: DreamCategory[] = []
53
+ if (/\bfragments?\b/.test(summary)) categories.push('fragments')
54
+ if (/\bskills?\b/.test(summary)) categories.push('skills')
55
+ if (/watermarks only/.test(summary)) categories.push('watermarks-only')
56
+ if (/^snapshot$/.test(summary)) categories.push('snapshot')
57
+ if (categories.length === 0) categories.push('other')
58
+ return categories
59
+ }
60
+
61
+ export function parseDreamDetail(nameStatus: string, patch: string): DreamEntryDetail {
62
+ const warnings: string[] = []
63
+ const changedTopics: TopicShardChange[] = []
64
+ const createdSkills: SkillCreation[] = []
65
+ let stateChanged = false
66
+
67
+ for (const { status, path, oldPath } of parseNameStatus(nameStatus)) {
68
+ if (path === STATE_PATH || oldPath === STATE_PATH) {
69
+ stateChanged = true
70
+ continue
71
+ }
72
+ const topic = TOPIC_PATH.exec(path)
73
+ if (topic !== null) {
74
+ changedTopics.push({ path, slug: topic[1] ?? path, status, additions: null, deletions: null })
75
+ continue
76
+ }
77
+ if (status === 'added') {
78
+ const skill = SKILL_PATH.exec(path)
79
+ if (skill !== null) createdSkills.push({ name: skill[1] ?? '', path })
80
+ }
81
+ }
82
+
83
+ applyTopicLineCounts(changedTopics, patch, warnings)
84
+ const addedFragments = extractAddedFragments(patch, warnings)
85
+
86
+ return { addedFragments, changedTopics, createdSkills, stateChanged, parseWarnings: warnings }
87
+ }
88
+
89
+ type NameStatusRow = { status: ShardChangeStatus; path: string; oldPath: string | null }
90
+
91
+ function parseNameStatus(nameStatus: string): NameStatusRow[] {
92
+ const rows: NameStatusRow[] = []
93
+ for (const line of nameStatus.split('\n')) {
94
+ if (line.trim().length === 0) continue
95
+ const cols = line.split('\t')
96
+ const code = cols[0] ?? ''
97
+ if (code.startsWith('R')) {
98
+ const oldPath = cols[1] ?? ''
99
+ const newPath = cols[2] ?? ''
100
+ if (newPath.length > 0) rows.push({ status: 'renamed', path: newPath, oldPath })
101
+ continue
102
+ }
103
+ const path = cols[1] ?? ''
104
+ if (path.length === 0) continue
105
+ rows.push({ status: mapStatusCode(code), path, oldPath: null })
106
+ }
107
+ return rows
108
+ }
109
+
110
+ function mapStatusCode(code: string): ShardChangeStatus {
111
+ const c = code.charAt(0)
112
+ if (c === 'A') return 'added'
113
+ if (c === 'M') return 'modified'
114
+ if (c === 'D') return 'deleted'
115
+ if (c === 'R') return 'renamed'
116
+ return 'unknown'
117
+ }
118
+
119
+ type ParsedHunk = { path: string; addedLines: string[] }
120
+
121
+ function* iterateHunks(patch: string): Generator<ParsedHunk> {
122
+ let currentPath: string | null = null
123
+ let added: string[] = []
124
+ const flush = function* (): Generator<ParsedHunk> {
125
+ if (currentPath !== null) yield { path: currentPath, addedLines: added }
126
+ }
127
+
128
+ for (const line of patch.split('\n')) {
129
+ if (line.startsWith('diff --git ')) {
130
+ yield* flush()
131
+ currentPath = null
132
+ added = []
133
+ continue
134
+ }
135
+ if (line.startsWith('+++ ')) {
136
+ currentPath = stripDiffPathPrefix(line.slice(4))
137
+ continue
138
+ }
139
+ if (line.startsWith('+') && !line.startsWith('+++')) {
140
+ added.push(line.slice(1))
141
+ }
142
+ }
143
+ yield* flush()
144
+ }
145
+
146
+ function stripDiffPathPrefix(raw: string): string {
147
+ const trimmed = raw.trim()
148
+ if (trimmed === '/dev/null') return trimmed
149
+ return trimmed.replace(/^b\//, '')
150
+ }
151
+
152
+ function extractAddedFragments(patch: string, warnings: string[]): FragmentEventSummary[] {
153
+ const out: FragmentEventSummary[] = []
154
+ for (const hunk of iterateHunks(patch)) {
155
+ const streamMatch = STREAM_PATH.exec(hunk.path)
156
+ if (streamMatch === null) continue
157
+ const streamDate = streamMatch[1] ?? null
158
+ for (const line of hunk.addedLines) {
159
+ if (line.trim().length === 0) continue
160
+ const fragment = parseFragmentLine(line, streamDate)
161
+ if (fragment === null) {
162
+ warnings.push(`unparseable stream line in ${hunk.path}`)
163
+ continue
164
+ }
165
+ if (fragment !== 'skip') out.push(fragment)
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ function parseFragmentLine(line: string, streamDate: string | null): FragmentEventSummary | 'skip' | null {
172
+ let raw: unknown
173
+ try {
174
+ raw = JSON.parse(line)
175
+ } catch {
176
+ return null
177
+ }
178
+ if (typeof raw !== 'object' || raw === null) return null
179
+ const obj = raw as Record<string, unknown>
180
+ if (obj.type !== 'fragment') return 'skip'
181
+ if (typeof obj.id !== 'string' || obj.id.length === 0) return null
182
+ return {
183
+ id: obj.id,
184
+ streamDate,
185
+ topic: typeof obj.topic === 'string' ? obj.topic : null,
186
+ bodyPreview: typeof obj.body === 'string' ? preview(obj.body) : null,
187
+ }
188
+ }
189
+
190
+ function applyTopicLineCounts(topics: TopicShardChange[], patch: string, warnings: string[]): void {
191
+ if (topics.length === 0) return
192
+ const counts = new Map<string, { additions: number; deletions: number }>()
193
+ let currentPath: string | null = null
194
+
195
+ for (const line of patch.split('\n')) {
196
+ if (line.startsWith('+++ ')) {
197
+ currentPath = stripDiffPathPrefix(line.slice(4))
198
+ if (currentPath !== null && !counts.has(currentPath)) counts.set(currentPath, { additions: 0, deletions: 0 })
199
+ continue
200
+ }
201
+ if (currentPath === null) continue
202
+ const bucket = counts.get(currentPath)
203
+ if (bucket === undefined) continue
204
+ if (line.startsWith('+') && !line.startsWith('+++')) bucket.additions++
205
+ else if (line.startsWith('-') && !line.startsWith('---')) bucket.deletions++
206
+ }
207
+
208
+ for (const topic of topics) {
209
+ if (topic.status === 'deleted') continue
210
+ const bucket = counts.get(topic.path)
211
+ if (bucket === undefined) {
212
+ if (topic.status === 'modified') warnings.push(`no diff hunk for modified topic ${topic.path}`)
213
+ continue
214
+ }
215
+ topic.additions = bucket.additions
216
+ topic.deletions = bucket.deletions
217
+ }
218
+ }
219
+
220
+ function preview(body: string): string {
221
+ const oneline = body.replace(/\s+/g, ' ').trim()
222
+ if (oneline.length <= BODY_PREVIEW_MAX) return oneline
223
+ return `${oneline.slice(0, BODY_PREVIEW_MAX)}…`
224
+ }
@@ -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
+ }
@@ -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 }
@@ -118,15 +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. **Drop the decoy reviewer App auth only, request-path only.** When this review was triggered by an explicit review-request inbound (path A) **and** you are running under **GitHub App** auth, remove the decoy reviewer from the PR's requested-reviewers list once the review has landed (after step 5 confirms it). Why: GitHub auto-adds **you** (the App account) to the PR's reviewers the moment your formal review posts, so the "reviewed" state is already recorded under the App identity — but the **decoy** account stays pinned in the requested-reviewers list as a perpetual "review requested", as if the review never happened. Removing it is the natural "I'm done, drop me" cleanup a human reviewer gets for free. The decoy login is the App slug `selfLogin` with the trailing `[bot]` removed (`my-app[bot]` `my-app`); see [GitHub decoy reviewer](/docs/internals/github-decoy-reviewer).
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
122
 
123
- ```sh
124
- gh api -X DELETE /repos/owner/repo/pulls/<N>/requested_reviewers -f 'reviewers[]=<decoy-login>'
125
- ```
126
-
127
- **Skip this entirely** when (a) you are under **PAT** auth — there is no decoy; the bot is a real user GitHub keeps listed as a reviewer, and removing yourself there is not wanted — or (b) the review came from a **plain-language** ask (path B) or a **team** request, where no decoy user was ever placed on the requested-reviewers list and the DELETE would be a no-op/404. The removal is safe under the adapter's self-loop guard: you make the `DELETE` authenticated as the App, so the `review_request_removed` webhook GitHub emits has your bot actor (`slug[bot]`) as `sender`, which the classifier drops, so it never wakes a fresh session (see "Self-loop safety" below). Treat a failure here as non-fatal — the review already landed; do not retry in a loop or surface it to the PR thread.
128
-
129
- 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 (and step 6 has dropped the decoy, if applicable), 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.
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.
130
124
 
131
125
  ### Zero actionable findings
132
126
 
@@ -156,4 +150,4 @@ For App auth, `GH_TOKEN` is an installation access token that refreshes automati
156
150
 
157
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.
158
152
 
159
- The same guard covers **removing** a reviewer: when you drop the decoy after a review (step 6 of the PR review flow), you act 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 you make yourself stays silent.
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.