typeclaw 0.25.0 → 0.27.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/session-origin.ts +36 -5
- package/src/agent/subagent-completion-reminder.ts +16 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/channels/adapters/discord-bot-classify.ts +3 -0
- package/src/channels/adapters/discord-bot-reactions.ts +164 -0
- package/src/channels/adapters/discord-bot.ts +23 -0
- package/src/channels/adapters/github/inbound.ts +60 -13
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- package/src/channels/adapters/slack-bot-classify.ts +2 -0
- package/src/channels/adapters/slack-bot-reactions.ts +167 -0
- package/src/channels/adapters/slack-bot.ts +24 -0
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +41 -0
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +43 -2
- package/src/container/logs.ts +70 -22
- package/src/init/index.ts +3 -3
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
package/src/cli/inspect.ts
CHANGED
|
@@ -2,7 +2,19 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
listViewerItems,
|
|
7
|
+
openViewerItem,
|
|
8
|
+
parseDuration,
|
|
9
|
+
parseFilter,
|
|
10
|
+
resolveSession,
|
|
11
|
+
runInspectLoop,
|
|
12
|
+
runViewerLoop,
|
|
13
|
+
streamLive,
|
|
14
|
+
type LiveSourceFactory,
|
|
15
|
+
type SessionSummary,
|
|
16
|
+
type ViewerItem,
|
|
17
|
+
} from '@/inspect'
|
|
6
18
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
19
|
|
|
8
20
|
import { createTailScope } from './inspect-controller'
|
|
@@ -13,12 +25,12 @@ const ESC_DEBOUNCE_MS = 50
|
|
|
13
25
|
export const inspectCommand = defineCommand({
|
|
14
26
|
meta: {
|
|
15
27
|
name: 'inspect',
|
|
16
|
-
description: '
|
|
28
|
+
description: 'session viewer: pick a session, the live TUI, or container logs to observe (host stage)',
|
|
17
29
|
},
|
|
18
30
|
args: {
|
|
19
31
|
session: {
|
|
20
32
|
type: 'positional',
|
|
21
|
-
description: 'session id or short prefix (omit to pick from
|
|
33
|
+
description: 'session id or short prefix (omit to pick from the list)',
|
|
22
34
|
required: false,
|
|
23
35
|
},
|
|
24
36
|
filter: {
|
|
@@ -42,42 +54,175 @@ export const inspectCommand = defineCommand({
|
|
|
42
54
|
const sessionArg = typeof args.session === 'string' ? args.session : undefined
|
|
43
55
|
const filterArg = typeof args.filter === 'string' ? args.filter : undefined
|
|
44
56
|
const sinceArg = typeof args.since === 'string' ? args.since : undefined
|
|
45
|
-
|
|
46
57
|
const isJson = args.json === true
|
|
47
|
-
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const interactive = !isJson && Boolean(process.stdin.isTTY)
|
|
49
|
-
const liveHint = interactive ? escHintLine(color) : undefined
|
|
50
|
-
|
|
51
|
-
const result = await runInspectLoop({
|
|
52
|
-
agentDir: cwd,
|
|
53
|
-
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
54
|
-
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
55
|
-
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
|
-
json: isJson,
|
|
57
|
-
color,
|
|
58
|
-
selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
|
|
59
|
-
...(liveSource !== undefined ? { liveSource } : {}),
|
|
60
|
-
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
61
|
-
...(interactive ? { interactive: true } : {}),
|
|
62
|
-
...(liveHint !== undefined ? { liveHint } : {}),
|
|
63
|
-
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
64
|
-
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
65
|
-
})
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
// JSON mode stays the scriptable, session-only path: no list, no logs/tui
|
|
60
|
+
// rows, explicit session id required. Behavior is unchanged from before the
|
|
61
|
+
// viewer merge.
|
|
62
|
+
if (isJson) {
|
|
63
|
+
const result = await runInspectLoop({
|
|
64
|
+
agentDir: cwd,
|
|
65
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
66
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
67
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
68
|
+
json: true,
|
|
69
|
+
color,
|
|
70
|
+
selectSession: (sessions, selectOpts) => clackSelectSession(sessions, selectOpts?.initialSessionId),
|
|
71
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
72
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
73
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
74
|
+
})
|
|
75
|
+
finish(result)
|
|
76
|
+
return
|
|
70
77
|
}
|
|
71
|
-
|
|
78
|
+
|
|
79
|
+
const exitCode = await runInspectViewer({
|
|
80
|
+
cwd,
|
|
81
|
+
...(sessionArg !== undefined ? { sessionArg } : {}),
|
|
82
|
+
...(filterArg !== undefined ? { filterArg } : {}),
|
|
83
|
+
...(sinceArg !== undefined ? { sinceArg } : {}),
|
|
84
|
+
color,
|
|
85
|
+
})
|
|
86
|
+
process.exit(exitCode)
|
|
72
87
|
},
|
|
73
88
|
})
|
|
74
89
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
export type RunInspectViewerOptions = {
|
|
91
|
+
cwd: string
|
|
92
|
+
sessionArg?: string
|
|
93
|
+
filterArg?: string
|
|
94
|
+
sinceArg?: string
|
|
95
|
+
color?: boolean
|
|
96
|
+
// Set false by the `tui` detach handoff: the live session was just ended, so
|
|
97
|
+
// no row should be offered as writable (see listViewerItems).
|
|
98
|
+
allowWritable?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The interactive session-viewer: list → open → back to list. Shared by the
|
|
102
|
+
// `inspect` command and `tui`'s esc-detach fallthrough. Returns an exit code
|
|
103
|
+
// instead of calling process.exit so callers can chain (e.g. tui drops here).
|
|
104
|
+
export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<number> {
|
|
105
|
+
const { cwd } = opts
|
|
106
|
+
const color = opts.color ?? useColor()
|
|
107
|
+
|
|
108
|
+
const filterResult = parseFilter(opts.filterArg)
|
|
109
|
+
if (!filterResult.ok) {
|
|
110
|
+
process.stderr.write(`${errorLine(filterResult.reason)}\n`)
|
|
111
|
+
return 2
|
|
112
|
+
}
|
|
113
|
+
let sinceMs: number | undefined
|
|
114
|
+
if (opts.sinceArg !== undefined) {
|
|
115
|
+
const d = parseDuration(opts.sinceArg)
|
|
116
|
+
if (!d.ok) {
|
|
117
|
+
process.stderr.write(`${errorLine(d.reason)}\n`)
|
|
118
|
+
return 2
|
|
119
|
+
}
|
|
120
|
+
sinceMs = Date.now() - d.ms
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const containerRunning = (await requireContainerRunning({ cwd })).ok
|
|
124
|
+
if (!containerRunning) {
|
|
125
|
+
process.stderr.write(`${c.yellow('⚠')} container not running; showing read-only history and logs only\n`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sessionsDir = `${cwd}/sessions`
|
|
129
|
+
|
|
130
|
+
// Resolve a session arg (id or short prefix) to a full session id BEFORE the
|
|
131
|
+
// loop: runViewerLoop matches preselectKey against exact itemKeys, so a bare
|
|
132
|
+
// prefix would otherwise miss every row and report "no sessions". 'logs' is a
|
|
133
|
+
// reserved key, not a session, so it bypasses resolution.
|
|
134
|
+
let preselectKey: string | undefined
|
|
135
|
+
if (opts.sessionArg !== undefined && opts.sessionArg !== 'logs') {
|
|
136
|
+
const resolved = await resolveSession(sessionsDir, opts.sessionArg, (l) => process.stderr.write(`${l}\n`))
|
|
137
|
+
if (!resolved.ok) {
|
|
138
|
+
const reason =
|
|
139
|
+
resolved.reason === 'ambiguous'
|
|
140
|
+
? `Ambiguous session prefix "${opts.sessionArg}" matches ${resolved.matches.length} sessions. Use a longer prefix or run \`typeclaw inspect\` without args.`
|
|
141
|
+
: `No session matching "${opts.sessionArg}" in ${sessionsDir}/`
|
|
142
|
+
process.stderr.write(`${errorLine(reason)}\n`)
|
|
143
|
+
return resolved.reason === 'ambiguous' ? 2 : 1
|
|
144
|
+
}
|
|
145
|
+
preselectKey = resolved.summary.sessionId
|
|
146
|
+
} else if (opts.sessionArg === 'logs') {
|
|
147
|
+
preselectKey = 'logs'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const interactive = Boolean(process.stdin.isTTY)
|
|
151
|
+
const liveHint = interactive ? escHintLine(color) : undefined
|
|
152
|
+
const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
|
|
153
|
+
|
|
154
|
+
const stdout = (line: string): void => {
|
|
155
|
+
process.stdout.write(`${line}\n`)
|
|
156
|
+
}
|
|
157
|
+
const stderr = (line: string): void => {
|
|
158
|
+
process.stderr.write(`${line}\n`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const open = openViewerItem({
|
|
162
|
+
cwd,
|
|
163
|
+
filter: filterResult.filter,
|
|
164
|
+
sinceMs,
|
|
165
|
+
json: false,
|
|
166
|
+
color,
|
|
167
|
+
interactive,
|
|
168
|
+
stdout,
|
|
169
|
+
stderr,
|
|
170
|
+
resolveTuiUrl: () => resolveTuiUrl(cwd),
|
|
171
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
172
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const cliAllowWritable = opts.allowWritable !== false
|
|
176
|
+
const result = await runViewerLoop<ViewerItem>({
|
|
177
|
+
listItems: async ({ allowWritable: loopAllowWritable }) => {
|
|
178
|
+
const listOpts: Parameters<typeof listViewerItems>[0] = {
|
|
179
|
+
sessionsDir,
|
|
180
|
+
containerRunning,
|
|
181
|
+
// Compose the CLI-level permission (false on tui detach handoff) with
|
|
182
|
+
// the loop-level one (false after returning to the picker from a viewer).
|
|
183
|
+
allowWritable: cliAllowWritable && loopAllowWritable,
|
|
184
|
+
limit: 20,
|
|
185
|
+
onWarn: stderr,
|
|
186
|
+
}
|
|
187
|
+
if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
|
|
188
|
+
return (await listViewerItems(listOpts)).items
|
|
189
|
+
},
|
|
190
|
+
keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
|
|
191
|
+
...(preselectKey !== undefined ? { preselectKey } : {}),
|
|
192
|
+
selectItem: (items, selectOpts) => clackSelectItem(items, selectOpts.initialKey),
|
|
193
|
+
openItem: open,
|
|
194
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
195
|
+
onEmpty: () => ({
|
|
196
|
+
ok: false,
|
|
197
|
+
exitCode: 1,
|
|
198
|
+
reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
|
|
199
|
+
}),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
if (!result.ok && result.reason !== undefined) {
|
|
203
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
204
|
+
}
|
|
205
|
+
return result.exitCode
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function finish(result: { ok: boolean; exitCode: number; reason?: string }): void {
|
|
209
|
+
if (!result.ok && result.reason !== undefined) {
|
|
210
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
80
211
|
}
|
|
212
|
+
process.exit(result.exitCode)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function resolveTuiUrl(cwd: string): Promise<string> {
|
|
216
|
+
const precheck = await requireContainerRunning({ cwd })
|
|
217
|
+
if (!precheck.ok) throw new Error(precheck.reason)
|
|
218
|
+
const port = await resolveHostPort({ cwd })
|
|
219
|
+
const token = await resolveTuiToken({ cwd })
|
|
220
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
221
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
222
|
+
return url.toString()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
|
|
81
226
|
const port = await resolveHostPort({ cwd })
|
|
82
227
|
const token = await resolveTuiToken({ cwd })
|
|
83
228
|
const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
|
|
@@ -94,7 +239,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
94
239
|
}
|
|
95
240
|
|
|
96
241
|
function escHintLine(color: boolean): string {
|
|
97
|
-
const text = '(esc to return to
|
|
242
|
+
const text = '(esc to return to the list · q to quit)'
|
|
98
243
|
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
99
244
|
}
|
|
100
245
|
|
|
@@ -105,7 +250,29 @@ function useColor(): boolean {
|
|
|
105
250
|
return Boolean(process.stdout.isTTY)
|
|
106
251
|
}
|
|
107
252
|
|
|
108
|
-
async function
|
|
253
|
+
async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
|
|
254
|
+
const { select } = await import('@clack/prompts')
|
|
255
|
+
prepareStdinForClack()
|
|
256
|
+
const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
|
|
257
|
+
const preferred =
|
|
258
|
+
initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
|
|
259
|
+
const picked = await select<string>({
|
|
260
|
+
message: `Pick what to view (showing ${items.length})`,
|
|
261
|
+
options: items.map((item) => ({
|
|
262
|
+
value: keyOf(item),
|
|
263
|
+
label: itemLabel(item),
|
|
264
|
+
...itemHint(item),
|
|
265
|
+
})),
|
|
266
|
+
initialValue: preferred,
|
|
267
|
+
})
|
|
268
|
+
if (isCancel(picked)) {
|
|
269
|
+
cancel('Cancelled.')
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
return items.find((i) => keyOf(i) === picked) ?? null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function clackSelectSession(
|
|
109
276
|
sessions: SessionSummary[],
|
|
110
277
|
initialSessionId: string | undefined,
|
|
111
278
|
): Promise<SessionSummary | null> {
|
|
@@ -119,7 +286,7 @@ async function clackSelect(
|
|
|
119
286
|
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
120
287
|
options: sessions.map((s) => ({
|
|
121
288
|
value: s.sessionId,
|
|
122
|
-
label:
|
|
289
|
+
label: sessionRowLabel(s),
|
|
123
290
|
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
124
291
|
})),
|
|
125
292
|
initialValue: preferred,
|
|
@@ -131,7 +298,20 @@ async function clackSelect(
|
|
|
131
298
|
return sessions.find((s) => s.sessionId === picked) ?? null
|
|
132
299
|
}
|
|
133
300
|
|
|
134
|
-
function
|
|
301
|
+
function itemLabel(item: ViewerItem): string {
|
|
302
|
+
if (item.kind === 'logs') return `${c.dim('▤')} container logs`
|
|
303
|
+
if (item.kind === 'tui') return `${c.green('●')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
|
|
304
|
+
return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function itemHint(item: ViewerItem): { hint: string } {
|
|
308
|
+
if (item.kind === 'logs') return { hint: 'read-only · works offline' }
|
|
309
|
+
if (item.kind === 'tui') return { hint: 'read+write · esc detaches and ends the live session' }
|
|
310
|
+
if (item.summary.firstPrompt !== null) return { hint: truncate(item.summary.firstPrompt, 60) }
|
|
311
|
+
return { hint: '(no prompt)' }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sessionRowLabel(s: SessionSummary): string {
|
|
135
315
|
const id = shortSessionId(s.sessionId)
|
|
136
316
|
const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
|
|
137
317
|
const when = formatRelative(s.mtimeMs)
|
package/src/cli/logs.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
|
|
|
3
3
|
import { logs, parseTailValue } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
|
|
6
|
+
import { runInspectViewer } from './inspect'
|
|
6
7
|
import { c, errorLine } from './ui'
|
|
7
8
|
|
|
8
9
|
export const logsCommand = defineCommand({
|
|
@@ -22,6 +23,11 @@ export const logsCommand = defineCommand({
|
|
|
22
23
|
alias: 'n',
|
|
23
24
|
description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
|
|
24
25
|
},
|
|
26
|
+
list: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description: 'open the session viewer on the logs entry instead of dumping logs',
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
25
31
|
},
|
|
26
32
|
async run({ args }) {
|
|
27
33
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
@@ -36,6 +42,15 @@ export const logsCommand = defineCommand({
|
|
|
36
42
|
tail = parsed.value
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// The viewer is strictly opt-in via --list, so the default `typeclaw logs`
|
|
46
|
+
// (piped, redirected, -f, or a plain TTY dump) keeps the raw `docker logs`
|
|
47
|
+
// pump that `typeclaw logs | grep` and CI depend on. --list drops into the
|
|
48
|
+
// session viewer pre-opened on the logs entry, where esc returns to the list.
|
|
49
|
+
if (args.list) {
|
|
50
|
+
const exitCode = await runInspectViewer({ cwd, sessionArg: 'logs' })
|
|
51
|
+
process.exit(exitCode)
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
if (args.follow) {
|
|
40
55
|
console.log(c.cyan('Streaming container logs...'))
|
|
41
56
|
} else {
|
package/src/cli/tui.ts
CHANGED
|
@@ -3,14 +3,16 @@ import { defineCommand } from 'citty'
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
import { CLI_VERSION } from '@/init/cli-version'
|
|
6
|
-
import {
|
|
6
|
+
import { runTuiViewer } from '@/inspect'
|
|
7
|
+
import { formatVersionMismatchWarning } from '@/tui'
|
|
7
8
|
|
|
9
|
+
import { runInspectViewer } from './inspect'
|
|
8
10
|
import { errorLine } from './ui'
|
|
9
11
|
|
|
10
12
|
export const tui = defineCommand({
|
|
11
13
|
meta: {
|
|
12
14
|
name: 'tui',
|
|
13
|
-
description: '
|
|
15
|
+
description: 'open the live agent session in the read+write viewer (host stage)',
|
|
14
16
|
},
|
|
15
17
|
args: {
|
|
16
18
|
prompt: {
|
|
@@ -25,50 +27,42 @@ export const tui = defineCommand({
|
|
|
25
27
|
},
|
|
26
28
|
},
|
|
27
29
|
async run({ args }) {
|
|
28
|
-
const
|
|
30
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
31
|
+
const resolveUrl: () => Promise<string> =
|
|
32
|
+
args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const result = await runTuiViewer({
|
|
35
|
+
resolveUrl,
|
|
36
|
+
...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
|
|
37
|
+
expectedVersion: CLI_VERSION,
|
|
38
|
+
onVersionMismatch: (info) => {
|
|
39
|
+
process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
|
|
40
|
+
},
|
|
41
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
42
|
+
})
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
const outcome = await tui.run()
|
|
46
|
-
if (!outcome.lostConnection) return
|
|
47
|
-
// The TUI lost its WS post-handshake (container restart, network blip,
|
|
48
|
-
// hostd hiccup). Re-resolve the URL because the host port can change
|
|
49
|
-
// across container lifecycles (see resolveHostPort), then reconnect.
|
|
50
|
-
// The initial prompt is intentionally cleared after the first cycle:
|
|
51
|
-
// on a reconnect, the agent is resuming the same session — replaying
|
|
52
|
-
// the prompt would re-send it to the LLM.
|
|
53
|
-
initialPrompt = undefined
|
|
54
|
-
attempt += 1
|
|
55
|
-
if (attempt > RECONNECT_MAX_ATTEMPTS) {
|
|
56
|
-
console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
|
|
57
|
-
process.exit(1)
|
|
58
|
-
}
|
|
59
|
-
process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
|
|
60
|
-
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
|
|
44
|
+
// Esc detached from the live session: drop into the viewer list so the user
|
|
45
|
+
// can pick another session or the container logs — `tui` is just a deep-link
|
|
46
|
+
// into the session viewer, pre-opened on the live session. allowWritable
|
|
47
|
+
// is false because detaching ended the live session, so no row may be
|
|
48
|
+
// offered as a writable "live TUI" anymore.
|
|
49
|
+
if (result.ok && result.escToPicker === true) {
|
|
50
|
+
const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
|
|
51
|
+
process.exit(viewerExit)
|
|
52
|
+
return
|
|
61
53
|
}
|
|
54
|
+
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
57
|
+
process.exit(result.exitCode)
|
|
58
|
+
}
|
|
59
|
+
process.exit(result.exitCode)
|
|
62
60
|
},
|
|
63
61
|
})
|
|
64
62
|
|
|
65
|
-
async function defaultUrl(): Promise<string> {
|
|
66
|
-
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
63
|
+
async function defaultUrl(cwd: string): Promise<string> {
|
|
67
64
|
const precheck = await requireContainerRunning({ cwd })
|
|
68
|
-
if (!precheck.ok)
|
|
69
|
-
console.error(errorLine(precheck.reason))
|
|
70
|
-
process.exit(1)
|
|
71
|
-
}
|
|
65
|
+
if (!precheck.ok) throw new Error(precheck.reason)
|
|
72
66
|
const port = await resolveHostPort({ cwd })
|
|
73
67
|
const token = await resolveTuiToken({ cwd })
|
|
74
68
|
const url = new URL(`ws://127.0.0.1:${port}`)
|
package/src/compose/logs.ts
CHANGED
|
@@ -100,7 +100,7 @@ export async function composeLogs({
|
|
|
100
100
|
follow,
|
|
101
101
|
...(tail !== undefined ? { tail } : {}),
|
|
102
102
|
})
|
|
103
|
-
const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
|
|
103
|
+
const proc = bun.spawn({ cmd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
104
104
|
return { agent, proc }
|
|
105
105
|
})
|
|
106
106
|
|
package/src/config/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isAbsolute, join, resolve } from 'node:path'
|
|
|
5
5
|
import type { Model } from '@mariozechner/pi-ai'
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
|
|
8
|
-
import { channelsSchema } from '@/channels/schema'
|
|
8
|
+
import { channelsSchema, SEEDED_GITHUB_EVENT_ALLOWLISTS } from '@/channels/schema'
|
|
9
9
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
10
10
|
import { rolesConfigSchema } from '@/permissions/schema'
|
|
11
11
|
import { secretFieldSchema } from '@/secrets/resolve'
|
|
@@ -810,6 +810,7 @@ export type MigrationStep =
|
|
|
810
810
|
| { kind: 'strip-permissions-gate-channel-respond' }
|
|
811
811
|
| { kind: 'model-to-models'; ref: string }
|
|
812
812
|
| { kind: 'drop-stale-model'; ref: string }
|
|
813
|
+
| { kind: 'drop-github-seeded-event-allowlist' }
|
|
813
814
|
|
|
814
815
|
export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
|
|
815
816
|
|
|
@@ -830,13 +831,15 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
|
830
831
|
// silently — same precedence rule as the dockerfile/gitignore migrations.
|
|
831
832
|
const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
|
|
832
833
|
const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
|
|
834
|
+
const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
|
|
833
835
|
if (
|
|
834
836
|
!hasLegacyDockerfile &&
|
|
835
837
|
!hasLegacyGitignore &&
|
|
836
838
|
!channelsAllowMigration.found &&
|
|
837
839
|
!hasLegacyGateChannelRespond &&
|
|
838
840
|
!hasLegacyModel &&
|
|
839
|
-
!hasStaleModelAlongsideModels
|
|
841
|
+
!hasStaleModelAlongsideModels &&
|
|
842
|
+
!hasSeededGithubEventAllowlist
|
|
840
843
|
) {
|
|
841
844
|
return { json, changed: false, applied: [] }
|
|
842
845
|
}
|
|
@@ -897,9 +900,43 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
|
897
900
|
delete next.model
|
|
898
901
|
applied.push({ kind: 'drop-stale-model', ref })
|
|
899
902
|
}
|
|
903
|
+
if (hasSeededGithubEventAllowlist) {
|
|
904
|
+
dropSeededGithubEventAllowlist(next)
|
|
905
|
+
applied.push({ kind: 'drop-github-seeded-event-allowlist' })
|
|
906
|
+
}
|
|
900
907
|
return { json: next, changed: true, applied }
|
|
901
908
|
}
|
|
902
909
|
|
|
910
|
+
// True when channels.github.eventAllowlist deep-equals an allowlist that
|
|
911
|
+
// `channel add` / `init` has previously seeded verbatim. Such a value is
|
|
912
|
+
// indistinguishable from "the default at that time", so stripping it lets the
|
|
913
|
+
// config re-track the shipped default. A user who hand-edited to any other set
|
|
914
|
+
// (added/removed/reordered an event) fails this check and is preserved.
|
|
915
|
+
function isSeededGithubEventAllowlist(obj: Record<string, unknown>): boolean {
|
|
916
|
+
const github = isPlainObject(obj.channels) ? obj.channels.github : undefined
|
|
917
|
+
if (!isPlainObject(github)) return false
|
|
918
|
+
const list = github.eventAllowlist
|
|
919
|
+
if (!Array.isArray(list)) return false
|
|
920
|
+
return SEEDED_GITHUB_EVENT_ALLOWLISTS.some((seeded) => arraysEqual(list, seeded))
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function dropSeededGithubEventAllowlist(next: Record<string, unknown>): void {
|
|
924
|
+
const channels = next.channels
|
|
925
|
+
if (!isPlainObject(channels)) return
|
|
926
|
+
const github = channels.github
|
|
927
|
+
if (!isPlainObject(github)) return
|
|
928
|
+
const { eventAllowlist: _dropped, ...rest } = github
|
|
929
|
+
next.channels = { ...channels, github: rest }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
933
|
+
if (a.length !== b.length) return false
|
|
934
|
+
for (let i = 0; i < a.length; i++) {
|
|
935
|
+
if (a[i] !== b[i]) return false
|
|
936
|
+
}
|
|
937
|
+
return true
|
|
938
|
+
}
|
|
939
|
+
|
|
903
940
|
// Builds a meaningful one-line git commit subject for a typeclaw.json
|
|
904
941
|
// migration. Single-step migrations get a specific subject; multi-step ones
|
|
905
942
|
// fall back to a stable summary subject with the count. The body (after the
|
|
@@ -949,6 +986,8 @@ function shortStepLabel(step: MigrationStep): string {
|
|
|
949
986
|
return 'lift model → models.default'
|
|
950
987
|
case 'drop-stale-model':
|
|
951
988
|
return 'drop stale legacy model alongside models'
|
|
989
|
+
case 'drop-github-seeded-event-allowlist':
|
|
990
|
+
return 'drop seeded channels.github.eventAllowlist'
|
|
952
991
|
}
|
|
953
992
|
}
|
|
954
993
|
|
|
@@ -972,6 +1011,8 @@ function describeStep(step: MigrationStep): string {
|
|
|
972
1011
|
return step.ref !== ''
|
|
973
1012
|
? `drop stale top-level model (${step.ref}) — models block takes precedence`
|
|
974
1013
|
: 'drop stale top-level model — models block takes precedence'
|
|
1014
|
+
case 'drop-github-seeded-event-allowlist':
|
|
1015
|
+
return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
|
|
975
1016
|
}
|
|
976
1017
|
}
|
|
977
1018
|
|