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.
- package/package.json +1 -1
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +27 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +69 -0
- package/src/channels/adapters/github/index.ts +11 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +71 -31
- package/src/channels/router.ts +112 -10
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -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/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -9
- package/src/tui/index.ts +70 -18
|
@@ -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
|
+
}
|
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 }
|
|
@@ -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. **
|
|
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
|
-
|
|
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
|
|
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.
|