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.
Files changed (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. 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
- function printStatus(runId: string | undefined, asJson: boolean): void {
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 r = redact(status(defaultDeps(), runId));
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
  });
@@ -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 { type Validation, validateProfile } from "../../domain/profile-rules.ts";
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
- runnableBackends,
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?.sessionId ?? null,
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(repo: string | null = null): Effects {
316
- const deps = defaultDeps();
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: () => doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
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) => validateProfile(p, c, runnableBackends()),
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, shown === undefined ? {} : { expect: shown });
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: { sessionId: "s-jobs", hostSessionId: null, name: "jobs screen", live: true },
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: { sessionId: "s-auth", hostSessionId: null, name: "auth build", live: false },
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) ? validateProfile(resolveProfile(before, name), c, BACKENDS) : null;
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 = ["claude", "claude-code", "codex", "opencode-go", "opencode", "cursor", "grok"];
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 {
@@ -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({ rawArgs: ctx.rawArgs });
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
  }
@@ -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(resolveProfile(d.base, d.name), resolveProfile(d.doc, d.name)).length;
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]) ? s.drafts : { ...s.drafts, [a.name]: fresh(a.name, a.doc) },
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 { ...s, drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc), doc, treatLikes } } };
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;
@@ -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: [{ command: "tab.runs" }, { action: { type: "session", key: "s-auth" } }],
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: [{ command: "tab.runs" }, { action: { type: "session", key: "s-jobs" } }],
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
- app.dispatch({ type: "show", name, doc: app.effects.readProfile(name) });
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
- : [{ text: ` ${(row && ABOUT[row.action.type]) ?? ""}`, tone: "muted" as const }];
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));