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,3 +1,4 @@
1
+ import { sessionKey, type HostSessionRef } from "../domain/host.ts";
1
2
  import { z } from "zod";
2
3
  import { sessionFileFor } from "../infra/claude-session.ts";
3
4
  import { withFileLock } from "../infra/filelock.ts";
@@ -6,27 +7,34 @@ import type { Deps } from "./ports.ts";
6
7
  import { type Run, runPaths } from "./run-store.ts";
7
8
  import { readNotes } from "./state.ts";
8
9
 
9
- /** Spec §4: a Claude Code session as catherd records it. */
10
- export interface SessionRef {
11
- sessionId: string;
12
- hostSessionId: string | null;
13
- name: string | null;
14
- }
10
+ /** Spec §4: a host session as catherd records it. */
11
+ export type SessionRef = HostSessionRef;
15
12
 
16
13
  /**
17
14
  * The session this process serves (spec §3.3), read live: its registry file's id and name when it has one (the
18
- * id in the environment goes stale after /clear), else the environment's. Null outside Claude Code.
15
+ * id in the environment goes stale after /clear), else the environment's. Null without known, consistent host identity.
19
16
  */
20
17
  export function currentSession(deps: Deps): SessionRef | null {
18
+ if (deps.host.host === "unknown" || deps.host.conflict) return null;
19
+ if (deps.host.host === "codex") return deps.host.session;
21
20
  const env = deps.session;
22
- if (!env) return null;
21
+ if (!env) return deps.host.session;
23
22
  const file = sessionFileFor(env);
24
23
  const sessionId = file?.sessionId ?? env.sessionId;
25
24
  if (!sessionId) return null;
26
- return { sessionId, hostSessionId: file?.hostSessionId ?? env.hostSessionId, name: file?.name ?? null };
25
+ return {
26
+ host: "claude-code",
27
+ sessionId,
28
+ hostSessionId: file?.hostSessionId ?? env.hostSessionId,
29
+ name: file?.name ?? null,
30
+ };
27
31
  }
28
32
 
29
- const OwnerSchema = z.object({ sessionId: z.string(), since: z.string() });
33
+ const OwnerSchema = z.looseObject({
34
+ host: z.enum(["claude-code", "codex"]).default("claude-code"),
35
+ sessionId: z.string(),
36
+ since: z.string(),
37
+ });
30
38
  export type Owner = z.infer<typeof OwnerSchema>;
31
39
 
32
40
  /** The run's owner session (state.json `owner`), or null for a run no session has owned (1.0, a terminal). */
