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.
Files changed (70) hide show
  1. package/README.md +75 -36
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +11 -0
  5. package/plugin/.mcp-codex.json +10 -0
  6. package/plugin/bin/catherd-mcp +1 -1
  7. package/plugin/skills/catherd/SKILL.md +44 -38
  8. package/plugin/skills/catherd-setup/SKILL.md +22 -6
  9. package/src/adapters/access.ts +7 -1
  10. package/src/adapters/cli.ts +1 -64
  11. package/src/cli.ts +48 -19
  12. package/src/domain/host.ts +17 -0
  13. package/src/domain/notice.ts +5 -2
  14. package/src/domain/profile-rules.ts +17 -0
  15. package/src/domain/profile.ts +32 -5
  16. package/src/domain/record.ts +1 -0
  17. package/src/entry/catalog-command.ts +4 -2
  18. package/src/entry/deps.ts +6 -3
  19. package/src/entry/doctor-command.ts +11 -2
  20. package/src/entry/host-arg.ts +28 -0
  21. package/src/entry/init-command.ts +18 -7
  22. package/src/entry/lock-command.ts +7 -2
  23. package/src/entry/mcp/handshake.ts +3 -1
  24. package/src/entry/mcp/run-tools.ts +5 -2
  25. package/src/entry/mcp/server.ts +129 -40
  26. package/src/entry/mcp/setup-tools.ts +7 -2
  27. package/src/entry/profile-command.ts +108 -12
  28. package/src/entry/runs-command.ts +81 -5
  29. package/src/entry/tui/effects.ts +19 -8
  30. package/src/entry/tui/fixtures.ts +33 -10
  31. package/src/entry/tui/run.tsx +10 -4
  32. package/src/entry/tui/state.ts +19 -6
  33. package/src/entry/tui/stories.ts +11 -4
  34. package/src/entry/tui/views/profile-actions.ts +4 -2
  35. package/src/entry/tui/views/profiles.tsx +2 -2
  36. package/src/entry/tui/views/save-dialog.tsx +2 -2
  37. package/src/infra/cli.ts +65 -0
  38. package/src/infra/codex-queue.ts +165 -0
  39. package/src/infra/delivery.ts +83 -0
  40. package/src/infra/dispatch-dir.ts +1 -0
  41. package/src/infra/env.ts +4 -1
  42. package/src/infra/host-context.ts +58 -0
  43. package/src/infra/peer-inbox.ts +4 -1
  44. package/src/infra/supervisor.ts +3 -2
  45. package/src/services/admission.ts +7 -3
  46. package/src/services/agent-links.ts +30 -5
  47. package/src/services/backends.ts +7 -0
  48. package/src/services/dispatch-service.ts +17 -7
  49. package/src/services/dispatches.ts +6 -1
  50. package/src/services/doctor-backends.ts +30 -7
  51. package/src/services/doctor-checks.ts +20 -5
  52. package/src/services/doctor-push.ts +69 -90
  53. package/src/services/doctor.ts +105 -33
  54. package/src/services/finalize.ts +1 -1
  55. package/src/services/lane-service.ts +1 -0
  56. package/src/services/notifier.ts +322 -70
  57. package/src/services/peek.ts +46 -9
  58. package/src/services/ports.ts +8 -2
  59. package/src/services/profile-service.ts +116 -30
  60. package/src/services/profile-store.ts +23 -6
  61. package/src/services/routing-service.ts +6 -1
  62. package/src/services/run-debug.ts +98 -4
  63. package/src/services/run-service.ts +2 -0
  64. package/src/services/run-store.ts +10 -4
  65. package/src/services/runs-page.ts +3 -2
  66. package/src/services/session-view.ts +18 -13
  67. package/src/services/sessions.ts +32 -16
  68. package/src/services/setup.ts +31 -10
  69. package/src/services/summary.ts +40 -5
  70. 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,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?.sessionId ?? null,
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(repo: string | null = null): Effects {
318
- 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);
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: () => doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
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, shown === undefined ? {} : { expect: shown });
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: { 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,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: () => ({}),
@@ -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);
@@ -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));
@@ -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
+ }