catherd-cli 1.3.0 → 1.5.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 +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- 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 +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- 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 +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
package/src/entry/tui/state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../../domain/host.ts";
|
|
1
2
|
import {
|
|
2
3
|
applyPatch,
|
|
3
4
|
diffProfiles,
|
|
@@ -21,6 +22,7 @@ interface Snapshot {
|
|
|
21
22
|
|
|
22
23
|
/** Spec §9.2: edits are staged per profile and touch nothing on disk until a save. */
|
|
23
24
|
export interface Draft extends Snapshot {
|
|
25
|
+
host: OrchestrationHost;
|
|
24
26
|
name: string;
|
|
25
27
|
/** the document as it was read, which a save diffs against */
|
|
26
28
|
base: ProfileDoc;
|
|
@@ -118,18 +120,28 @@ 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;
|
|
124
127
|
drafts: Record<string, Draft>;
|
|
125
128
|
/** the open dialogs; only the top one is drawn and takes keys */
|
|
126
129
|
dialogs: Dialog[];
|
|
127
|
-
/**
|
|
128
|
-
|
|
130
|
+
/**
|
|
131
|
+
* spec §4: the session the Runs tab has open (`key` null: "earlier runs"); null shows the sessions. `run`: the
|
|
132
|
+
* run it was opened for (a recent run on the Status tab), whose first row the cursor starts on
|
|
133
|
+
*/
|
|
134
|
+
session: { key: string | null; run?: string } | null;
|
|
129
135
|
/** the role the open session shows, by its dispatch */
|
|
130
136
|
role: { run: string; dispatchId: string } | null;
|
|
131
137
|
/** spec 1.1 §10: the milestone the open session shows, its digest */
|
|
132
138
|
milestone: { run: string; name: string } | null;
|
|
139
|
+
/** where esc lands: the last session opened, and the last role or milestone opened in it */
|
|
140
|
+
back: {
|
|
141
|
+
session: string | null;
|
|
142
|
+
role: { run: string; dispatchId: string } | null;
|
|
143
|
+
milestone: { run: string; name: string } | null;
|
|
144
|
+
} | null;
|
|
133
145
|
paused: boolean;
|
|
134
146
|
armed: Armed | null;
|
|
135
147
|
}
|
|
@@ -153,8 +165,8 @@ export type Action =
|
|
|
153
165
|
| { type: "saving"; name: string; on: boolean }
|
|
154
166
|
| { type: "input"; value: string }
|
|
155
167
|
| { type: "invalid"; error: string | null }
|
|
156
|
-
/** opens a session of the Runs tab (`key` null: "earlier runs") */
|
|
157
|
-
| { type: "session"; key: string | null }
|
|
168
|
+
/** opens a session of the Runs tab (`key` null: "earlier runs"), on `run`'s first row when given */
|
|
169
|
+
| { type: "session"; key: string | null; run?: string }
|
|
158
170
|
/** opens one role of the open session */
|
|
159
171
|
| { type: "role"; run: string; dispatchId: string }
|
|
160
172
|
/** opens one milestone of the open session, on its digest */
|
|
@@ -165,7 +177,8 @@ export type Action =
|
|
|
165
177
|
| { type: "arm"; what: Armed["what"]; target: string; at: number }
|
|
166
178
|
| { type: "disarm" };
|
|
167
179
|
|
|
168
|
-
export const initialState = (tab: Tab = "status"): AppState => ({
|
|
180
|
+
export const initialState = (tab: Tab = "status", host: OrchestrationHost = "unknown"): AppState => ({
|
|
181
|
+
host,
|
|
169
182
|
tab,
|
|
170
183
|
profile: null,
|
|
171
184
|
drafts: {},
|
|
@@ -173,11 +186,13 @@ export const initialState = (tab: Tab = "status"): AppState => ({
|
|
|
173
186
|
session: null,
|
|
174
187
|
role: null,
|
|
175
188
|
milestone: null,
|
|
189
|
+
back: null,
|
|
176
190
|
paused: false,
|
|
177
191
|
armed: null,
|
|
178
192
|
});
|
|
179
193
|
|
|
180
|
-
const fresh = (name: string, doc: ProfileDoc): Draft => ({
|
|
194
|
+
const fresh = (name: string, doc: ProfileDoc, host: OrchestrationHost): Draft => ({
|
|
195
|
+
host,
|
|
181
196
|
name,
|
|
182
197
|
base: doc,
|
|
183
198
|
doc,
|
|
@@ -195,7 +210,10 @@ export const currentDraft = (s: AppState): Draft | null => (s.profile ? (s.draft
|
|
|
195
210
|
const isStaged = (d: Draft | undefined): boolean => d !== undefined && dirtyCount(d) > 0;
|
|
196
211
|
|
|
197
212
|
export function dirtyCount(d: Draft): number {
|
|
198
|
-
const fields = diffProfiles(
|
|
213
|
+
const fields = diffProfiles(
|
|
214
|
+
resolveProfile(d.base, d.name, d.host),
|
|
215
|
+
resolveProfile(d.doc, d.name, d.host),
|
|
216
|
+
).length;
|
|
199
217
|
return fields + Object.keys(d.treatLikes).length;
|
|
200
218
|
}
|
|
201
219
|
|
|
@@ -239,7 +257,9 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
239
257
|
tab: "profiles",
|
|
240
258
|
profile: a.name,
|
|
241
259
|
// Keep only a draft with staged changes; a clean one restarts from the file just read.
|
|
242
|
-
drafts: isStaged(s.drafts[a.name])
|
|
260
|
+
drafts: isStaged(s.drafts[a.name])
|
|
261
|
+
? s.drafts
|
|
262
|
+
: { ...s.drafts, [a.name]: fresh(a.name, a.doc, s.host) },
|
|
243
263
|
};
|
|
244
264
|
case "edit":
|
|
245
265
|
return withDraft(s, s.profile, (d) =>
|
|
@@ -302,13 +322,16 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
302
322
|
const d = s.drafts[a.name];
|
|
303
323
|
const from = a.from;
|
|
304
324
|
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) } };
|
|
325
|
+
return { ...s, drafts: { ...s.drafts, [a.name]: fresh(a.name, a.doc, s.host) } };
|
|
306
326
|
// edited while the save wrote: the saved file is the new base, and the newer edits stay staged over it
|
|
307
327
|
const treatLikes = Object.fromEntries(
|
|
308
328
|
Object.entries(d.treatLikes).filter(([rung, like]) => from.treatLikes[rung] !== like),
|
|
309
329
|
);
|
|
310
330
|
const doc = applyPatch(a.doc, patchBetween(from.doc, d.doc));
|
|
311
|
-
return {
|
|
331
|
+
return {
|
|
332
|
+
...s,
|
|
333
|
+
drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc, s.host), doc, treatLikes } },
|
|
334
|
+
};
|
|
312
335
|
}
|
|
313
336
|
case "forget": {
|
|
314
337
|
const { [a.name]: _gone, ...drafts } = s.drafts;
|
|
@@ -341,12 +364,29 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
341
364
|
else if (top.kind === "save" && a.type === "invalid") next = { ...top, error: a.error };
|
|
342
365
|
return next === top ? s : { ...s, dialogs: [...s.dialogs.slice(0, -1), next] };
|
|
343
366
|
}
|
|
344
|
-
case "session":
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
367
|
+
case "session": {
|
|
368
|
+
// a run asked for starts the cursor on that run; reopening the same session keeps the row esc lands on
|
|
369
|
+
const back =
|
|
370
|
+
!a.run && s.back?.session === a.key ? s.back : { session: a.key, role: null, milestone: null };
|
|
371
|
+
return {
|
|
372
|
+
...s,
|
|
373
|
+
session: { key: a.key, ...(a.run ? { run: a.run } : {}) },
|
|
374
|
+
role: null,
|
|
375
|
+
milestone: null,
|
|
376
|
+
back,
|
|
377
|
+
armed: null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
case "role": {
|
|
381
|
+
const role = { run: a.run, dispatchId: a.dispatchId };
|
|
382
|
+
const back = { session: s.session?.key ?? null, role, milestone: null };
|
|
383
|
+
return { ...s, role, milestone: null, back, armed: null };
|
|
384
|
+
}
|
|
385
|
+
case "milestone": {
|
|
386
|
+
const milestone = { run: a.run, name: a.name };
|
|
387
|
+
const back = { session: s.session?.key ?? null, role: null, milestone };
|
|
388
|
+
return { ...s, milestone, role: null, back, armed: null };
|
|
389
|
+
}
|
|
350
390
|
case "up":
|
|
351
391
|
if (s.milestone) return { ...s, milestone: null, armed: null };
|
|
352
392
|
return s.role ? { ...s, role: null, armed: null } : { ...s, session: null, armed: null };
|
package/src/entry/tui/stories.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
2
|
import type { ProfilePatch } from "../../domain/profile.ts";
|
|
2
3
|
import type { CommandId } from "./commands.ts";
|
|
3
4
|
import type { FixtureOptions } from "./fixtures.ts";
|
|
@@ -58,19 +59,25 @@ export const STORIES: Story[] = [
|
|
|
58
59
|
{
|
|
59
60
|
name: "session",
|
|
60
61
|
title: "Runs: one session with two runs, one continued elsewhere",
|
|
61
|
-
steps: [
|
|
62
|
+
steps: [
|
|
63
|
+
{ command: "tab.runs" },
|
|
64
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-auth" }) } },
|
|
65
|
+
],
|
|
62
66
|
},
|
|
63
67
|
{
|
|
64
68
|
name: "session-live",
|
|
65
69
|
title: "Runs: a live session with three live roles",
|
|
66
|
-
steps: [
|
|
70
|
+
steps: [
|
|
71
|
+
{ command: "tab.runs" },
|
|
72
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
73
|
+
],
|
|
67
74
|
},
|
|
68
75
|
{
|
|
69
76
|
name: "role",
|
|
70
77
|
title: "Runs: a role opened",
|
|
71
78
|
steps: [
|
|
72
79
|
{ command: "tab.runs" },
|
|
73
|
-
{ action: { type: "session", key: "s-jobs" } },
|
|
80
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
74
81
|
{ action: { type: "role", run: "20260926-114800-jobs-screen", dispatchId: "d1" } },
|
|
75
82
|
],
|
|
76
83
|
},
|
|
@@ -79,7 +86,7 @@ export const STORIES: Story[] = [
|
|
|
79
86
|
title: "Runs: a milestone opened on its digest",
|
|
80
87
|
steps: [
|
|
81
88
|
{ command: "tab.runs" },
|
|
82
|
-
{ action: { type: "session", key: "s-jobs" } },
|
|
89
|
+
{ action: { type: "session", key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }) } },
|
|
83
90
|
{ action: { type: "milestone", run: "20260926-114800-jobs-screen", name: "M0" } },
|
|
84
91
|
],
|
|
85
92
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
|
|
3
|
-
import { PROFILE_NAME, patchBetween, type ProfileDoc } from "../../../domain/profile.ts";
|
|
3
|
+
import { PROFILE_NAME, patchBetween, resolveProfile, type ProfileDoc } from "../../../domain/profile.ts";
|
|
4
4
|
import { CHANGED_ON_DISK } from "../effects.ts";
|
|
5
5
|
import { type AppApi, useApp, useDialogHandler } from "../providers/app.tsx";
|
|
6
6
|
import { type Data, useData } from "../providers/data.tsx";
|
|
@@ -16,7 +16,9 @@ const fail = (app: AppApi, e: unknown) => app.toast(errorToast(e));
|
|
|
16
16
|
*/
|
|
17
17
|
export function showProfile(app: AppApi, name: string, o: { quiet?: boolean } = {}): unknown {
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
const doc = app.effects.readProfile(name);
|
|
20
|
+
resolveProfile(doc, name, app.getState().host);
|
|
21
|
+
app.dispatch({ type: "show", name, doc });
|
|
20
22
|
return null;
|
|
21
23
|
} catch (e) {
|
|
22
24
|
if (!o.quiet) fail(app, e);
|
|
@@ -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) =>
|
|
@@ -267,7 +267,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
267
267
|
value: v.dim,
|
|
268
268
|
title: `${v.dim} ${v.value}`,
|
|
269
269
|
group: "values",
|
|
270
|
-
detail: `${v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
|
|
270
|
+
detail: `${v.lent === "treat-like" ? `like ${v.from}` : v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
|
|
271
271
|
})),
|
|
272
272
|
{
|
|
273
273
|
value: "runs",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type MutableRefObject, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { type MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { waitingLine } from "../../../services/orchestrator-wait.ts";
|
|
2
3
|
import { useApp, useBack, useNow } from "../providers/app.tsx";
|
|
3
4
|
import { useData, usePoll } from "../providers/data.tsx";
|
|
4
5
|
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
@@ -7,7 +8,7 @@ import { useUi } from "../providers/theme.tsx";
|
|
|
7
8
|
import { isArmed } from "../state.ts";
|
|
8
9
|
import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
|
|
9
10
|
import { glyph, mascot, type Token } from "../theme.ts";
|
|
10
|
-
import type { RoleRow, SessionRow, SessionRun } from "../effects.ts";
|
|
11
|
+
import type { RoleRow, SessionDetail, SessionRow, SessionRun } from "../effects.ts";
|
|
11
12
|
import { Line, type Part } from "../widgets/line.tsx";
|
|
12
13
|
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
13
14
|
|
|
@@ -166,6 +167,8 @@ function roleMark(r: RoleRow, plain: boolean): Part {
|
|
|
166
167
|
function runHeading(r: SessionRun, now: number, plain: boolean): Part[] {
|
|
167
168
|
const bits = [r.repo, `started ${ago(now - Date.parse(r.createdAt))}`];
|
|
168
169
|
if (r.budget !== null) bits.push(`budget ${Math.round(r.budget * 100)}%`);
|
|
170
|
+
const waiting = waitingLine(r.waiting, now);
|
|
171
|
+
if (waiting) bits.push(waiting);
|
|
169
172
|
const moved =
|
|
170
173
|
r.continued === "here" ? "continued here" : r.continuedIn ? `continued in ${r.continuedIn}` : null;
|
|
171
174
|
return [
|
|
@@ -181,8 +184,11 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
|
|
|
181
184
|
const app = useApp();
|
|
182
185
|
const [watching, setWatching] = useState(false);
|
|
183
186
|
const polled = usePoll(read, watching ? WATCHED_EVERY_MS : RUN_EVERY_MS, { paused: app.state.paused, key });
|
|
187
|
+
// the watch calls the latest refresh; the ref moves after each commit, never during a render
|
|
184
188
|
const refresh = useRef(polled.refresh);
|
|
185
|
-
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
refresh.current = polled.refresh;
|
|
191
|
+
});
|
|
186
192
|
const dirs = polled.value?.dirs.join("\n") ?? "";
|
|
187
193
|
useEffect(() => {
|
|
188
194
|
if (app.state.paused || dirs === "") {
|
|
@@ -196,8 +202,26 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
|
|
|
196
202
|
return { ...polled, watching };
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The row a session's screen starts on before the cursor moves: the first row of the run it was opened for,
|
|
207
|
+
* else its first live role (live roles can sit below many landed milestones), else its first row.
|
|
208
|
+
*/
|
|
209
|
+
function startRow(d: SessionDetail, run: string | undefined): string | null {
|
|
210
|
+
const rows = (r: SessionDetail["runs"][number]) => [
|
|
211
|
+
...r.milestones.map((m) => milestoneKey(r.id, m.name)),
|
|
212
|
+
...r.roles.map((x) => roleKey(x.run, x.dispatchId)),
|
|
213
|
+
];
|
|
214
|
+
const asked = run === undefined ? undefined : d.runs.find((r) => r.id === run);
|
|
215
|
+
const first = asked ? rows(asked)[0] : undefined;
|
|
216
|
+
if (first) return first;
|
|
217
|
+
const live = d.runs.flatMap((r) => r.roles).find((x) => x.live);
|
|
218
|
+
if (live) return roleKey(live.run, live.dispatchId);
|
|
219
|
+
return d.runs.flatMap(rows)[0] ?? null;
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
function SessionView(props: {
|
|
200
223
|
sessionKey: string | null;
|
|
224
|
+
run: string | undefined;
|
|
201
225
|
width: number;
|
|
202
226
|
height: number;
|
|
203
227
|
initial: string | null;
|
|
@@ -206,17 +230,26 @@ function SessionView(props: {
|
|
|
206
230
|
const app = useApp();
|
|
207
231
|
const ui = useUi();
|
|
208
232
|
const now = useNow(1_000);
|
|
209
|
-
const [
|
|
233
|
+
const [chosen, setSelected, chosenNow] = useSelection(props.initial);
|
|
210
234
|
const polled = useLiveRead(() => app.effects.session(props.sessionKey), String(props.sessionKey));
|
|
211
235
|
useRefresher(props.refresher, polled.refresh);
|
|
212
236
|
useBack(true, "view", () => app.dispatch({ type: "up" }));
|
|
213
237
|
const d = polled.value;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
// each row's role or milestone by its key, built once per read rather than on every key
|
|
239
|
+
const byKey = useMemo(() => {
|
|
240
|
+
const roles = new Map<string, RoleRow>();
|
|
241
|
+
const milestones = new Map<string, { run: string; name: string }>();
|
|
242
|
+
for (const r of d?.runs ?? []) {
|
|
243
|
+
for (const m of r.milestones) milestones.set(milestoneKey(r.id, m.name), { run: r.id, name: m.name });
|
|
244
|
+
for (const x of r.roles) roles.set(roleKey(x.run, x.dispatchId), x);
|
|
245
|
+
}
|
|
246
|
+
return { roles, milestones, start: d ? startRow(d, props.run) : null };
|
|
247
|
+
}, [d, props.run]);
|
|
248
|
+
// until the cursor moves, it stands on the start row
|
|
249
|
+
const selected = chosen ?? byKey.start;
|
|
250
|
+
const selectedNow = () => chosenNow() ?? byKey.start;
|
|
251
|
+
const roleAt = (key: string | null) => (key === null ? null : (byKey.roles.get(key) ?? null));
|
|
252
|
+
const milestoneAt = (key: string | null) => (key === null ? null : (byKey.milestones.get(key) ?? null));
|
|
220
253
|
useCommandLayer("row.runs", {
|
|
221
254
|
"runs.open": () => {
|
|
222
255
|
const k = selectedNow();
|
|
@@ -275,7 +308,7 @@ function SessionView(props: {
|
|
|
275
308
|
: { text: `${glyph("waiting", ui.plain)} ${m.name}`, tone: "muted" },
|
|
276
309
|
m.landed
|
|
277
310
|
? { text: m.what ? ` ${m.what}` : "", tone: "muted" }
|
|
278
|
-
: { text:
|
|
311
|
+
: { text: ` not landed${m.what ? ` · ${m.what}` : ""}`, tone: "muted" },
|
|
279
312
|
]}
|
|
280
313
|
/>
|
|
281
314
|
),
|
|
@@ -349,10 +382,17 @@ function RoleView(props: {
|
|
|
349
382
|
const app = useApp();
|
|
350
383
|
const ui = useUi();
|
|
351
384
|
const [selected, setSelected] = useSelection();
|
|
385
|
+
// a role with its record is finished for good: nothing on its screen changes, so it is read no more
|
|
386
|
+
// (r still reads it again)
|
|
387
|
+
const [done, setDone] = useState(false);
|
|
352
388
|
const polled = usePoll(() => app.effects.role(props.run, props.dispatchId), RUN_EVERY_MS, {
|
|
353
|
-
paused: app.state.paused,
|
|
389
|
+
paused: app.state.paused || done,
|
|
354
390
|
key: `${props.run}/${props.dispatchId}`,
|
|
355
391
|
});
|
|
392
|
+
const recorded = polled.value?.record != null;
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (recorded) setDone(true);
|
|
395
|
+
}, [recorded]);
|
|
356
396
|
useRefresher(props.refresher, polled.refresh);
|
|
357
397
|
useBack(true, "view", () => app.dispatch({ type: "up" }));
|
|
358
398
|
const d = polled.value;
|
|
@@ -532,13 +572,16 @@ export function RunsView(props: { width: number; height: number }) {
|
|
|
532
572
|
app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
|
|
533
573
|
},
|
|
534
574
|
});
|
|
535
|
-
const { milestone, role, session } = app.state;
|
|
575
|
+
const { milestone, role, session, back } = app.state;
|
|
536
576
|
// esc lands on the row it came from: the last session opened, and the last milestone or role opened in it
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
577
|
+
const backRow =
|
|
578
|
+
back && session && back.session === session.key
|
|
579
|
+
? back.milestone
|
|
580
|
+
? milestoneKey(back.milestone.run, back.milestone.name)
|
|
581
|
+
: back.role
|
|
582
|
+
? roleKey(back.role.run, back.role.dispatchId)
|
|
583
|
+
: null
|
|
584
|
+
: null;
|
|
542
585
|
if (milestone)
|
|
543
586
|
return (
|
|
544
587
|
<MilestoneView
|
|
@@ -563,11 +606,14 @@ export function RunsView(props: { width: number; height: number }) {
|
|
|
563
606
|
return (
|
|
564
607
|
<SessionView
|
|
565
608
|
sessionKey={session.key}
|
|
609
|
+
run={session.run}
|
|
566
610
|
width={props.width}
|
|
567
611
|
height={props.height}
|
|
568
|
-
initial={
|
|
612
|
+
initial={backRow}
|
|
569
613
|
refresher={refresher}
|
|
570
614
|
/>
|
|
571
615
|
);
|
|
572
|
-
return
|
|
616
|
+
return (
|
|
617
|
+
<SessionList width={props.width} height={props.height} initial={back ? keyOf(back.session) : null} />
|
|
618
|
+
);
|
|
573
619
|
}
|
|
@@ -48,11 +48,11 @@ export interface SavePreview {
|
|
|
48
48
|
*/
|
|
49
49
|
function previewSave(
|
|
50
50
|
d: Draft,
|
|
51
|
-
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
|
|
51
|
+
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile" | "profiles">,
|
|
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));
|
|
@@ -62,7 +62,11 @@ function previewSave(
|
|
|
62
62
|
changes: diffProfiles(before, after),
|
|
63
63
|
treatLikes: Object.entries(d.treatLikes),
|
|
64
64
|
validation,
|
|
65
|
-
|
|
65
|
+
// as the ProfileService rules: a profile with no stored file has no errors a save could repair (1.2 minor)
|
|
66
|
+
repair:
|
|
67
|
+
validation.errors.length > 0 &&
|
|
68
|
+
fx.profiles().names.includes(d.name) &&
|
|
69
|
+
repairs(fx.validate(before, staged), validation),
|
|
66
70
|
agentsAdded: [...b].filter((x) => !a.has(x)),
|
|
67
71
|
agentsRemoved: [...a].filter((x) => !b.has(x)),
|
|
68
72
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Check } from "../../../services/doctor-checks.ts";
|
|
2
|
+
import { waitingLine } from "../../../services/orchestrator-wait.ts";
|
|
2
3
|
import { useApp, useNow } from "../providers/app.tsx";
|
|
3
4
|
import { useData } from "../providers/data.tsx";
|
|
4
5
|
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
@@ -19,6 +20,8 @@ function stateParts(state: Check["state"], word: string, plain: boolean): Part[]
|
|
|
19
20
|
/** A run in one line: its state word first, then title, repo and progress (research C4). */
|
|
20
21
|
export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
|
|
21
22
|
const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
|
|
23
|
+
const waiting = waitingLine(r.waiting, now);
|
|
24
|
+
if (waiting) bits.push(waiting);
|
|
22
25
|
if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
|
|
23
26
|
return [
|
|
24
27
|
r.live
|
|
@@ -189,10 +192,11 @@ export function StatusView(props: { width: number; height: number }) {
|
|
|
189
192
|
if (name !== null) {
|
|
190
193
|
showProfile(app, name);
|
|
191
194
|
} else if (selected?.startsWith("run:")) {
|
|
192
|
-
// spec §4: a run opens in its session's screen, which shows it with the session's other runs
|
|
195
|
+
// spec §4: a run opens in its session's screen, which shows it with the session's other runs; the
|
|
196
|
+
// cursor starts on that run
|
|
193
197
|
const row = runs.find((r) => `run:${r.id}` === selected);
|
|
194
198
|
app.dispatch({ type: "tab", tab: "runs" });
|
|
195
|
-
if (row) app.dispatch({ type: "session", key: row.session });
|
|
199
|
+
if (row) app.dispatch({ type: "session", key: row.session, run: row.id });
|
|
196
200
|
}
|
|
197
201
|
},
|
|
198
202
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { inspectWorkspace, workspaceStatus } from "../services/workspace-service.ts";
|
|
4
|
+
import { JSON_ARG, printJson } from "./cli-kit.ts";
|
|
5
|
+
import { defaultDeps } from "./deps.ts";
|
|
6
|
+
|
|
7
|
+
const inspect = defineCommand({
|
|
8
|
+
meta: { name: "inspect", description: "Read the explicit repositories in a workspace manifest" },
|
|
9
|
+
args: {
|
|
10
|
+
root: { type: "positional", description: "workspace root (default: current directory)" },
|
|
11
|
+
...JSON_ARG,
|
|
12
|
+
},
|
|
13
|
+
async run({ args }) {
|
|
14
|
+
const view = await inspectWorkspace(resolve(args.root ?? process.cwd()));
|
|
15
|
+
if (args.json) return printJson(view);
|
|
16
|
+
console.log(view.root);
|
|
17
|
+
for (const [name, path] of Object.entries(view.repos)) console.log(`${name}: ${path}`);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const status = defineCommand({
|
|
22
|
+
meta: { name: "status", description: "Read a workspace run's children, dependencies and budget" },
|
|
23
|
+
args: {
|
|
24
|
+
workspace: { type: "positional", required: true, description: "workspace run id" },
|
|
25
|
+
...JSON_ARG,
|
|
26
|
+
},
|
|
27
|
+
async run({ args }) {
|
|
28
|
+
const view = await workspaceStatus(defaultDeps(), args.workspace);
|
|
29
|
+
if (args.json) return printJson(view);
|
|
30
|
+
console.log(`${view.workspace.id}: ${view.workspace.title}`);
|
|
31
|
+
for (const step of view.steps) {
|
|
32
|
+
const blocked = step.blockedBy.length ? ` (waiting for ${step.blockedBy.join(", ")})` : "";
|
|
33
|
+
console.log(`${step.id}: ${step.repo} ${step.state}${step.run ? ` ${step.run}` : ""}${blocked}`);
|
|
34
|
+
}
|
|
35
|
+
console.log(
|
|
36
|
+
`spend: ${view.spend.tokens} tokens, ${view.spend.minutes.toFixed(2)} min, $${view.spend.usd.toFixed(2)}`,
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const workspaceCommand = defineCommand({
|
|
42
|
+
meta: { name: "workspace", description: "Inspect workspace members and coordinated runs" },
|
|
43
|
+
subCommands: { inspect, status },
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { tryLock } from "./filelock.ts";
|
|
3
|
+
import { log } from "./log.ts";
|
|
4
|
+
import { locksDir } from "./paths.ts";
|
|
5
|
+
import { ensurePrivateDir } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
/** The machine-wide lock the MCP server that runs the boot sync and reconcile holds for its life. */
|
|
8
|
+
export const bootLockTarget = (): string => join(locksDir(), "mcp-boot");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plan 22 (the three MCP servers of one Codex session): boot sync and reconcile are single-flight across
|
|
12
|
+
* processes. The first server takes this lock (its pid and start time in it; a dead holder's is reclaimed) and
|
|
13
|
+
* keeps it until it closes; a second server finds it held and skips both. Returns the release, or null while
|
|
14
|
+
* another live server holds it. A lock that cannot be taken for another reason never costs a reconcile: the
|
|
15
|
+
* server runs the boot work, with nothing to release.
|
|
16
|
+
*/
|
|
17
|
+
export function takeBootLock(): (() => void) | null {
|
|
18
|
+
try {
|
|
19
|
+
ensurePrivateDir(locksDir());
|
|
20
|
+
return tryLock(bootLockTarget());
|
|
21
|
+
} catch (e) {
|
|
22
|
+
log("warn", "boot", { lock: bootLockTarget(), error: e instanceof Error ? e.message : String(e) });
|
|
23
|
+
return () => {};
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/infra/cli.ts
ADDED
|
@@ -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
|
+
}
|