@@ -38,10 +46,12 @@ export function runOwner(run: Run): Owner | null {
38
46
  /** Whether this process serves a session and that session owns the run now. */
39
47
  export function ownsRun(deps: Deps, run: Run): boolean {
40
48
  const me = currentSession(deps);
41
- return me !== null && runOwner(run)?.sessionId === me.sessionId;
49
+ const owner = runOwner(run);
50
+ return me !== null && owner !== null && sessionKey(owner) === sessionKey(me);
42
51
  }
43
52
 
44
53
  const SessionRowSchema = z.looseObject({
54
+ host: z.enum(["claude-code", "codex"]).default("claude-code"),
45
55
  sessionId: z.string(),
46
56
  hostSessionId: z.string().nullable(),
47
57
  name: z.string().nullable(),
@@ -59,8 +69,7 @@ export function readSessionRows(run: Run): SessionRow[] {
59
69
 
60
70
  /**
61
71
  * Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
62
- * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Outside
63
- * Claude Code it changes nothing. Returns whether the owner changed.
72
+ * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
64
73
  */
65
74
  export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
66
75
  const me = currentSession(deps);
@@ -70,6 +79,7 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
70
79
  const row = (since: string): void => {
71
80
  ensureJsonlHeader(p.sessions, "sessions");
72
81
  appendJsonl(p.sessions, {
82
+ host: me.host,
73
83
  sessionId: me.sessionId,
74
84
  hostSessionId: me.hostSessionId,
75
85
  name: me.name,
@@ -78,14 +88,20 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
78
88
  };
79
89
  // the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
80
90
  return withFileLock(p.stateJson, () => {
91
+ const live = currentSession(deps);
92
+ if (!live || sessionKey(live) !== sessionKey(me)) return false;
81
93
  const notes = readNotes(run);
82
94
  const owner = runOwner(run);
83
- if (owner?.sessionId === me.sessionId) {
95
+ if (owner && sessionKey(owner) === sessionKey(me)) {
84
96
  // a crash between the state.json write and the append left the trail without its owner: repaired here
85
- if (readSessionRows(run).at(-1)?.sessionId !== me.sessionId) row(owner.since);
97
+ const last = readSessionRows(run).at(-1);
98
+ if (!last || sessionKey(last) !== sessionKey(me)) row(owner.since);
86
99
  return false;
87
100
  }
88
- writeJsonAtomic(p.stateJson, { ...notes, owner: { sessionId: me.sessionId, since: at } });
101
+ writeJsonAtomic(p.stateJson, {
102
+ ...notes,
103
+ owner: { ...owner, host: me.host, sessionId: me.sessionId, since: at },
104
+ });
89
105
  row(at);
90
106
  return true;
91
107
  });
@@ -1,3 +1,6 @@
1
+ import { ADAPTER_IDS } from "../domain/ids.ts";
2
+ import { usedBackends } from "./doctor-backends.ts";
3
+ import type { HostContext } from "../domain/host.ts";
1
4
  import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
2
5
  import { basename, join } from "node:path";
3
6
  import type { Issue } from "../domain/profile-rules.ts";
@@ -5,7 +8,14 @@ import { configDir } from "../infra/paths.ts";
5
8
  import { type Refreshed, refreshDiscovery } from "./catalog-service.ts";
6
9
  import type { Synced } from "./agent-links.ts";
7
10
  import { activate, resetProfile, withProfilesLock } from "./profile-service.ts";
8
- import { configFile, profilesDir, projectsFile } from "./profile-store.ts";
11
+ import {
12
+ activeName,
13
+ configFile,
14
+ getProfile,
15
+ profilesDir,
16
+ projectsFile,
17
+ readProjects,
18
+ } from "./profile-store.ts";
9
19
  import { ensurePrivateDir } from "../infra/store.ts";
10
20
 
11
21
  /** True when profile `name` has a file (`default` exists as a name even before it has one). */
@@ -65,23 +75,34 @@ export interface InitResult {
65
75
  /**
66
76
  * Spec §8 `catherd init`, the setup half: moves 0.x files aside, writes the default profile (spec §7.2)
67
77
  * unless a 1.0 one exists and `overwrite` is not set, makes it active and links its agents, and lists
68
- * every backend's models. It never throws for a default profile that does not validate here: it writes
69
- * nothing, returns the errors, and activates the profile only when it has a file.
78
+ * backend models, skipping unused headless Claude on a Codex host. Defaults that do not validate return
79
+ * errors without a write; the profile becomes active only when it has a file.
70
80
  */
71
- export async function initSetup(
72
- o: { profile?: string; overwrite?: boolean; now?: Date } = {},
73
- ): Promise<InitResult> {
81
+ export async function initSetup(o: {
82
+ profile?: string;
83
+ repo?: string | null;
84
+ overwrite?: boolean;
85
+ now?: Date;
86
+ host: HostContext;
87
+ }): Promise<InitResult> {
74
88
  const moved = moveLegacy(o.now);
75
- const profile = o.profile ?? "default";
89
+ const profile = o.profile ?? activeName(o.repo);
76
90
  let created = false;
77
91
  let errors: Issue[] = [];
78
92
  if (!hasProfileFile(profile) || o.overwrite === true) {
79
- const saved = resetProfile(profile);
93
+ const saved = resetProfile(profile, o.host.host);
80
94
  created = saved.saved;
81
95
  errors = saved.errors;
82
96
  }
83
97
  const active = hasProfileFile(profile);
84
- const synced = active ? activate(profile) : null;
85
- const refreshed = await refreshDiscovery();
98
+ const repo =
99
+ o.profile === undefined && o.repo && readProjects().bindings[o.repo] !== undefined ? o.repo : null;
100
+ const synced = active ? activate(profile, repo, o.host.host) : null;
101
+ const used = usedBackends(active ? [getProfile(profile, o.host.host)] : []);
102
+ const backends =
103
+ o.host.host === "codex" && !used.has("claude-code")
104
+ ? ADAPTER_IDS.filter((id) => id !== "claude-code")
105
+ : undefined;
106
+ const refreshed = await refreshDiscovery({ backends });
86
107
  return { moved, profile, created, errors, active, synced, refreshed };
87
108
  }
@@ -1,3 +1,6 @@
1
+ import type { HostContext } from "../domain/host.ts";
2
+ import type { QueueCapability } from "../infra/codex-queue.ts";
3
+ import { inspectionHost, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
1
4
  import { resolve } from "node:path";
2
5
  import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
3
6
  import { isCatherdError } from "../domain/errors.ts";
@@ -7,7 +10,7 @@ import { nonBlankLines, readJsonl } from "../infra/store.ts";
7
10
  import { spendOf } from "./budget.ts";
8
11
  import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
9
12
  import { type OpenQuestion, openQuestions } from "./questions.ts";
10
- import { type DispatchState, liveDispatches } from "./dispatches.ts";
13
+ import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
11
14
  import { type RunSession, sessionFacts } from "./session-view.ts";
12
15
  import type { Deps } from "./ports.ts";
13
16
  import {
@@ -21,6 +24,7 @@ import {
21
24
  } from "./run-store.ts";
22
25
 
23
26
  export interface RunSummary {
27
+ delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
24
28
  id: string;
25
29
  /** spec 1.1 §8: the owner questions not answered yet, listed first */
26
30
  questions: OpenQuestion[];
@@ -51,17 +55,28 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
51
55
  const now = deps.now();
52
56
  const { records, corrupt } = readRecords(run);
53
57
  const live = liveDispatches(run, now);
58
+ const recorded = new Set(records.map((r) => r.dispatchId));
54
59
  const agents = readAgentRuns(run);
55
60
  const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
56
61
  const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
57
62
  let budget: BudgetStatus | null = null;
58
63
  const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
59
64
  try {
60
- budget = budgetStatus(spendOf(run, records, live, now), deps.profiles.forRepo(run.meta.repo).budget);
65
+ budget = budgetStatus(
66
+ spendOf(run, records, live, now),
67
+ deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
68
+ );
61
69
  } catch (e) {
62
70
  warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
63
71
  }
64
72
  return {
73
+ delivery: listDispatches(run).flatMap((d) =>
74
+ inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
75
+ name: d.admit.name,
76
+ dispatchId: d.admit.dispatchId,
77
+ ...inspection,
78
+ })),
79
+ ),
65
80
  id: run.id,
66
81
  questions: openQuestions(run),
67
82
  title: run.meta.title,
@@ -110,13 +125,33 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
110
125
  export function status(
111
126
  deps: Deps,
112
127
  runId?: string,
113
- ): { version: string; runs: RunSummary[]; warnings: string[] } {
114
- if (runId) return { version: deps.version, runs: [summarizeRun(deps, findRun(runId))], warnings: [] };
128
+ queue: QueueCapability | null = null,
129
+ ): {
130
+ version: string;
131
+ runs: RunSummary[];
132
+ warnings: string[];
133
+ host: HostContext;
134
+ queue: QueueCapability | null;
135
+ } {
136
+ if (runId)
137
+ return {
138
+ host: inspectionHost(deps.host),
139
+ queue,
140
+ version: deps.version,
141
+ runs: [summarizeRun(deps, findRun(runId))],
142
+ warnings: [],
143
+ };
115
144
  const { runs, corrupt } = listRuns();
116
145
  const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
117
146
  const all = runs.map((r) => summarizeRun(deps, r));
118
147
  const live = all.filter((s) => s.live.length > 0);
119
- return { version: deps.version, runs: live.length ? live : all.slice(0, 1), warnings };
148
+ return {
149
+ host: inspectionHost(deps.host),
150
+ queue,
151
+ version: deps.version,
152
+ runs: live.length ? live : all.slice(0, 1),
153
+ warnings,
154
+ };
120
155
  }
121
156
 
122
157
  export interface RungStats {
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import { type Dim, scoresOf, rungInfo } from "../domain/catalog.ts";
2
3
  import { CatherdError } from "../domain/errors.ts";
3
4
  import { tryParseRung } from "../domain/ids.ts";
@@ -21,7 +22,10 @@ export interface LeftOnStandIn {
21
22
  * inferred stand-in, or be left with no value at all, once the user's treat-likes for `removed` (canonical
22
23
  * rungs, or all of them) are gone. Never removed silently.
23
24
  */
24
- export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
25
+ export function leftOnStandIns(
26
+ removed: string[] | "all",
27
+ host: OrchestrationHost = "unknown",
28
+ ): LeftOnStandIn[] {
25
29
  const cur = readOverride();
26
30
  const treatLike = Object.fromEntries(
27
31
  Object.entries(cur.treatLike).filter(([r]) => removed !== "all" && !removed.includes(r)),
@@ -29,12 +33,18 @@ export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
29
33
  const gone = removed === "all" ? Object.keys(cur.treatLike) : removed;
30
34
  const c = loadCatalog({ timings: false, override: { ...cur, treatLike } });
31
35
  const out: LeftOnStandIn[] = [];
36
+ // an unknown host resolves no omitted architect/verifier rungs: name what either host would run
37
+ const hosts: OrchestrationHost[] = host === "unknown" ? ["claude-code", "codex"] : [host];
32
38
  for (const profile of listProfiles()) {
33
- const p = getProfile(profile);
34
- const rungs = new Set([
35
- ...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
36
- ...Object.values(p.failover),
37
- ]);
39
+ const rungs = new Set(
40
+ hosts.flatMap((h) => {
41
+ const p = getProfile(profile, h);
42
+ return [
43
+ ...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
44
+ ...Object.values(p.failover),
45
+ ];
46
+ }),
47
+ );
38
48
  for (const rung of rungs) {
39
49
  if (!tryParseRung(rung)) continue;
40
50
  const canonical = rungInfo(c, rung).canonical;
@@ -65,6 +75,7 @@ export function suggestFor(rung: string): { canonical: string; lacking: Dim[]; s
65
75
  */
66
76
  export async function clearTreatLike(
67
77
  rung: string,
78
+ host: OrchestrationHost = "unknown",
68
79
  ): Promise<{ rung: string; like: string; left: LeftOnStandIn[] }> {
69
80
  const canonical = canonicalRung(rung);
70
81
  const like = readOverride().treatLike[canonical];
@@ -72,13 +83,15 @@ export async function clearTreatLike(
72
83
  throw new CatherdError("E_INPUT_INVALID", `${canonical} has no treat-like of yours to clear`, {
73
84
  fix: "catherd catalog list shows each rung's treat-like; a shipped one is not yours to clear",
74
85
  });
75
- const left = leftOnStandIns([canonical]);
86
+ const left = leftOnStandIns([canonical], host);
76
87
  await removeTreatLikes([canonical]);
77
88
  return { rung: canonical, like, left };
78
89
  }
79
90
 
80
91
  /** Spec 1.2 §6.4 `--reset`: removes every treat-like of the user's, naming first the rungs left on a stand-in. */
81
- export async function resetTreatLikes(): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
82
- const left = leftOnStandIns("all");
92
+ export async function resetTreatLikes(
93
+ host: OrchestrationHost = "unknown",
94
+ ): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
95
+ const left = leftOnStandIns("all", host);
83
96
  return { removed: await removeTreatLikes("all"), left };
84
97
  }