typeclaw 0.26.0 → 0.28.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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/session-origin.ts +9 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- 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 +19 -288
- package/src/container/logs.ts +70 -22
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- 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/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/src/cron/index.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readFile
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import type { SubagentRegistry } from '@/agent/subagents'
|
|
6
|
-
import { commitSystemFile } from '@/git/system-commit'
|
|
7
6
|
|
|
8
|
-
import {
|
|
9
|
-
buildCronMigrationCommitMessage,
|
|
10
|
-
type CronFile,
|
|
11
|
-
type CronMigrationStep,
|
|
12
|
-
migrateLegacyCronShape,
|
|
13
|
-
parseCronFile,
|
|
14
|
-
} from './schema'
|
|
7
|
+
import { type CronFile, parseCronFile } from './schema'
|
|
15
8
|
|
|
16
9
|
export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
|
|
17
10
|
export {
|
|
@@ -31,16 +24,12 @@ export {
|
|
|
31
24
|
} from './scheduler'
|
|
32
25
|
export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
|
|
33
26
|
export {
|
|
34
|
-
buildCronMigrationCommitMessage,
|
|
35
27
|
cronFileSchema,
|
|
36
28
|
cronJobSchema,
|
|
37
29
|
type CronFile,
|
|
38
30
|
type CronJob,
|
|
39
|
-
type CronMigrationResult,
|
|
40
|
-
type CronMigrationStep,
|
|
41
31
|
type ExecJob,
|
|
42
32
|
type HandlerJob,
|
|
43
|
-
migrateLegacyCronShape,
|
|
44
33
|
parseCronJson,
|
|
45
34
|
type ParseCronJsonOptions,
|
|
46
35
|
type ParseCronResult,
|
|
@@ -54,12 +43,6 @@ export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false;
|
|
|
54
43
|
|
|
55
44
|
export type LoadCronOptions = {
|
|
56
45
|
subagents?: SubagentRegistry
|
|
57
|
-
// When true (the default), legacy-shape migrations are written back
|
|
58
|
-
// to cron.json on disk and committed by the system-commit helper.
|
|
59
|
-
// Read-only inspection callers must pass `false` so an unaware
|
|
60
|
-
// `typeclaw cron list` against a legacy file does not produce a
|
|
61
|
-
// commit on whatever branch the user happens to be on.
|
|
62
|
-
persistMigrations?: boolean
|
|
63
46
|
}
|
|
64
47
|
|
|
65
48
|
export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
|
|
@@ -80,36 +63,12 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
|
|
|
80
63
|
return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
|
|
81
64
|
}
|
|
82
65
|
|
|
83
|
-
const
|
|
84
|
-
const persistMigrations = options.persistMigrations ?? true
|
|
85
|
-
if (migrated.changed && persistMigrations) {
|
|
86
|
-
await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
66
|
+
const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
90
67
|
if (!result.ok) return { ok: false, reason: result.reason }
|
|
91
68
|
|
|
92
69
|
return { ok: true, file: result.file }
|
|
93
70
|
}
|
|
94
71
|
|
|
95
|
-
async function persistMigratedCron(
|
|
96
|
-
path: string,
|
|
97
|
-
json: unknown,
|
|
98
|
-
agentDir: string,
|
|
99
|
-
applied: readonly CronMigrationStep[],
|
|
100
|
-
): Promise<void> {
|
|
101
|
-
try {
|
|
102
|
-
await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
|
|
103
|
-
} catch {
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const message = buildCronMigrationCommitMessage(applied)
|
|
108
|
-
if (message !== null) {
|
|
109
|
-
await commitSystemFile(agentDir, CRON_FILE, message)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
72
|
function errorMessage(err: unknown): string {
|
|
114
73
|
return err instanceof Error ? err.message : String(err)
|
|
115
74
|
}
|
package/src/cron/schema.ts
CHANGED
|
@@ -64,99 +64,7 @@ export type ParseCronOptions = {
|
|
|
64
64
|
subagents?: SubagentRegistry
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
// `scheduledByRole` became mandatory on every job. The schema gate
|
|
69
|
-
// (`parseCronFile`) rejects legacy entries with a precise remediation
|
|
70
|
-
// message, but rejecting on every container boot is a stuck state for
|
|
71
|
-
// the user — the agent crashes in a tight restart loop with no path
|
|
72
|
-
// forward except hand-editing cron.json.
|
|
73
|
-
//
|
|
74
|
-
// The migration stamps `scheduledByRole: 'owner'` on every job that's
|
|
75
|
-
// missing it. `owner` is the right default for two reasons:
|
|
76
|
-
// 1. Before #171 there was no role concept; every cron job ran with
|
|
77
|
-
// the same (effectively-owner) privileges the agent had.
|
|
78
|
-
// 2. The schema gate's own error message tells users to add
|
|
79
|
-
// `"scheduledByRole": "owner"` for manually-authored entries —
|
|
80
|
-
// we just do it for them.
|
|
81
|
-
//
|
|
82
|
-
// Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
|
|
83
|
-
// function, returns the rewritten JSON plus an `applied` array so
|
|
84
|
-
// callers can build a meaningful commit message. Returns `changed:
|
|
85
|
-
// false` on canonical input so the persist + commit path stays
|
|
86
|
-
// untouched on the happy path.
|
|
87
|
-
export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
|
|
88
|
-
|
|
89
|
-
export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
|
|
90
|
-
|
|
91
|
-
export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
|
|
92
|
-
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
93
|
-
return { json, changed: false, applied: [] }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const obj = json as Record<string, unknown>
|
|
97
|
-
const jobs = obj.jobs
|
|
98
|
-
if (!Array.isArray(jobs)) {
|
|
99
|
-
return { json, changed: false, applied: [] }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const stampedIds: string[] = []
|
|
103
|
-
const nextJobs = jobs.map((job) => {
|
|
104
|
-
if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
|
|
105
|
-
const record = job as Record<string, unknown>
|
|
106
|
-
if ('scheduledByRole' in record) return job
|
|
107
|
-
const id = typeof record.id === 'string' ? record.id : '<unknown>'
|
|
108
|
-
stampedIds.push(id)
|
|
109
|
-
return { ...record, scheduledByRole: 'owner' }
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
if (stampedIds.length === 0) {
|
|
113
|
-
return { json, changed: false, applied: [] }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
json: { ...obj, jobs: nextJobs },
|
|
118
|
-
changed: true,
|
|
119
|
-
applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Builds a one-line git commit subject (plus enumerating body) for a
|
|
124
|
-
// cron.json migration. Returns null when no steps were applied — callers
|
|
125
|
-
// should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
|
|
126
|
-
// in src/config/config.ts.
|
|
127
|
-
export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
|
|
128
|
-
const first = applied[0]
|
|
129
|
-
if (first === undefined) return null
|
|
130
|
-
|
|
131
|
-
const subject =
|
|
132
|
-
applied.length === 1
|
|
133
|
-
? `cron.json: ${shortCronStepLabel(first)}`
|
|
134
|
-
: `cron.json: migrate legacy shape (${applied.length} steps)`
|
|
135
|
-
|
|
136
|
-
const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
|
|
137
|
-
return `${subject}\n\n${bodyLines.join('\n')}\n`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function shortCronStepLabel(step: CronMigrationStep): string {
|
|
141
|
-
switch (step.kind) {
|
|
142
|
-
case 'stamp-scheduled-by-role-owner':
|
|
143
|
-
return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function describeCronStep(step: CronMigrationStep): string {
|
|
148
|
-
switch (step.kind) {
|
|
149
|
-
case 'stamp-scheduled-by-role-owner':
|
|
150
|
-
return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export type ParseCronJsonOptions = ParseCronOptions & {
|
|
155
|
-
// Apply `migrateLegacyCronShape` before schema validation. Defaults to true
|
|
156
|
-
// so the guard accepts the same legacy shapes `loadCron` would auto-migrate
|
|
157
|
-
// on disk; pass false to validate the exact bytes (used in tests).
|
|
158
|
-
migrate?: boolean
|
|
159
|
-
}
|
|
67
|
+
export type ParseCronJsonOptions = ParseCronOptions
|
|
160
68
|
|
|
161
69
|
export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
|
|
162
70
|
let json: unknown
|
|
@@ -166,9 +74,7 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
|
|
|
166
74
|
return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
|
|
167
75
|
}
|
|
168
76
|
|
|
169
|
-
|
|
170
|
-
const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
|
|
171
|
-
return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
77
|
+
return parseCronFile(json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
172
78
|
}
|
|
173
79
|
|
|
174
80
|
export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
|
package/src/init/gitignore.ts
CHANGED
|
@@ -38,8 +38,7 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
|
|
|
38
38
|
#
|
|
39
39
|
# auth.json is the pre-rename name for secrets.json; kept here permanently
|
|
40
40
|
# as a safety net so an agent folder cloned from a pre-rename machine never
|
|
41
|
-
# stages credentials by accident
|
|
42
|
-
# auth.json -> secrets.json migration.
|
|
41
|
+
# stages credentials by accident.
|
|
43
42
|
#
|
|
44
43
|
# .typeclaw/home/ is the persistent-$HOME overlay populated by the
|
|
45
44
|
# entrypoint shim's \`link_persistent_home_files\` (see
|
package/src/inspect/index.ts
CHANGED
|
@@ -16,8 +16,26 @@ export { replayJsonl } from './replay'
|
|
|
16
16
|
export { streamLive } from './live'
|
|
17
17
|
export { parseDuration, parseFilter } from './types'
|
|
18
18
|
export type { InspectCategory, InspectEvent, InspectFilter } from './types'
|
|
19
|
-
export { runInspectLoop } from './loop'
|
|
20
|
-
export type {
|
|
19
|
+
export { runInspectLoop, runViewerLoop } from './loop'
|
|
20
|
+
export type {
|
|
21
|
+
OpenItem,
|
|
22
|
+
OpenItemContext,
|
|
23
|
+
RunInspectLoopOptions,
|
|
24
|
+
RunViewerLoopOptions,
|
|
25
|
+
SelectItem,
|
|
26
|
+
TailController,
|
|
27
|
+
} from './loop'
|
|
28
|
+
export type { ViewerItem } from './item'
|
|
29
|
+
export { isWritable, itemKey } from './item'
|
|
30
|
+
export { listViewerItems } from './item-list'
|
|
31
|
+
export type { ListViewerItemsOptions, ViewerList } from './item-list'
|
|
32
|
+
export { openViewerItem } from './open-item'
|
|
33
|
+
export type { OpenViewerDeps } from './open-item'
|
|
34
|
+
export { runTuiViewer } from './tui-item'
|
|
35
|
+
export type { RunTuiViewerOptions } from './tui-item'
|
|
36
|
+
export { streamLogs } from './logs-item'
|
|
37
|
+
export { createTranscriptView } from './transcript-view'
|
|
38
|
+
export type { TranscriptViewOptions, TranscriptViewOutcome } from './transcript-view'
|
|
21
39
|
|
|
22
40
|
export type RunInspectOptions = {
|
|
23
41
|
agentDir: string
|
|
@@ -51,7 +69,7 @@ export type LiveSourceFactory = (opts: {
|
|
|
51
69
|
}) => AsyncIterable<InspectEvent>
|
|
52
70
|
|
|
53
71
|
export type RunInspectResult =
|
|
54
|
-
| { ok: true; exitCode:
|
|
72
|
+
| { ok: true; exitCode: number; escToPicker?: boolean }
|
|
55
73
|
| { ok: false; exitCode: number; reason: string }
|
|
56
74
|
|
|
57
75
|
export type InspectTarget = {
|
|
@@ -164,51 +182,58 @@ async function chooseSession(
|
|
|
164
182
|
return { ok: true, summary: picked }
|
|
165
183
|
}
|
|
166
184
|
|
|
167
|
-
|
|
185
|
+
// Lifecycle phases surfaced to a consumer so renderers can react (e.g. batch a
|
|
186
|
+
// pi-tui render at replay-end, announce a live divider). `sessionLive` on
|
|
187
|
+
// 'live-start' is the registry hit/miss from the live source's onSubscribed.
|
|
188
|
+
export type StreamPhase =
|
|
189
|
+
| { phase: 'replay-end' }
|
|
190
|
+
| { phase: 'replay-only-idle' }
|
|
191
|
+
| { phase: 'live-start'; sessionLive: boolean }
|
|
192
|
+
| { phase: 'end' }
|
|
193
|
+
|
|
194
|
+
export type StreamSessionEventsOptions = {
|
|
168
195
|
summary: SessionSummary
|
|
169
196
|
filter: InspectFilter
|
|
170
197
|
sinceMs: number | undefined
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
stderr: (line: string) => void
|
|
198
|
+
onEvent: (event: InspectEvent) => void
|
|
199
|
+
onPhase?: (phase: StreamPhase) => void
|
|
200
|
+
onWarn?: (msg: string) => void
|
|
175
201
|
liveSource?: LiveSourceFactory
|
|
176
202
|
signal?: AbortSignal
|
|
177
|
-
|
|
178
|
-
interactive
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
203
|
+
// When true and replay-only with a signal, block until aborted instead of
|
|
204
|
+
// returning immediately — a stable interactive viewer that esc/q dismisses.
|
|
205
|
+
blockWhenReplayOnly?: boolean
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The read path shared by the line renderer and the pi-tui transcript view:
|
|
209
|
+
// replay the JSONL transcript, then optionally live-tail, applying since/filter
|
|
210
|
+
// and honoring signal.aborted (-> escToPicker). Knows nothing about rendering —
|
|
211
|
+
// it just delivers ordered, filtered InspectEvents to onEvent and announces
|
|
212
|
+
// phase transitions via onPhase.
|
|
213
|
+
export async function streamSessionEvents(opts: StreamSessionEventsOptions): Promise<{ escToPicker: boolean }> {
|
|
214
|
+
const aborted = (): boolean => opts.signal?.aborted === true
|
|
215
|
+
const deliver = (event: InspectEvent): void => {
|
|
182
216
|
if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
|
|
183
217
|
if (!matchesFilter(event, opts.filter)) return
|
|
184
|
-
|
|
185
|
-
opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
|
|
186
|
-
} else {
|
|
187
|
-
opts.stdout(renderEvent(event, { color: opts.color }))
|
|
188
|
-
}
|
|
218
|
+
opts.onEvent(event)
|
|
189
219
|
}
|
|
190
220
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
221
|
+
for await (const event of replayJsonl(
|
|
222
|
+
opts.summary.sessionFile,
|
|
223
|
+
opts.onWarn !== undefined ? { onWarn: opts.onWarn } : {},
|
|
224
|
+
)) {
|
|
194
225
|
if (aborted()) return { escToPicker: true }
|
|
195
|
-
|
|
226
|
+
deliver(event)
|
|
196
227
|
}
|
|
228
|
+
opts.onPhase?.({ phase: 'replay-end' })
|
|
197
229
|
|
|
198
230
|
if (opts.liveSource === undefined) {
|
|
199
|
-
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
200
|
-
// Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
|
|
201
231
|
if (aborted()) return { escToPicker: true }
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
|
|
205
|
-
// no listener and would hang) or in json/non-interactive mode (scriptability).
|
|
206
|
-
if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
|
|
207
|
-
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
208
|
-
opts.stdout(divider(opts.color, opts.liveHint))
|
|
209
|
-
}
|
|
232
|
+
if (opts.blockWhenReplayOnly === true && opts.signal !== undefined) {
|
|
233
|
+
opts.onPhase?.({ phase: 'replay-only-idle' })
|
|
210
234
|
await waitForAbort(opts.signal)
|
|
211
235
|
}
|
|
236
|
+
opts.onPhase?.({ phase: 'end' })
|
|
212
237
|
return { escToPicker: aborted() }
|
|
213
238
|
}
|
|
214
239
|
|
|
@@ -227,24 +252,85 @@ async function streamSession(opts: {
|
|
|
227
252
|
let liveAnnounced = false
|
|
228
253
|
try {
|
|
229
254
|
for await (const event of liveIter) {
|
|
230
|
-
if (!liveAnnounced
|
|
231
|
-
opts.
|
|
232
|
-
divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
|
|
233
|
-
)
|
|
234
|
-
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
235
|
-
opts.stdout(divider(opts.color, opts.liveHint))
|
|
236
|
-
}
|
|
255
|
+
if (!liveAnnounced) {
|
|
256
|
+
opts.onPhase?.({ phase: 'live-start', sessionLive })
|
|
237
257
|
liveAnnounced = true
|
|
238
258
|
}
|
|
239
|
-
|
|
259
|
+
deliver(event)
|
|
240
260
|
}
|
|
241
261
|
} catch (err) {
|
|
242
|
-
opts.
|
|
262
|
+
opts.onWarn?.(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
|
|
243
263
|
}
|
|
244
|
-
|
|
264
|
+
opts.onPhase?.({ phase: 'end' })
|
|
245
265
|
return { escToPicker: aborted() }
|
|
246
266
|
}
|
|
247
267
|
|
|
268
|
+
// Line/JSON renderer: the original streamSession behavior, now expressed as a
|
|
269
|
+
// streamSessionEvents consumer. Preserves the exact header/divider output and
|
|
270
|
+
// scriptable stdout/JSON contract.
|
|
271
|
+
async function streamSession(opts: {
|
|
272
|
+
summary: SessionSummary
|
|
273
|
+
filter: InspectFilter
|
|
274
|
+
sinceMs: number | undefined
|
|
275
|
+
json: boolean
|
|
276
|
+
color: boolean
|
|
277
|
+
stdout: (line: string) => void
|
|
278
|
+
stderr: (line: string) => void
|
|
279
|
+
liveSource?: LiveSourceFactory
|
|
280
|
+
signal?: AbortSignal
|
|
281
|
+
liveHint?: string
|
|
282
|
+
interactive?: boolean
|
|
283
|
+
}): Promise<{ escToPicker: boolean }> {
|
|
284
|
+
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
285
|
+
|
|
286
|
+
const onEvent = (event: InspectEvent): void => {
|
|
287
|
+
if (opts.json) opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
|
|
288
|
+
else opts.stdout(renderEvent(event, { color: opts.color }))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const emitHint = (): void => {
|
|
292
|
+
if (!opts.json && opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
293
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Replay-only prints the end-of-transcript footer once at the idle point (the
|
|
298
|
+
// viewer then blocks); the terminal 'end' phase must not print it a second
|
|
299
|
+
// time. Live mode skips the idle phase and prints the footer only at 'end'.
|
|
300
|
+
let footerPrinted = false
|
|
301
|
+
const printFooter = (): void => {
|
|
302
|
+
opts.stdout('─── end of transcript ───')
|
|
303
|
+
footerPrinted = true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const onPhase = (p: StreamPhase): void => {
|
|
307
|
+
if (opts.json) return
|
|
308
|
+
if (p.phase === 'replay-only-idle') {
|
|
309
|
+
printFooter()
|
|
310
|
+
emitHint()
|
|
311
|
+
} else if (p.phase === 'live-start') {
|
|
312
|
+
opts.stdout(
|
|
313
|
+
divider(opts.color, p.sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
|
|
314
|
+
)
|
|
315
|
+
emitHint()
|
|
316
|
+
} else if (p.phase === 'end' && !footerPrinted) {
|
|
317
|
+
printFooter()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return streamSessionEvents({
|
|
322
|
+
summary: opts.summary,
|
|
323
|
+
filter: opts.filter,
|
|
324
|
+
sinceMs: opts.sinceMs,
|
|
325
|
+
onEvent,
|
|
326
|
+
onPhase,
|
|
327
|
+
onWarn: opts.stderr,
|
|
328
|
+
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
329
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
330
|
+
blockWhenReplayOnly: opts.interactive === true && !opts.json,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
248
334
|
function divider(color: boolean, text: string): string {
|
|
249
335
|
if (color) return `\u001b[2m${text}\u001b[0m`
|
|
250
336
|
return text
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ViewerItem } from './item'
|
|
2
|
+
import { listSessions, type ListSessionsOptions, type SessionSummary } from './session-list'
|
|
3
|
+
|
|
4
|
+
export type ListViewerItemsOptions = ListSessionsOptions & {
|
|
5
|
+
containerRunning: boolean
|
|
6
|
+
includeLogs?: boolean
|
|
7
|
+
// Defaults to true. The detach-to-list path (after `typeclaw tui` esc) sets
|
|
8
|
+
// this false: detaching ENDS the server-side session, so the just-killed
|
|
9
|
+
// (most-recent) tui transcript — and any older tui transcript the heuristic
|
|
10
|
+
// would otherwise promote — must NOT be offered as a writable live row.
|
|
11
|
+
allowWritable?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ViewerList = {
|
|
15
|
+
items: ViewerItem[]
|
|
16
|
+
writableSessionId: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Builds the session-viewer list. The writable TUI row is a heuristic, not an
|
|
20
|
+
// authoritative query: when the container is up, the single most-recent
|
|
21
|
+
// tui-origin session becomes the read+write `tui` item; every other session is
|
|
22
|
+
// read-only. With the container down there is no live session to drive, so all
|
|
23
|
+
// sessions are read-only. The `logs` row is appended last (container stdout,
|
|
24
|
+
// available offline) so it sits below the divider in the picker.
|
|
25
|
+
export async function listViewerItems(opts: ListViewerItemsOptions): Promise<ViewerList> {
|
|
26
|
+
const sessions = await listSessions(opts)
|
|
27
|
+
const allowWritable = opts.allowWritable !== false
|
|
28
|
+
const writableSessionId = opts.containerRunning && allowWritable ? pickWritableSession(sessions) : null
|
|
29
|
+
|
|
30
|
+
const items: ViewerItem[] = sessions.map((summary) =>
|
|
31
|
+
summary.sessionId === writableSessionId
|
|
32
|
+
? { kind: 'tui', summary, writable: true }
|
|
33
|
+
: { kind: 'session', summary, writable: false },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if (opts.includeLogs !== false) items.push({ kind: 'logs' })
|
|
37
|
+
|
|
38
|
+
return { items, writableSessionId }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pickWritableSession(sessions: SessionSummary[]): string | null {
|
|
42
|
+
const tuiSession = sessions.find((s) => s.origin?.kind === 'tui')
|
|
43
|
+
return tuiSession?.sessionId ?? null
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionSummary } from './session-list'
|
|
2
|
+
|
|
3
|
+
// At most one item is `writable` (the live TUI session); every other session is
|
|
4
|
+
// read-only. `logs` carries no session — it is container stdout, available even
|
|
5
|
+
// when the agent server is down.
|
|
6
|
+
export type ViewerItem =
|
|
7
|
+
| { kind: 'session'; summary: SessionSummary; writable: false }
|
|
8
|
+
| { kind: 'tui'; summary: SessionSummary; writable: true }
|
|
9
|
+
| { kind: 'logs' }
|
|
10
|
+
|
|
11
|
+
export function isWritable(item: ViewerItem): item is Extract<ViewerItem, { kind: 'tui' }> {
|
|
12
|
+
return item.kind === 'tui'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function itemKey(item: ViewerItem): string {
|
|
16
|
+
return item.kind === 'logs' ? 'logs' : item.summary.sessionId
|
|
17
|
+
}
|
package/src/inspect/label.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function originLabel(origin: MinimalSessionOrigin): string {
|
|
|
17
17
|
case 'cron':
|
|
18
18
|
return `Cron ${origin.jobId} (${origin.jobKind})`
|
|
19
19
|
case 'subagent':
|
|
20
|
-
return `Subagent ${origin.subagent}
|
|
20
|
+
return `Subagent ${origin.subagent}`
|
|
21
21
|
case 'channel':
|
|
22
22
|
return channelLabel(origin)
|
|
23
23
|
case 'system':
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { logs } from '@/container'
|
|
2
|
+
|
|
3
|
+
export type StreamLogsOptions = {
|
|
4
|
+
cwd: string
|
|
5
|
+
color: boolean
|
|
6
|
+
stdout: (line: string) => void
|
|
7
|
+
stderr: (line: string) => void
|
|
8
|
+
signal?: AbortSignal
|
|
9
|
+
liveHint?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type StreamLogsResult = { escToPicker: boolean }
|
|
13
|
+
|
|
14
|
+
// Interactive container-logs viewer for the session-viewer list. Unlike the raw
|
|
15
|
+
// `logs` pump (host-stage, used for `typeclaw logs | grep`), this one runs under
|
|
16
|
+
// the loop's tail scope: aborting the signal (esc/q/ctrl-c) kills `docker logs`
|
|
17
|
+
// and returns control to the picker. Works with the agent server down — it only
|
|
18
|
+
// needs the container to exist.
|
|
19
|
+
export async function streamLogs(opts: StreamLogsOptions): Promise<StreamLogsResult> {
|
|
20
|
+
const aborted = (): boolean => opts.signal?.aborted === true
|
|
21
|
+
if (aborted()) return { escToPicker: true }
|
|
22
|
+
|
|
23
|
+
opts.stdout(divider(opts.color, '─── container logs ───'))
|
|
24
|
+
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
25
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const out = lineSink(opts.stdout)
|
|
29
|
+
const err = lineSink(opts.stderr)
|
|
30
|
+
|
|
31
|
+
const result = await logs({
|
|
32
|
+
cwd: opts.cwd,
|
|
33
|
+
follow: true,
|
|
34
|
+
out,
|
|
35
|
+
err,
|
|
36
|
+
useColor: opts.color,
|
|
37
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
opts.stderr(result.reason)
|
|
42
|
+
return { escToPicker: aborted() }
|
|
43
|
+
}
|
|
44
|
+
return { escToPicker: aborted() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function divider(color: boolean, text: string): string {
|
|
48
|
+
if (color) return `\u001b[2m${text}\u001b[0m`
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `logs` writes pre-formatted chunks (already newline-terminated) to a
|
|
53
|
+
// WritableStream; the loop's sink wants newline-free lines. Buffer partial
|
|
54
|
+
// lines and forward complete ones so a chunk split mid-line never emits a
|
|
55
|
+
// truncated row.
|
|
56
|
+
function lineSink(emit: (line: string) => void): NodeJS.WritableStream {
|
|
57
|
+
let buffer = ''
|
|
58
|
+
const flushLines = (): void => {
|
|
59
|
+
let idx = buffer.indexOf('\n')
|
|
60
|
+
while (idx !== -1) {
|
|
61
|
+
emit(buffer.slice(0, idx))
|
|
62
|
+
buffer = buffer.slice(idx + 1)
|
|
63
|
+
idx = buffer.indexOf('\n')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
write(chunk: string | Uint8Array): boolean {
|
|
68
|
+
buffer += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
|
|
69
|
+
flushLines()
|
|
70
|
+
return true
|
|
71
|
+
},
|
|
72
|
+
end(): void {
|
|
73
|
+
if (buffer.length > 0) {
|
|
74
|
+
emit(buffer)
|
|
75
|
+
buffer = ''
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
} as unknown as NodeJS.WritableStream
|
|
79
|
+
}
|
package/src/inspect/loop.ts
CHANGED
|
@@ -6,6 +6,80 @@ export type TailController = {
|
|
|
6
6
|
dispose: () => void
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export type OpenItemContext = {
|
|
10
|
+
createTailScope: () => TailController
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<TItem | null>
|
|
14
|
+
|
|
15
|
+
export type OpenItemResult = {
|
|
16
|
+
result: RunInspectResult
|
|
17
|
+
// True only when the viewer that just closed ended a writable (live TUI)
|
|
18
|
+
// session — i.e. a tui detach. Logs and read-only transcripts return
|
|
19
|
+
// escToPicker WITHOUT this, so they must not suppress the writable row.
|
|
20
|
+
endedWritableSession?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type OpenItem<TItem> = (item: TItem, ctx: OpenItemContext) => Promise<OpenItemResult>
|
|
24
|
+
|
|
25
|
+
export type ListItemsContext = {
|
|
26
|
+
// False once the user has returned to the picker from any viewer: the prior
|
|
27
|
+
// viewer interaction ended, so there is no proof of a still-live writable
|
|
28
|
+
// session — a detached tui session must not be re-promoted as writable.
|
|
29
|
+
allowWritable: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RunViewerLoopOptions<TItem> = {
|
|
33
|
+
listItems: (ctx: ListItemsContext) => Promise<TItem[]>
|
|
34
|
+
keyOf: (item: TItem) => string
|
|
35
|
+
preselectKey?: string
|
|
36
|
+
selectItem: SelectItem<TItem>
|
|
37
|
+
openItem: OpenItem<TItem>
|
|
38
|
+
createTailScope: () => TailController
|
|
39
|
+
onEmpty: () => RunInspectResult
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The session-viewer state machine: pick an item → open it → on back, re-open
|
|
43
|
+
// the picker; on exit, return. `openItem` owns the per-branch lifecycle and
|
|
44
|
+
// decides whether to request a tail scope (session/logs do; tui does not, since
|
|
45
|
+
// it owns its own raw-mode terminal). When used, the tail scope is created
|
|
46
|
+
// inside `openItem` AFTER the picker resolves and disposed before the picker
|
|
47
|
+
// re-opens, so clack always owns a clean cooked-mode stdin.
|
|
48
|
+
export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): Promise<RunInspectResult> {
|
|
49
|
+
let preselectKey = opts.preselectKey
|
|
50
|
+
let lastPickedKey: string | undefined
|
|
51
|
+
// Writable is only safe on the very first list. Returning to the picker means
|
|
52
|
+
// a viewer was just opened and left — any writable session it might represent
|
|
53
|
+
// is gone (detach ends the live session), so subsequent refreshes are
|
|
54
|
+
// read-only.
|
|
55
|
+
let allowWritable = true
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
const items = await opts.listItems({ allowWritable })
|
|
59
|
+
if (items.length === 0) return opts.onEmpty()
|
|
60
|
+
|
|
61
|
+
let chosen: TItem | null
|
|
62
|
+
if (preselectKey !== undefined) {
|
|
63
|
+
chosen = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
|
|
64
|
+
preselectKey = undefined
|
|
65
|
+
if (chosen === null) return opts.onEmpty()
|
|
66
|
+
} else {
|
|
67
|
+
const hint = lastPickedKey
|
|
68
|
+
chosen = await opts.selectItem(items, hint !== undefined ? { initialKey: hint } : {})
|
|
69
|
+
if (chosen === null) return { ok: false, exitCode: 130, reason: 'cancelled' }
|
|
70
|
+
lastPickedKey = opts.keyOf(chosen)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const opened = await opts.openItem(chosen, { createTailScope: opts.createTailScope })
|
|
74
|
+
const result = opened.result
|
|
75
|
+
if (!result.ok) return result
|
|
76
|
+
if (result.escToPicker !== true) return result
|
|
77
|
+
// Only a writable (tui) detach ends the live session; leaving logs or a
|
|
78
|
+
// read-only transcript leaves it untouched, so the writable row stays.
|
|
79
|
+
if (opened.endedWritableSession === true) allowWritable = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
9
83
|
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
|
|
10
84
|
// Builds a fresh interaction scope for ONE live-tail attempt: a new
|
|
11
85
|
// AbortController plus a temporary raw-mode listener. The loop creates it
|
|
@@ -30,12 +104,9 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
|
|
|
30
104
|
if (sessionArg !== undefined) resolveOpts.sessionIdOrPrefix = sessionArg
|
|
31
105
|
else delete (resolveOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
32
106
|
|
|
33
|
-
// Picker phase: cooked-mode stdin, no tail scope alive.
|
|
34
107
|
const resolved = await resolveInspectTarget(resolveOpts)
|
|
35
108
|
if (!resolved.ok) return resolved
|
|
36
109
|
|
|
37
|
-
// Streaming phase: scope owns raw-mode stdin start-to-dispose, never
|
|
38
|
-
// spanning the picker above or the next iteration's picker below.
|
|
39
110
|
const scope = opts.createTailScope()
|
|
40
111
|
let result: RunInspectResult
|
|
41
112
|
try {
|