catherd-cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -36
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +44 -38
- package/plugin/skills/catherd-setup/SKILL.md +22 -6
- package/src/adapters/access.ts +7 -1
- package/src/adapters/cli.ts +1 -64
- package/src/cli.ts +48 -19
- package/src/domain/host.ts +17 -0
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +32 -5
- package/src/domain/record.ts +1 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +19 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +2 -2
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +7 -3
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +6 -1
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +105 -33
- package/src/services/finalize.ts +1 -1
- package/src/services/lane-service.ts +1 -0
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +116 -30
- package/src/services/profile-store.ts +23 -6
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +2 -0
- package/src/services/run-store.ts +10 -4
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
package/src/services/sessions.ts
CHANGED
|
@@ -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
|
|
10
|
-
export
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
});
|
package/src/services/setup.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
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 ??
|
|
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
|
|
85
|
-
|
|
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
|
}
|
package/src/services/summary.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
114
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
82
|
-
|
|
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
|
}
|