catherd-cli 1.3.0 → 1.4.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/README.md +75 -36
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +44 -38
- package/plugin/skills/catherd-setup/SKILL.md +22 -6
- package/src/adapters/access.ts +7 -1
- package/src/adapters/cli.ts +1 -64
- package/src/cli.ts +48 -19
- package/src/domain/host.ts +17 -0
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +32 -5
- package/src/domain/record.ts +1 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +19 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +2 -2
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +7 -3
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +6 -1
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +105 -33
- package/src/services/finalize.ts +1 -1
- package/src/services/lane-service.ts +1 -0
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +116 -30
- package/src/services/profile-store.ts +23 -6
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +2 -0
- package/src/services/run-store.ts +10 -4
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { defineCommand } from "citty";
|
|
3
|
+
import { assertId } from "../domain/ids.ts";
|
|
4
|
+
import { knownQueueCapability, UNCHECKED_QUEUE } from "../infra/codex-queue.ts";
|
|
5
|
+
import { listDispatches } from "../services/dispatches.ts";
|
|
6
|
+
import { retryDelivery } from "../services/notifier.ts";
|
|
7
|
+
import { inspectDelivery } from "../services/run-debug.ts";
|
|
8
|
+
import { terminalHost } from "./host-arg.ts";
|
|
3
9
|
import { formatBudget } from "../domain/budget.ts";
|
|
4
10
|
import { CatherdError } from "../domain/errors.ts";
|
|
5
11
|
import { gitToplevel } from "../infra/git.ts";
|
|
@@ -52,15 +58,30 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
52
58
|
lines.push(
|
|
53
59
|
` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)}`,
|
|
54
60
|
);
|
|
61
|
+
for (const d of s.delivery)
|
|
62
|
+
lines.push(
|
|
63
|
+
` ${JSON.parse(d.eventId)[2] === "stalled" ? "stalled notice" : d.delivery === "collected" ? "collected" : "unread"} ${d.name} · delivery ${d.delivery}${d.receipt ? ` · queue receipt ${d.receipt.msgId}` : ""}`,
|
|
64
|
+
);
|
|
55
65
|
for (const l of s.stateTail) lines.push(` | ${l}`);
|
|
56
66
|
for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
|
|
57
67
|
return lines;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `live`: a long-lived watch, which reads the queue capability a background probe keeps fresh. A one-shot
|
|
72
|
+
* status never probes it: a slow Codex CLI must not delay the stored run state, nor hold the process open.
|
|
73
|
+
*/
|
|
74
|
+
async function printStatus(runId: string | undefined, asJson: boolean, live = false): Promise<void> {
|
|
61
75
|
// redacted as `runs show` is: the state.md tail can quote a secret, and status goes to shared terminals
|
|
62
76
|
registerSavedSecrets();
|
|
63
|
-
const
|
|
77
|
+
const host = terminalHost(undefined);
|
|
78
|
+
const queue =
|
|
79
|
+
host.host === "codex" && !host.conflict
|
|
80
|
+
? live
|
|
81
|
+
? knownQueueCapability(process.env)
|
|
82
|
+
: UNCHECKED_QUEUE
|
|
83
|
+
: null;
|
|
84
|
+
const r = redact(status(defaultDeps(host), runId, queue));
|
|
64
85
|
if (asJson) return printJson(r);
|
|
65
86
|
if (r.runs.length === 0) console.log("no runs yet");
|
|
66
87
|
// grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
|
|
@@ -84,6 +105,13 @@ function printStatus(runId: string | undefined, asJson: boolean): void {
|
|
|
84
105
|
for (const l of rest) console.log(l);
|
|
85
106
|
}
|
|
86
107
|
}
|
|
108
|
+
console.log(`host ${r.host.host}${r.host.conflict ? ` · conflict: ${r.host.conflict}` : ""}`);
|
|
109
|
+
if (r.queue)
|
|
110
|
+
console.log(
|
|
111
|
+
r.queue.checked === false
|
|
112
|
+
? `queue unchecked · ${r.queue.reason}`
|
|
113
|
+
: `queue CLI ${r.queue.cli ? "supported" : "unavailable"} · server ${r.queue.server}${r.queue.reason ? ` · ${r.queue.reason}` : ""}`,
|
|
114
|
+
);
|
|
87
115
|
for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
|
|
88
116
|
}
|
|
89
117
|
|
|
@@ -95,7 +123,7 @@ export const statusCommand = defineCommand({
|
|
|
95
123
|
},
|
|
96
124
|
args: { run: { type: "positional", required: false, description: "run id" }, ...json },
|
|
97
125
|
run({ args }) {
|
|
98
|
-
printStatus(args.run, args.json === true);
|
|
126
|
+
return printStatus(args.run, args.json === true);
|
|
99
127
|
},
|
|
100
128
|
});
|
|
101
129
|
|
|
@@ -142,7 +170,7 @@ export const watchCommand = defineCommand({
|
|
|
142
170
|
}
|
|
143
171
|
for (;;) {
|
|
144
172
|
if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
|
|
145
|
-
printStatus(undefined, false);
|
|
173
|
+
await printStatus(undefined, false, true);
|
|
146
174
|
console.log(`updated ${new Date().toLocaleTimeString()} · Ctrl-C to stop`);
|
|
147
175
|
await Bun.sleep(every);
|
|
148
176
|
}
|
|
@@ -223,6 +251,10 @@ const show = defineCommand({
|
|
|
223
251
|
for (const d of debug ?? []) {
|
|
224
252
|
console.log(`\n--- ${d.name} ${d.dispatchId} (${d.rung}, admitted ${d.admittedAt})`);
|
|
225
253
|
console.log(`record: ${d.record ? JSON.stringify(d.record) : "none yet"}`);
|
|
254
|
+
for (const e of d.deliveries.length ? d.deliveries : [d])
|
|
255
|
+
console.log(
|
|
256
|
+
`delivery: ${e.delivery}; event ${e.eventId}; receipt ${e.receipt ? JSON.stringify(e.receipt) : "none"}`,
|
|
257
|
+
);
|
|
226
258
|
console.log(`exit.json: ${d.exit ? JSON.stringify(d.exit) : "none yet"}`);
|
|
227
259
|
for (const [label, lines] of [
|
|
228
260
|
["stderr", d.stderrTail],
|
|
@@ -252,9 +284,53 @@ const cancelCmd = defineCommand({
|
|
|
252
284
|
},
|
|
253
285
|
});
|
|
254
286
|
|
|
287
|
+
const retryPush = defineCommand({
|
|
288
|
+
meta: {
|
|
289
|
+
name: "retry-push",
|
|
290
|
+
description: "Retry a stored event for its current owner, acknowledging possible duplicate input",
|
|
291
|
+
},
|
|
292
|
+
args: {
|
|
293
|
+
run: { type: "positional", required: true, description: "stored run id" },
|
|
294
|
+
name: { type: "positional", required: true, description: "stored role name" },
|
|
295
|
+
event: { type: "string", required: true, description: "exact event ID shown by peek/status" },
|
|
296
|
+
"acknowledge-possible-duplicate": {
|
|
297
|
+
type: "boolean",
|
|
298
|
+
description: "explicitly acknowledge possible duplicate native input",
|
|
299
|
+
},
|
|
300
|
+
...json,
|
|
301
|
+
},
|
|
302
|
+
async run({ args }) {
|
|
303
|
+
if (args["acknowledge-possible-duplicate"] !== true)
|
|
304
|
+
throw new CatherdError("E_INPUT_INVALID", "Retry requires --acknowledge-possible-duplicate");
|
|
305
|
+
assertId("role name", args.name);
|
|
306
|
+
const run = findRun(args.run);
|
|
307
|
+
const d = listDispatches(run).find(
|
|
308
|
+
(d) =>
|
|
309
|
+
d.admit.name === args.name &&
|
|
310
|
+
["finished", "stalled"].some(
|
|
311
|
+
(kind) => args.event === JSON.stringify([run.id, d.admit.dispatchId, kind]),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
if (!d) throw new CatherdError("E_INPUT_INVALID", "Event does not belong to this stored run and role");
|
|
315
|
+
const deps = defaultDeps(terminalHost(undefined));
|
|
316
|
+
await retryDelivery(deps, d.dir, args.event, { allowPossibleDuplicate: true });
|
|
317
|
+
const inspection = inspectDelivery(
|
|
318
|
+
run,
|
|
319
|
+
d,
|
|
320
|
+
readRecords(run).records.some((r) => r.dispatchId === d.admit.dispatchId),
|
|
321
|
+
args.event,
|
|
322
|
+
);
|
|
323
|
+
if (args.json) printJson({ ...inspection, processing: "unconfirmed" });
|
|
324
|
+
else
|
|
325
|
+
console.log(
|
|
326
|
+
`retry inspected: ${inspection.delivery}; event ${args.event}${inspection.receipt ? `; queue receipt ${inspection.receipt.msgId}` : ""}; processing unconfirmed; use peek/result`,
|
|
327
|
+
);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
255
331
|
/** Spec §8 `catherd runs list|show [--debug]|cancel`; a bare `catherd runs` lists them, as `status` needs no run. */
|
|
256
332
|
export const runsCommand = defineCommand({
|
|
257
333
|
meta: { name: "runs", description: "Runs: list them (the default), show one, cancel a live role" },
|
|
258
|
-
subCommands: { list, show, cancel: cancelCmd },
|
|
334
|
+
subCommands: { list, show, cancel: cancelCmd, "retry-push": retryPush },
|
|
259
335
|
default: "list",
|
|
260
336
|
});
|
package/src/entry/tui/effects.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { HostContext } from "../../domain/host.ts";
|
|
2
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
3
|
import { realpathSync, statSync, watch } from "node:fs";
|
|
2
4
|
import { adapterFor } from "../../adapters/registry.ts";
|
|
3
5
|
import "../../adapters/all.ts";
|
|
@@ -88,6 +90,7 @@ export interface RunRow {
|
|
|
88
90
|
* The live set calls the services; tests and the storybook pass fakes.
|
|
89
91
|
*/
|
|
90
92
|
export interface Effects {
|
|
93
|
+
host: HostContext;
|
|
91
94
|
version: string;
|
|
92
95
|
doctor(): Promise<DoctorReport>;
|
|
93
96
|
runs(): { rows: RunRow[]; warnings: string[] };
|
|
@@ -247,7 +250,7 @@ export function rowOf(s: RunSummary): RunRow {
|
|
|
247
250
|
roleRuns: s.totals.runs,
|
|
248
251
|
landed: s.milestones.length,
|
|
249
252
|
budget: s.budget?.fraction ?? null,
|
|
250
|
-
session: s.session
|
|
253
|
+
session: s.session ? sessionKey(s.session) : null,
|
|
251
254
|
};
|
|
252
255
|
}
|
|
253
256
|
|
|
@@ -314,14 +317,19 @@ export function watchDirs(
|
|
|
314
317
|
* The live effects. `repo` is the git toplevel the TUI was opened in (null outside one): inside a repo
|
|
315
318
|
* bound to a profile, `here` is that profile and activating binds the repo instead of the global profile.
|
|
316
319
|
*/
|
|
317
|
-
export function liveEffects(
|
|
318
|
-
|
|
320
|
+
export function liveEffects(
|
|
321
|
+
repo: string | null = null,
|
|
322
|
+
host: HostContext = { host: "unknown", session: null, conflict: null },
|
|
323
|
+
): Effects {
|
|
324
|
+
const deps = defaultDeps(host);
|
|
319
325
|
const rows = memoRuns((r) => rowOf(summarizeRun(deps, r)));
|
|
320
326
|
const harnesses = HARNESS_KEYS.filter((h) => adapterFor(h));
|
|
321
327
|
const bound = () => (repo !== null && readProjects().bindings[repo] !== undefined ? repo : null);
|
|
322
328
|
return {
|
|
329
|
+
host,
|
|
323
330
|
version: VERSION,
|
|
324
|
-
doctor: () =>
|
|
331
|
+
doctor: () =>
|
|
332
|
+
doctor({ host, repo, bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
|
|
325
333
|
runs() {
|
|
326
334
|
const { runs, corrupt } = listRuns();
|
|
327
335
|
return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
|
|
@@ -343,7 +351,7 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
343
351
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
344
352
|
};
|
|
345
353
|
},
|
|
346
|
-
validate: (p, c) => validateHere(p, c),
|
|
354
|
+
validate: (p, c) => validateHere(p, c, undefined, host.host),
|
|
347
355
|
enforcement: enforcementOf,
|
|
348
356
|
harnesses,
|
|
349
357
|
isolation(h) {
|
|
@@ -356,10 +364,13 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
356
364
|
agents: (p) => agentFiles(p, VERSION).map((f) => f.name),
|
|
357
365
|
async save(name, patch, treatLikes, shown) {
|
|
358
366
|
for (const [rung, like] of Object.entries(treatLikes)) await saveTreatLike(rung, like);
|
|
359
|
-
return patchProfile(name, patch,
|
|
367
|
+
return patchProfile(name, patch, {
|
|
368
|
+
host: host.host,
|
|
369
|
+
...(shown === undefined ? {} : { expect: shown }),
|
|
370
|
+
});
|
|
360
371
|
},
|
|
361
|
-
activate: (name, scope) => activate(name, scope),
|
|
362
|
-
create: createProfile,
|
|
372
|
+
activate: (name, scope) => activate(name, scope, host.host),
|
|
373
|
+
create: (name, from) => createProfile(name, from, host.host),
|
|
363
374
|
remove: deleteProfile,
|
|
364
375
|
refreshCatalog: () => refreshDiscovery(),
|
|
365
376
|
syncSources: () => syncSources(),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../../domain/host.ts";
|
|
2
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
3
|
import { isDeepStrictEqual } from "node:util";
|
|
2
4
|
import {
|
|
3
5
|
applyPatch,
|
|
@@ -30,6 +32,9 @@ import { withStaged } from "./profile-tree.ts";
|
|
|
30
32
|
* the catalog is the shipped one, read through the catalog service.
|
|
31
33
|
*/
|
|
32
34
|
export const FIXTURE_REPORT: DoctorReport = {
|
|
35
|
+
host: { host: "unknown", session: null, conflict: null },
|
|
36
|
+
queue: null,
|
|
37
|
+
push: null,
|
|
33
38
|
ready: false,
|
|
34
39
|
version: "1.0.0",
|
|
35
40
|
checks: [
|
|
@@ -79,6 +84,7 @@ export const FIXTURE_REPORT: DoctorReport = {
|
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): RunSummary => ({
|
|
87
|
+
delivery: [],
|
|
82
88
|
repo: "/home/me/app",
|
|
83
89
|
createdAt: "2026-09-26T11:48:00.000Z",
|
|
84
90
|
session: null,
|
|
@@ -112,7 +118,13 @@ const FIXTURE_RUNS: RunSummary[] = [
|
|
|
112
118
|
summary({
|
|
113
119
|
id: JOBS,
|
|
114
120
|
title: "Jobs screen",
|
|
115
|
-
session: {
|
|
121
|
+
session: {
|
|
122
|
+
host: "claude-code",
|
|
123
|
+
sessionId: "s-jobs",
|
|
124
|
+
hostSessionId: null,
|
|
125
|
+
name: "jobs screen",
|
|
126
|
+
live: true,
|
|
127
|
+
},
|
|
116
128
|
stateTail: ["M1 in review", "Next: fix round for M1.L2"],
|
|
117
129
|
live: [
|
|
118
130
|
{
|
|
@@ -141,7 +153,13 @@ const FIXTURE_RUNS: RunSummary[] = [
|
|
|
141
153
|
title: "Auth refactor",
|
|
142
154
|
repo: "/home/me/api",
|
|
143
155
|
createdAt: "2026-09-25T09:00:00.000Z",
|
|
144
|
-
session: {
|
|
156
|
+
session: {
|
|
157
|
+
host: "claude-code",
|
|
158
|
+
sessionId: "s-auth",
|
|
159
|
+
hostSessionId: null,
|
|
160
|
+
name: "auth build",
|
|
161
|
+
live: false,
|
|
162
|
+
},
|
|
145
163
|
continuedIn: "kit follow-up",
|
|
146
164
|
stateTail: ["Next: done"],
|
|
147
165
|
totals: {
|
|
@@ -200,7 +218,7 @@ const auth = (continued: SessionRun["continued"]): SessionRun => ({
|
|
|
200
218
|
export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
201
219
|
{
|
|
202
220
|
row: {
|
|
203
|
-
key: "s-jobs",
|
|
221
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }),
|
|
204
222
|
name: "jobs screen",
|
|
205
223
|
live: true,
|
|
206
224
|
runs: 1,
|
|
@@ -267,7 +285,7 @@ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
|
267
285
|
},
|
|
268
286
|
{
|
|
269
287
|
row: {
|
|
270
|
-
key: "s-kit",
|
|
288
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-kit" }),
|
|
271
289
|
name: "kit follow-up",
|
|
272
290
|
live: false,
|
|
273
291
|
runs: 1,
|
|
@@ -279,7 +297,7 @@ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
|
279
297
|
},
|
|
280
298
|
{
|
|
281
299
|
row: {
|
|
282
|
-
key: "s-auth",
|
|
300
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-auth" }),
|
|
283
301
|
name: "auth build",
|
|
284
302
|
live: false,
|
|
285
303
|
runs: 2,
|
|
@@ -394,6 +412,7 @@ const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
|
|
|
394
412
|
* directory the TUI stands in and `bindings` the repo bindings, as `liveEffects(repo)` reads them.
|
|
395
413
|
*/
|
|
396
414
|
export interface FixtureOptions {
|
|
415
|
+
host?: OrchestrationHost;
|
|
397
416
|
runs?: RunSummary[];
|
|
398
417
|
sessions?: { row: SessionRow; runs: SessionRun[] }[];
|
|
399
418
|
report?: DoctorReport;
|
|
@@ -418,6 +437,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
418
437
|
/** the run folders watched now */
|
|
419
438
|
watched: string[][];
|
|
420
439
|
} {
|
|
440
|
+
const host = o.host ?? "claude-code";
|
|
421
441
|
const runs = o.runs ?? FIXTURE_RUNS;
|
|
422
442
|
const sessions = o.sessions ?? FIXTURE_SESSIONS;
|
|
423
443
|
const listeners = new Set<() => void>();
|
|
@@ -434,15 +454,17 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
434
454
|
after: ProfileDoc,
|
|
435
455
|
staged: Record<string, string> = {},
|
|
436
456
|
) => {
|
|
437
|
-
const p = resolveProfile(after, name);
|
|
457
|
+
const p = resolveProfile(after, name, host);
|
|
438
458
|
const c = withStaged(loadCatalog({ timings: false }), staged);
|
|
439
|
-
const v = validateProfile(p, c, BACKENDS);
|
|
459
|
+
const v = validateProfile(p, c, BACKENDS, undefined, host);
|
|
440
460
|
// spec 1.2 §6.2, as the ProfileService rules: a save that repairs part of an invalid profile goes through
|
|
441
|
-
const was = docs.has(name)
|
|
461
|
+
const was = docs.has(name)
|
|
462
|
+
? validateProfile(resolveProfile(before, name, host), c, BACKENDS, undefined, host)
|
|
463
|
+
: null;
|
|
442
464
|
return {
|
|
443
465
|
saved: repairs(was, v),
|
|
444
466
|
...v,
|
|
445
|
-
diff: diffProfiles(resolveProfile(before, name), p),
|
|
467
|
+
diff: diffProfiles(resolveProfile(before, name, host), p),
|
|
446
468
|
linked: [],
|
|
447
469
|
pruned: [],
|
|
448
470
|
newSessionNeededFor: [],
|
|
@@ -454,6 +476,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
454
476
|
touch: () => {
|
|
455
477
|
for (const l of listeners) l();
|
|
456
478
|
},
|
|
479
|
+
host: { host, session: null, conflict: null },
|
|
457
480
|
version: "1.0.0",
|
|
458
481
|
doctor: async () => o.report ?? FIXTURE_REPORT,
|
|
459
482
|
runs: () => ({ rows: runs.map(rowOf), warnings: [] }),
|
|
@@ -520,7 +543,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
520
543
|
catalog: loadCatalog({ timings: false }),
|
|
521
544
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
522
545
|
}),
|
|
523
|
-
validate: (p, c) => validateProfile(p, c, BACKENDS),
|
|
546
|
+
validate: (p, c) => validateProfile(p, c, BACKENDS, undefined, host),
|
|
524
547
|
enforcement: (rung) => (rung.startsWith("codex:") ? "enforced" : "advisory"),
|
|
525
548
|
harnesses: ["codex", "claude-code", "opencode"],
|
|
526
549
|
isolation: () => ({}),
|
package/src/entry/tui/run.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { terminalHost } from "../host-arg.ts";
|
|
1
2
|
import { type CliRenderer, createCliRenderer, SystemClock } from "@opentui/core";
|
|
2
3
|
import { createRoot } from "@opentui/react";
|
|
3
4
|
import { gitToplevel } from "../../infra/git.ts";
|
|
@@ -26,6 +27,7 @@ import { App } from "./views/app.tsx";
|
|
|
26
27
|
|
|
27
28
|
export interface TuiOptions {
|
|
28
29
|
rawArgs: string[];
|
|
30
|
+
host?: string;
|
|
29
31
|
tab?: Tab;
|
|
30
32
|
env?: Record<string, string | undefined>;
|
|
31
33
|
/** Whether stdin and stdout are a terminal; tests pass it so none reads the real one. */
|
|
@@ -59,8 +61,9 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
59
61
|
}
|
|
60
62
|
const keybinds = keybindsFromConfig();
|
|
61
63
|
const story = Boolean(env.CATHERD_STORY);
|
|
64
|
+
const host = terminalHost(o.host, env);
|
|
62
65
|
// the repo this directory is in, resolved once at start (the repo ruling)
|
|
63
|
-
const effects: Effects = story ? fixtureEffects() : liveEffects(await gitToplevel(process.cwd()));
|
|
66
|
+
const effects: Effects = story ? fixtureEffects() : liveEffects(await gitToplevel(process.cwd()), host);
|
|
64
67
|
const renderer = await createCliRenderer({
|
|
65
68
|
exitOnCtrlC: false,
|
|
66
69
|
useMouse: false,
|
|
@@ -83,7 +86,7 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
83
86
|
keymap={createAppKeymap(renderer)}
|
|
84
87
|
effects={effects}
|
|
85
88
|
clock={new WallClock()}
|
|
86
|
-
initial={initialState(o.tab ?? "status")}
|
|
89
|
+
initial={initialState(o.tab ?? "status", effects.host.host)}
|
|
87
90
|
copy={(text) => renderer.copyToClipboardOSC52(text)}
|
|
88
91
|
onExit={(code, kept) => {
|
|
89
92
|
result = { code, kept };
|
|
@@ -103,6 +106,9 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/** Bare `catherd`: `cli.ts` imports this only when no subcommand was named, so no subcommand loads OpenTUI. */
|
|
106
|
-
export async function tuiRun(ctx: { rawArgs: string[] }): Promise<void> {
|
|
107
|
-
process.exitCode = await openTui({
|
|
109
|
+
export async function tuiRun(ctx: { rawArgs: string[]; args?: Record<string, unknown> }): Promise<void> {
|
|
110
|
+
process.exitCode = await openTui({
|
|
111
|
+
rawArgs: ctx.rawArgs,
|
|
112
|
+
host: typeof ctx.args?.host === "string" ? ctx.args.host : undefined,
|
|
113
|
+
});
|
|
108
114
|
}
|
package/src/entry/tui/state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../../domain/host.ts";
|
|
1
2
|
import {
|
|
2
3
|
applyPatch,
|
|
3
4
|
diffProfiles,
|
|
@@ -21,6 +22,7 @@ interface Snapshot {
|
|
|
21
22
|
|
|
22
23
|
/** Spec §9.2: edits are staged per profile and touch nothing on disk until a save. */
|
|
23
24
|
export interface Draft extends Snapshot {
|
|
25
|
+
host: OrchestrationHost;
|
|
24
26
|
name: string;
|
|
25
27
|
/** the document as it was read, which a save diffs against */
|
|
26
28
|
base: ProfileDoc;
|
|
@@ -118,6 +120,7 @@ export const ARM_MS: Record<Armed["what"], number> = { interrupt: 1_500, delete:
|
|
|
118
120
|
export const HISTORY_LIMIT = 100;
|
|
119
121
|
|
|
120
122
|
export interface AppState {
|
|
123
|
+
host: OrchestrationHost;
|
|
121
124
|
tab: Tab;
|
|
122
125
|
/** the profile the Profiles tab shows; null until one is read */
|
|
123
126
|
profile: string | null;
|
|
@@ -165,7 +168,8 @@ export type Action =
|
|
|
165
168
|
| { type: "arm"; what: Armed["what"]; target: string; at: number }
|
|
166
169
|
| { type: "disarm" };
|
|
167
170
|
|
|
168
|
-
export const initialState = (tab: Tab = "status"): AppState => ({
|
|
171
|
+
export const initialState = (tab: Tab = "status", host: OrchestrationHost = "unknown"): AppState => ({
|
|
172
|
+
host,
|
|
169
173
|
tab,
|
|
170
174
|
profile: null,
|
|
171
175
|
drafts: {},
|
|
@@ -177,7 +181,8 @@ export const initialState = (tab: Tab = "status"): AppState => ({
|
|
|
177
181
|
armed: null,
|
|
178
182
|
});
|
|
179
183
|
|
|
180
|
-
const fresh = (name: string, doc: ProfileDoc): Draft => ({
|
|
184
|
+
const fresh = (name: string, doc: ProfileDoc, host: OrchestrationHost): Draft => ({
|
|
185
|
+
host,
|
|
181
186
|
name,
|
|
182
187
|
base: doc,
|
|
183
188
|
doc,
|
|
@@ -195,7 +200,10 @@ export const currentDraft = (s: AppState): Draft | null => (s.profile ? (s.draft
|
|
|
195
200
|
const isStaged = (d: Draft | undefined): boolean => d !== undefined && dirtyCount(d) > 0;
|
|
196
201
|
|
|
197
202
|
export function dirtyCount(d: Draft): number {
|
|
198
|
-
const fields = diffProfiles(
|
|
203
|
+
const fields = diffProfiles(
|
|
204
|
+
resolveProfile(d.base, d.name, d.host),
|
|
205
|
+
resolveProfile(d.doc, d.name, d.host),
|
|
206
|
+
).length;
|
|
199
207
|
return fields + Object.keys(d.treatLikes).length;
|
|
200
208
|
}
|
|
201
209
|
|
|
@@ -239,7 +247,9 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
239
247
|
tab: "profiles",
|
|
240
248
|
profile: a.name,
|
|
241
249
|
// Keep only a draft with staged changes; a clean one restarts from the file just read.
|
|
242
|
-
drafts: isStaged(s.drafts[a.name])
|
|
250
|
+
drafts: isStaged(s.drafts[a.name])
|
|
251
|
+
? s.drafts
|
|
252
|
+
: { ...s.drafts, [a.name]: fresh(a.name, a.doc, s.host) },
|
|
243
253
|
};
|
|
244
254
|
case "edit":
|
|
245
255
|
return withDraft(s, s.profile, (d) =>
|
|
@@ -302,13 +312,16 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
302
312
|
const d = s.drafts[a.name];
|
|
303
313
|
const from = a.from;
|
|
304
314
|
if (!from || !d || (same(d.doc, from.doc) && same(d.treatLikes, from.treatLikes)))
|
|
305
|
-
return { ...s, drafts: { ...s.drafts, [a.name]: fresh(a.name, a.doc) } };
|
|
315
|
+
return { ...s, drafts: { ...s.drafts, [a.name]: fresh(a.name, a.doc, s.host) } };
|
|
306
316
|
// edited while the save wrote: the saved file is the new base, and the newer edits stay staged over it
|
|
307
317
|
const treatLikes = Object.fromEntries(
|
|
308
318
|
Object.entries(d.treatLikes).filter(([rung, like]) => from.treatLikes[rung] !== like),
|
|
309
319
|
);
|
|
310
320
|
const doc = applyPatch(a.doc, patchBetween(from.doc, d.doc));
|
|
311
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
...s,
|
|
323
|
+
drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc, s.host), doc, treatLikes } },
|
|
324
|
+
};
|
|
312
325
|
}
|
|
313
326
|
case "forget": {
|
|
314
327
|
const { [a.name]: _gone, ...drafts } = s.drafts;
|
package/src/entry/tui/stories.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
2
|
import type { ProfilePatch } from "../../domain/profile.ts";
|
|
2
3
|
import type { CommandId } from "./commands.ts";
|
|
3
4
|
import type { FixtureOptions } from "./fixtures.ts";
|
|
@@ -58,19 +59,25 @@ export const STORIES: Story[] = [
|
|
|
58
59
|
{
|
|
59
60
|
name: "session",
|
|
60
61
|
title: "Runs: one session with two runs, one continued elsewhere",
|
|
61
|
-
steps: [
|
|
62
|
+
steps: [
|
|
63
|
+
{ command: "tab.runs" },
|
|
64
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-auth" }) } },
|
|
65
|
+
],
|
|
62
66
|
},
|
|
63
67
|
{
|
|
64
68
|
name: "session-live",
|
|
65
69
|
title: "Runs: a live session with three live roles",
|
|
66
|
-
steps: [
|
|
70
|
+
steps: [
|
|
71
|
+
{ command: "tab.runs" },
|
|
72
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
73
|
+
],
|
|
67
74
|
},
|
|
68
75
|
{
|
|
69
76
|
name: "role",
|
|
70
77
|
title: "Runs: a role opened",
|
|
71
78
|
steps: [
|
|
72
79
|
{ command: "tab.runs" },
|
|
73
|
-
{ action: { type: "session", key: "s-jobs" } },
|
|
80
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
74
81
|
{ action: { type: "role", run: "20260926-114800-jobs-screen", dispatchId: "d1" } },
|
|
75
82
|
],
|
|
76
83
|
},
|
|
@@ -79,7 +86,7 @@ export const STORIES: Story[] = [
|
|
|
79
86
|
title: "Runs: a milestone opened on its digest",
|
|
80
87
|
steps: [
|
|
81
88
|
{ command: "tab.runs" },
|
|
82
|
-
{ action: { type: "session", key: "s-jobs" } },
|
|
89
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
83
90
|
{ action: { type: "milestone", run: "20260926-114800-jobs-screen", name: "M0" } },
|
|
84
91
|
],
|
|
85
92
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
|
|
3
|
-
import { PROFILE_NAME, patchBetween, type ProfileDoc } from "../../../domain/profile.ts";
|
|
3
|
+
import { PROFILE_NAME, patchBetween, resolveProfile, type ProfileDoc } from "../../../domain/profile.ts";
|
|
4
4
|
import { CHANGED_ON_DISK } from "../effects.ts";
|
|
5
5
|
import { type AppApi, useApp, useDialogHandler } from "../providers/app.tsx";
|
|
6
6
|
import { type Data, useData } from "../providers/data.tsx";
|
|
@@ -16,7 +16,9 @@ const fail = (app: AppApi, e: unknown) => app.toast(errorToast(e));
|
|
|
16
16
|
*/
|
|
17
17
|
export function showProfile(app: AppApi, name: string, o: { quiet?: boolean } = {}): unknown {
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
const doc = app.effects.readProfile(name);
|
|
20
|
+
resolveProfile(doc, name, app.getState().host);
|
|
21
|
+
app.dispatch({ type: "show", name, doc });
|
|
20
22
|
return null;
|
|
21
23
|
} catch (e) {
|
|
22
24
|
if (!o.quiet) fail(app, e);
|
|
@@ -100,7 +100,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
100
100
|
},
|
|
101
101
|
);
|
|
102
102
|
}, [app.state.profile, here, app, polledAt, openTries]);
|
|
103
|
-
const profile = useMemo(() => (draft ? resolveProfile(draft.doc, draft.name) : null), [draft]);
|
|
103
|
+
const profile = useMemo(() => (draft ? resolveProfile(draft.doc, draft.name, draft.host) : null), [draft]);
|
|
104
104
|
const billingKey = JSON.stringify(profile?.billing ?? {});
|
|
105
105
|
// read again after every save too, by the identity of the base it reads back (the same bytes when only
|
|
106
106
|
// treat-likes changed): a save writes the staged treat-likes to the catalog override and clears them
|
|
@@ -185,7 +185,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
185
185
|
/** the draft as it is now: two keys in one tick must not both edit the profile this render drew */
|
|
186
186
|
const current = () => {
|
|
187
187
|
const d = currentDraft(app.getState());
|
|
188
|
-
return d ? resolveProfile(d.doc, d.name) : null;
|
|
188
|
+
return d ? resolveProfile(d.doc, d.name, d.host) : null;
|
|
189
189
|
};
|
|
190
190
|
const edit = (patch: ReturnType<typeof patchFor>) => patch && app.dispatch({ type: "edit", patch });
|
|
191
191
|
const toggleOpen = (r: Row, open?: boolean) =>
|
|
@@ -51,8 +51,8 @@ function previewSave(
|
|
|
51
51
|
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
|
|
52
52
|
now: ProfileDoc = fx.readProfile(d.name),
|
|
53
53
|
): SavePreview {
|
|
54
|
-
const before = resolveProfile(now, d.name);
|
|
55
|
-
const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name);
|
|
54
|
+
const before = resolveProfile(now, d.name, d.host);
|
|
55
|
+
const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name, d.host);
|
|
56
56
|
const { catalog } = fx.catalog(after.billing);
|
|
57
57
|
const a = new Set(fx.agents(before));
|
|
58
58
|
const b = new Set(fx.agents(after));
|
package/src/infra/cli.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
2
|
+
import { log } from "./log.ts";
|
|
3
|
+
import { killGroup } from "./proc.ts";
|
|
4
|
+
|
|
5
|
+
export interface CliResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
out: string;
|
|
8
|
+
err: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Process groups of CLI calls still running: an exiting catherd (Ctrl-C is exit 130) kills them. */
|
|
12
|
+
const live = new Set<number>();
|
|
13
|
+
let hooked = false;
|
|
14
|
+
function track(pid: number): void {
|
|
15
|
+
live.add(pid);
|
|
16
|
+
if (hooked) return;
|
|
17
|
+
hooked = true;
|
|
18
|
+
// a detached group gets no terminal SIGINT, and process.exit skips the timeout, so kill it on the way out
|
|
19
|
+
process.on("exit", () => {
|
|
20
|
+
for (const g of live) killGroup(g, "SIGKILL");
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs `bin <args>` without catherd's secrets and with no stdin; null when `bin` is not on PATH. A call
|
|
26
|
+
* past `timeoutMs` is killed with every process it started (it runs in its own process group) and is
|
|
27
|
+
* reported as failed, so neither a wedged CLI nor a child holding its pipes stalls the caller.
|
|
28
|
+
*/
|
|
29
|
+
export async function runCli(
|
|
30
|
+
bin: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
o: { timeoutMs: number; env?: Record<string, string>; cwd?: string; logArgs?: string[] },
|
|
33
|
+
): Promise<CliResult | null> {
|
|
34
|
+
if (!Bun.which(bin, { PATH: process.env.PATH ?? "" })) return null;
|
|
35
|
+
const logArgs = o.logArgs ?? args;
|
|
36
|
+
log("debug", "spawn", { argv: [bin, ...logArgs], env: scrubSecrets(o.env ?? {}) });
|
|
37
|
+
const p = Bun.spawn([bin, ...args], {
|
|
38
|
+
cwd: o.cwd,
|
|
39
|
+
env: scrubSecrets({ ...process.env, ...o.env }),
|
|
40
|
+
stdin: "ignore",
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
detached: true,
|
|
44
|
+
});
|
|
45
|
+
track(p.pid);
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
const late = new Promise<null>((resolve) => {
|
|
48
|
+
timer = setTimeout(() => resolve(null), o.timeoutMs);
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const done = await Promise.race([
|
|
52
|
+
Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]),
|
|
53
|
+
late,
|
|
54
|
+
]);
|
|
55
|
+
if (!done) {
|
|
56
|
+
killGroup(p.pid, "SIGKILL");
|
|
57
|
+
return { ok: false, out: "", err: `${bin} ${logArgs.join(" ")} timed out after ${o.timeoutMs} ms` };
|
|
58
|
+
}
|
|
59
|
+
const [code, out, err] = done;
|
|
60
|
+
return { ok: code === 0, out, err };
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
live.delete(p.pid);
|
|
64
|
+
}
|
|
65
|
+
}
|