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.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -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
- /** spec §4: the session the Runs tab has open (`key` null: "earlier runs"); null shows the sessions */
128
- session: { key: string | null } | null;
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(resolveProfile(d.base, d.name), resolveProfile(d.doc, d.name)).length;
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]) ? s.drafts : { ...s.drafts, [a.name]: fresh(a.name, a.doc) },
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 { ...s, drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc), doc, treatLikes } } };
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
- return { ...s, session: { key: a.key }, role: null, milestone: null, armed: null };
346
- case "role":
347
- return { ...s, role: { run: a.run, dispatchId: a.dispatchId }, milestone: null, armed: null };
348
- case "milestone":
349
- return { ...s, milestone: { run: a.run, name: a.name }, role: null, armed: null };
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 };
@@ -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) =>
@@ -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
- refresh.current = polled.refresh;
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 [selected, setSelected, selectedNow] = useSelection(props.initial);
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
- const roleAt = (key: string | null) =>
215
- d?.runs.flatMap((r) => r.roles).find((x) => key === roleKey(x.run, x.dispatchId)) ?? null;
216
- const milestoneAt = (key: string | null) =>
217
- d?.runs
218
- .flatMap((r) => r.milestones.map((m) => ({ run: r.id, name: m.name })))
219
- .find((x) => key === milestoneKey(x.run, x.name)) ?? null;
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: " not landed", tone: "muted" },
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 back = useRef<{ session: string | null; row: string | null }>({ session: null, row: null });
538
- if (session && back.current.session !== keyOf(session.key))
539
- back.current = { session: keyOf(session.key), row: null };
540
- if (milestone) back.current.row = milestoneKey(milestone.run, milestone.name);
541
- if (role) back.current.row = roleKey(role.run, role.dispatchId);
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={back.current.row}
612
+ initial={backRow}
569
613
  refresher={refresher}
570
614
  />
571
615
  );
572
- return <SessionList width={props.width} height={props.height} initial={back.current.session} />;
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
- repair: validation.errors.length > 0 && repairs(fx.validate(before, staged), validation),
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
+ }
@@ -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
+ }