catherd-cli 1.2.1 → 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 +146 -36
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- 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 +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +49 -19
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -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/knowledge-command.ts +50 -0
- 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 +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- 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 +16 -3
- 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/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- 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 +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- 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/source-derive.ts +32 -7
- 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,10 +1,12 @@
|
|
|
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";
|
|
4
6
|
import { agentFiles } from "../../domain/agents.ts";
|
|
5
7
|
import { type Catalog, rungInfo } from "../../domain/catalog.ts";
|
|
6
8
|
import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
|
|
7
|
-
import {
|
|
9
|
+
import type { Validation } from "../../domain/profile-rules.ts";
|
|
8
10
|
import type { Access } from "../../domain/record.ts";
|
|
9
11
|
import { VERSION } from "../../infra/version.ts";
|
|
10
12
|
import {
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
projectsFile,
|
|
40
42
|
readProfileDoc,
|
|
41
43
|
readProjects,
|
|
42
|
-
|
|
44
|
+
validateHere,
|
|
43
45
|
} from "../../services/profile-store.ts";
|
|
44
46
|
import { listRuns, type Run, runPaths } from "../../services/run-store.ts";
|
|
45
47
|
import {
|
|
@@ -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[] };
|
|
@@ -121,6 +124,8 @@ export interface Effects {
|
|
|
121
124
|
enforcement(rung: string, access: Access): "enforced" | "advisory";
|
|
122
125
|
/** the harnesses a profile can isolate */
|
|
123
126
|
harnesses: readonly string[];
|
|
127
|
+
/** spec 1.3 §8: what a harness's native mode loads, and the API key its isolation needs, when it says */
|
|
128
|
+
isolation(harness: string): { note?: string; key?: string };
|
|
124
129
|
/** the native agents a profile links, by name */
|
|
125
130
|
agents(p: Profile): string[];
|
|
126
131
|
/**
|
|
@@ -245,7 +250,7 @@ export function rowOf(s: RunSummary): RunRow {
|
|
|
245
250
|
roleRuns: s.totals.runs,
|
|
246
251
|
landed: s.milestones.length,
|
|
247
252
|
budget: s.budget?.fraction ?? null,
|
|
248
|
-
session: s.session
|
|
253
|
+
session: s.session ? sessionKey(s.session) : null,
|
|
249
254
|
};
|
|
250
255
|
}
|
|
251
256
|
|
|
@@ -312,14 +317,19 @@ export function watchDirs(
|
|
|
312
317
|
* The live effects. `repo` is the git toplevel the TUI was opened in (null outside one): inside a repo
|
|
313
318
|
* bound to a profile, `here` is that profile and activating binds the repo instead of the global profile.
|
|
314
319
|
*/
|
|
315
|
-
export function liveEffects(
|
|
316
|
-
|
|
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);
|
|
317
325
|
const rows = memoRuns((r) => rowOf(summarizeRun(deps, r)));
|
|
318
326
|
const harnesses = HARNESS_KEYS.filter((h) => adapterFor(h));
|
|
319
327
|
const bound = () => (repo !== null && readProjects().bindings[repo] !== undefined ? repo : null);
|
|
320
328
|
return {
|
|
329
|
+
host,
|
|
321
330
|
version: VERSION,
|
|
322
|
-
doctor: () =>
|
|
331
|
+
doctor: () =>
|
|
332
|
+
doctor({ host, repo, bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
|
|
323
333
|
runs() {
|
|
324
334
|
const { runs, corrupt } = listRuns();
|
|
325
335
|
return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
|
|
@@ -341,16 +351,26 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
341
351
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
342
352
|
};
|
|
343
353
|
},
|
|
344
|
-
validate: (p, c) =>
|
|
354
|
+
validate: (p, c) => validateHere(p, c, undefined, host.host),
|
|
345
355
|
enforcement: enforcementOf,
|
|
346
356
|
harnesses,
|
|
357
|
+
isolation(h) {
|
|
358
|
+
const a = adapterFor(h);
|
|
359
|
+
return {
|
|
360
|
+
...(a?.isolationNote ? { note: a.isolationNote } : {}),
|
|
361
|
+
...(a?.isolationKey ? { key: a.isolationKey } : {}),
|
|
362
|
+
};
|
|
363
|
+
},
|
|
347
364
|
agents: (p) => agentFiles(p, VERSION).map((f) => f.name),
|
|
348
365
|
async save(name, patch, treatLikes, shown) {
|
|
349
366
|
for (const [rung, like] of Object.entries(treatLikes)) await saveTreatLike(rung, like);
|
|
350
|
-
return patchProfile(name, patch,
|
|
367
|
+
return patchProfile(name, patch, {
|
|
368
|
+
host: host.host,
|
|
369
|
+
...(shown === undefined ? {} : { expect: shown }),
|
|
370
|
+
});
|
|
351
371
|
},
|
|
352
|
-
activate: (name, scope) => activate(name, scope),
|
|
353
|
-
create: createProfile,
|
|
372
|
+
activate: (name, scope) => activate(name, scope, host.host),
|
|
373
|
+
create: (name, from) => createProfile(name, from, host.host),
|
|
354
374
|
remove: deleteProfile,
|
|
355
375
|
refreshCatalog: () => refreshDiscovery(),
|
|
356
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,9 +543,10 @@ 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"],
|
|
549
|
+
isolation: () => ({}),
|
|
526
550
|
agents: (p) =>
|
|
527
551
|
(["architect", "verifier"] as const)
|
|
528
552
|
.filter((r) => p.roles[r].enabled)
|
|
@@ -75,7 +75,16 @@ export function withStaged(c: Catalog, staged: Record<string, string>): Catalog
|
|
|
75
75
|
return { ...c, treatLike };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const GROUP_ORDER = [
|
|
78
|
+
const GROUP_ORDER = [
|
|
79
|
+
"claude",
|
|
80
|
+
"claude-code",
|
|
81
|
+
"codex",
|
|
82
|
+
"opencode-go",
|
|
83
|
+
"opencode",
|
|
84
|
+
"cursor",
|
|
85
|
+
"grok",
|
|
86
|
+
"antigravity",
|
|
87
|
+
];
|
|
79
88
|
const GROUP_NOTE: Record<string, string> = { claude: "native subagent", "claude-code": "headless" };
|
|
80
89
|
|
|
81
90
|
interface ModelEntry {
|
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);
|
|
@@ -41,6 +41,10 @@ const ABOUT: Partial<Record<RowAction["type"], string>> = {
|
|
|
41
41
|
notify: "space turns this notification on or off",
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/** Spec 1.3 §8: a harness row's line: what isolating it needs, and what its native mode loads. */
|
|
45
|
+
export const isolationAbout = (i: { note?: string; key?: string }): string =>
|
|
46
|
+
[`${ABOUT.isolated}${i.key ? ` (it needs ${i.key})` : ""}`, i.note].filter(Boolean).join("; ");
|
|
47
|
+
|
|
44
48
|
/**
|
|
45
49
|
* The profiles poll failed after a good read (its error is newer than the value it keeps): one error line
|
|
46
50
|
* and, when the failure says, its fix; none once a read succeeds again.
|
|
@@ -96,7 +100,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
96
100
|
},
|
|
97
101
|
);
|
|
98
102
|
}, [app.state.profile, here, app, polledAt, openTries]);
|
|
99
|
-
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]);
|
|
100
104
|
const billingKey = JSON.stringify(profile?.billing ?? {});
|
|
101
105
|
// read again after every save too, by the identity of the base it reads back (the same bytes when only
|
|
102
106
|
// treat-likes changed): a save writes the staged treat-likes to the catalog override and clears them
|
|
@@ -181,7 +185,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
181
185
|
/** the draft as it is now: two keys in one tick must not both edit the profile this render drew */
|
|
182
186
|
const current = () => {
|
|
183
187
|
const d = currentDraft(app.getState());
|
|
184
|
-
return d ? resolveProfile(d.doc, d.name) : null;
|
|
188
|
+
return d ? resolveProfile(d.doc, d.name, d.host) : null;
|
|
185
189
|
};
|
|
186
190
|
const edit = (patch: ReturnType<typeof patchFor>) => patch && app.dispatch({ type: "edit", patch });
|
|
187
191
|
const toggleOpen = (r: Row, open?: boolean) =>
|
|
@@ -411,7 +415,16 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
411
415
|
},
|
|
412
416
|
...(row.issue.fix ? [{ text: ` fix: ${row.issue.fix}`, tone: "muted" as const }] : []),
|
|
413
417
|
]
|
|
414
|
-
: [
|
|
418
|
+
: [
|
|
419
|
+
{
|
|
420
|
+
text: ` ${
|
|
421
|
+
row?.action.type === "isolated"
|
|
422
|
+
? isolationAbout(app.effects.isolation(row.action.harness))
|
|
423
|
+
: ((row && ABOUT[row.action.type]) ?? "")
|
|
424
|
+
}`,
|
|
425
|
+
tone: "muted" as const,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
415
428
|
const aboutLines = about
|
|
416
429
|
.flatMap((p) => wrap(p.text, props.width).map((text) => ({ ...p, text })))
|
|
417
430
|
.slice(0, 2);
|
|
@@ -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));
|