catherd-cli 1.0.0 → 1.1.1
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 +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +118 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
4
|
+
import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
|
|
5
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
6
|
+
import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
8
|
+
import { log } from "../infra/log.ts";
|
|
9
|
+
import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
|
|
10
|
+
import { writeJsonAtomic } from "../infra/store.ts";
|
|
11
|
+
import { type Settled, settledHooks, type Stalled, stallHooks } from "./dispatch-service.ts";
|
|
12
|
+
import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
|
|
13
|
+
import type { Deps } from "./ports.ts";
|
|
14
|
+
import { listRuns, readRecords, type Run } from "./run-store.ts";
|
|
15
|
+
import { currentSession, ownsRun } from "./sessions.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Spec §3.4: runs only inside the MCP server, the session's child and so its only sender (§3.2). For every run this
|
|
19
|
+
* session owns, a dispatch that is settled with its record still unread is announced to the session's peer inbox;
|
|
20
|
+
* notices that arrive within the window of each other go as one message; a message that went out is written down
|
|
21
|
+
* (`notified.json`), so a restart never sends it twice, and claimed while it goes, so two servers of one session never
|
|
22
|
+
* both send it. Ownership is asked again under the claim and once more after the message went: a run another session
|
|
23
|
+
* claimed meanwhile is left unmarked, so its new owner announces it too (a copy to the session that gave the run up is
|
|
24
|
+
* acceptable, a notice lost to the owner is not); a notice whose claim another server holds is tried again after the
|
|
25
|
+
* window until it is sent, read, marked or no longer this session's. Disk stays the truth: a notice that cannot be
|
|
26
|
+
* sent is dropped (its claim freed), and the record waits, unread, for `result` or `peek`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface NotifierOptions {
|
|
30
|
+
/** notices this close to each other go as one message (spec: 3 s); tests shorten it */
|
|
31
|
+
coalesceMs?: number;
|
|
32
|
+
/** the sender; tests may replace it */
|
|
33
|
+
send?: (
|
|
34
|
+
target: { socketPath: string | null; token: string | null },
|
|
35
|
+
content: string,
|
|
36
|
+
p: NoticePriority,
|
|
37
|
+
) => Promise<SendResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Notifier {
|
|
41
|
+
/** the settled hook: queues the dispatch's notice when this session should hear of it */
|
|
42
|
+
onSettled(s: Settled): void;
|
|
43
|
+
/** the stall hook: queues a stalled role's notice, once per dispatch */
|
|
44
|
+
onStall(s: Stalled): void;
|
|
45
|
+
/** spec §3.4 "a scan on start": every unread, un-notified record of a run this session owns */
|
|
46
|
+
scan(): Promise<void>;
|
|
47
|
+
/** resolves once nothing is queued and no message is on its way */
|
|
48
|
+
idle(): Promise<void>;
|
|
49
|
+
/** removes the hook and drops what is queued */
|
|
50
|
+
stop(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const notified = (dir: string): boolean => existsSync(dispatchPaths(dir).notified);
|
|
54
|
+
|
|
55
|
+
function replyOf(run: Run, r: RunRecord): string {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(join(run.dir, r.replyPath), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Spec §3.5/§3.6: one finished role's notice, with what failover did in place of a limit's status. */
|
|
64
|
+
export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
|
|
65
|
+
const fo = readFailover(d.dir);
|
|
66
|
+
let status: string = r.status;
|
|
67
|
+
if (r.status === "limit") {
|
|
68
|
+
const why = fo?.standIn
|
|
69
|
+
? `failed over to ${fo.standIn.rung}`
|
|
70
|
+
: fo?.hints.some((h) => h.startsWith("failover: run "))
|
|
71
|
+
? "its stand-in is a Claude agent: result(run, name) has the hint"
|
|
72
|
+
: fo?.hints.some((h) => h.startsWith("failover: "))
|
|
73
|
+
? "paused: the stand-in was refused"
|
|
74
|
+
: "paused: no stand-in";
|
|
75
|
+
status = `limit on ${r.rung}; ${why}`;
|
|
76
|
+
}
|
|
77
|
+
const urgent = r.status === "limit" || r.replyStatus === "blocked" || r.replyStatus === "refused";
|
|
78
|
+
return {
|
|
79
|
+
kind: "finished",
|
|
80
|
+
runId: run.id,
|
|
81
|
+
runTitle: run.meta.title,
|
|
82
|
+
dispatchId: r.dispatchId,
|
|
83
|
+
name: r.name,
|
|
84
|
+
role: r.role,
|
|
85
|
+
lane: r.lane,
|
|
86
|
+
rung: r.rung,
|
|
87
|
+
status,
|
|
88
|
+
replyStatus: r.replyStatus,
|
|
89
|
+
secs: r.secs,
|
|
90
|
+
changedOwned: r.changedOwned.length,
|
|
91
|
+
reply: replyOf(run, r),
|
|
92
|
+
priority: urgent ? "next" : "later",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Spec §3.6: a live role that went quiet, once, at `next`. */
|
|
97
|
+
export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
|
|
98
|
+
return {
|
|
99
|
+
kind: "stalled",
|
|
100
|
+
runId: run.id,
|
|
101
|
+
runTitle: run.meta.title,
|
|
102
|
+
dispatchId: d.admit.dispatchId,
|
|
103
|
+
name: d.admit.name,
|
|
104
|
+
role: d.admit.role,
|
|
105
|
+
lane: d.admit.lane,
|
|
106
|
+
rung: d.admit.rung,
|
|
107
|
+
status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
|
|
108
|
+
replyStatus: null,
|
|
109
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
110
|
+
changedOwned: 0,
|
|
111
|
+
reply: "",
|
|
112
|
+
priority: "next",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Queued {
|
|
117
|
+
/** its key in the queue */
|
|
118
|
+
key: string;
|
|
119
|
+
/** the run it is about: its owner may change before the message goes */
|
|
120
|
+
run: Run;
|
|
121
|
+
notice: Notice;
|
|
122
|
+
/** the mark a message that went out leaves */
|
|
123
|
+
mark: string;
|
|
124
|
+
/** whether it is still news when the message goes */
|
|
125
|
+
due: () => boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Starts the notifier for this process's session and hooks it to every settled dispatch. */
|
|
129
|
+
export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
|
|
130
|
+
const window = o.coalesceMs ?? 3_000;
|
|
131
|
+
const send = o.send ?? sendToInbox;
|
|
132
|
+
const queue = new Map<string, Queued>();
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
134
|
+
let firstAt = 0;
|
|
135
|
+
let flight: Promise<void> = Promise.resolve();
|
|
136
|
+
let stopped = false;
|
|
137
|
+
|
|
138
|
+
/** This session's id, when there is one and it owns the run. */
|
|
139
|
+
const owned = (run: Run): boolean => ownsRun(deps, run);
|
|
140
|
+
|
|
141
|
+
const flush = (): void => {
|
|
142
|
+
timer = null;
|
|
143
|
+
const batch = [...queue.values()];
|
|
144
|
+
queue.clear();
|
|
145
|
+
// a delivery that throws (the notified.json write, say) is logged and never stops the ones after it
|
|
146
|
+
flight = flight
|
|
147
|
+
.then(() => deliver(batch))
|
|
148
|
+
.catch((e: unknown) =>
|
|
149
|
+
log("warn", "notify", { error: errorMessage(e), dispatches: batch.map((q) => q.notice.dispatchId) }),
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const schedule = (): void => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (timer === null) firstAt = now;
|
|
156
|
+
else clearTimeout(timer);
|
|
157
|
+
// quiet for `window`, but never held past five windows: a steady trickle still goes out
|
|
158
|
+
timer = setTimeout(flush, Math.max(0, Math.min(window, firstAt + 5 * window - now)));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
async function deliver(batch: Queued[]): Promise<void> {
|
|
162
|
+
if (!deps.session) return;
|
|
163
|
+
// Each notice is claimed before it goes (an exclusive lock on its mark, freed if its holder dies): two servers
|
|
164
|
+
// of one session, a replacement starting while the old one still watches, never both send it. Whether it is
|
|
165
|
+
// still news is asked under the claim: the other may have announced it just before letting go.
|
|
166
|
+
const claims: (() => void)[] = [];
|
|
167
|
+
const due: Queued[] = [];
|
|
168
|
+
const busy: Queued[] = [];
|
|
169
|
+
try {
|
|
170
|
+
for (const q of batch) {
|
|
171
|
+
// read (or announced) meanwhile, or the run moved to another session (whose server tells it)
|
|
172
|
+
if (!q.due() || !owned(q.run)) continue;
|
|
173
|
+
const release = tryLock(q.mark);
|
|
174
|
+
// another server is announcing it: asked again after the window, since that server may leave it unmarked
|
|
175
|
+
// (the run moved to this session while its message was on its way)
|
|
176
|
+
if (!release) {
|
|
177
|
+
busy.push(q);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
claims.push(release);
|
|
181
|
+
// asked again under the claim: the run may have moved, or the notice gone out, while it was taken
|
|
182
|
+
if (q.due() && owned(q.run)) due.push(q);
|
|
183
|
+
}
|
|
184
|
+
if (due.length === 0) return;
|
|
185
|
+
const notices = due.map((q) => q.notice);
|
|
186
|
+
const r = await send(
|
|
187
|
+
{ socketPath: deps.session.socketPath, token: deps.session.token },
|
|
188
|
+
envelope(formatNotices(notices)),
|
|
189
|
+
priorityOf(notices),
|
|
190
|
+
).catch((e: unknown): SendResult => ({ outcome: "error", reason: errorMessage(e) }));
|
|
191
|
+
if (r.outcome !== "sent") {
|
|
192
|
+
log("warn", "notify", {
|
|
193
|
+
outcome: r.outcome,
|
|
194
|
+
reason: r.reason,
|
|
195
|
+
dispatches: notices.map((n) => n.dispatchId),
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const at = new Date(deps.now()).toISOString();
|
|
200
|
+
// a run that moved while the message was on its way is left unmarked: its new owner announces it too
|
|
201
|
+
const marked = due.filter((q) => owned(q.run));
|
|
202
|
+
for (const q of marked) writeJsonAtomic(q.mark, { schema: 1, msgId: r.msgId, at });
|
|
203
|
+
const unmarked = due.filter((q) => !marked.includes(q)).map((q) => q.notice.dispatchId);
|
|
204
|
+
log("info", "notify", { msgId: r.msgId, dispatches: notices.map((n) => n.dispatchId), unmarked });
|
|
205
|
+
} finally {
|
|
206
|
+
for (const release of claims) release();
|
|
207
|
+
requeue(busy);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Queues again the notices another server held the claim on; the next pass drops those no longer due or owned. */
|
|
212
|
+
function requeue(busy: Queued[]): void {
|
|
213
|
+
if (stopped || busy.length === 0) return;
|
|
214
|
+
for (const q of busy) if (!queue.has(q.key)) queue.set(q.key, q);
|
|
215
|
+
schedule();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const enqueue = (run: Run, d: Dispatch, record: RunRecord): void => {
|
|
219
|
+
if (queue.has(record.dispatchId) || notified(d.dir) || !awaitsCollect(d.dir) || !owned(run)) return;
|
|
220
|
+
queue.set(record.dispatchId, {
|
|
221
|
+
key: record.dispatchId,
|
|
222
|
+
run,
|
|
223
|
+
notice: finishedNotice(run, d, record),
|
|
224
|
+
mark: dispatchPaths(d.dir).notified,
|
|
225
|
+
due: () => awaitsCollect(d.dir) && !notified(d.dir),
|
|
226
|
+
});
|
|
227
|
+
schedule();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const onStall = (s: Stalled): void => {
|
|
231
|
+
try {
|
|
232
|
+
const p = dispatchPaths(s.d.dir);
|
|
233
|
+
const key = `${s.d.admit.dispatchId} stall`;
|
|
234
|
+
if (queue.has(key) || existsSync(p.stallNotified) || existsSync(p.exit) || !owned(s.run)) return;
|
|
235
|
+
queue.set(key, {
|
|
236
|
+
key,
|
|
237
|
+
run: s.run,
|
|
238
|
+
notice: stalledNotice(s.run, s.d, s.quietMs, deps.now()),
|
|
239
|
+
mark: p.stallNotified,
|
|
240
|
+
// it finished meanwhile: its record is the news now
|
|
241
|
+
due: () => !existsSync(p.stallNotified) && !existsSync(p.exit),
|
|
242
|
+
});
|
|
243
|
+
schedule();
|
|
244
|
+
} catch (e) {
|
|
245
|
+
log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const onSettled = (s: Settled): void => {
|
|
250
|
+
try {
|
|
251
|
+
enqueue(s.run, s.d, s.record);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const n: Notifier = {
|
|
258
|
+
onSettled,
|
|
259
|
+
onStall,
|
|
260
|
+
async scan() {
|
|
261
|
+
if (!currentSession(deps)) return;
|
|
262
|
+
for (const run of listRuns().runs) {
|
|
263
|
+
try {
|
|
264
|
+
if (!owned(run)) continue;
|
|
265
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
266
|
+
for (const d of listDispatches(run)) {
|
|
267
|
+
const r = records.get(d.admit.dispatchId);
|
|
268
|
+
// a limit not yet failed over is reconcile's to settle first: its hook announces it then
|
|
269
|
+
if (r && !(r.status === "limit" && !readFailover(d.dir))) enqueue(run, d, r);
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
log("warn", "notify", { run: run.id, error: errorMessage(e) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async idle() {
|
|
277
|
+
for (;;) {
|
|
278
|
+
if (timer === null && queue.size === 0) {
|
|
279
|
+
await flight;
|
|
280
|
+
if (timer === null && queue.size === 0) return;
|
|
281
|
+
} else await Bun.sleep(5);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
stop() {
|
|
285
|
+
stopped = true;
|
|
286
|
+
settledHooks.delete(onSettled);
|
|
287
|
+
stallHooks.delete(onStall);
|
|
288
|
+
if (timer !== null) clearTimeout(timer);
|
|
289
|
+
timer = null;
|
|
290
|
+
queue.clear();
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
settledHooks.add(onSettled);
|
|
294
|
+
stallHooks.add(onStall);
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { assertId } from "../domain/ids.ts";
|
|
2
|
+
import { noticeHeader } from "../domain/notice.ts";
|
|
3
|
+
import { awaitsCollect } from "../infra/dispatch-dir.ts";
|
|
4
|
+
import { claim } from "./dispatch-service.ts";
|
|
5
|
+
import { type Dispatch, type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
6
|
+
import { lastActivity } from "./finalize.ts";
|
|
7
|
+
import { finishedNotice } from "./notifier.ts";
|
|
8
|
+
import type { Deps } from "./ports.ts";
|
|
9
|
+
import { type Reentry, reentry } from "./reentry.ts";
|
|
10
|
+
import { findRun, listRuns, readAgentRuns, readRecords, type Run } from "./run-store.ts";
|
|
11
|
+
import { currentSession, runOwner } from "./sessions.ts";
|
|
12
|
+
import { readNotes } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
export interface PeekRole {
|
|
15
|
+
name: string;
|
|
16
|
+
role: string;
|
|
17
|
+
rung: string;
|
|
18
|
+
state: DispatchState;
|
|
19
|
+
/** since admission */
|
|
20
|
+
secs: number;
|
|
21
|
+
/** the last command, file edit or message line, else the last event's name; null before any event */
|
|
22
|
+
lastEvent: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Spec 1.1 §8/§10: the open owner questions come first, then the run, its protocol step and the verifier's step. */
|
|
26
|
+
export interface PeekRun extends Reentry {
|
|
27
|
+
run: string;
|
|
28
|
+
title: string;
|
|
29
|
+
/** the session that owns the run now, null when none has */
|
|
30
|
+
owner: string | null;
|
|
31
|
+
live: PeekRole[];
|
|
32
|
+
/** each finished record not yet read, as the first line of its message */
|
|
33
|
+
unread: { name: string; dispatchId: string; header: string }[];
|
|
34
|
+
/** the latest native Claude run recorded with record_agent_run */
|
|
35
|
+
native: { name: string; role: string; rung: string; status: string; at: string } | null;
|
|
36
|
+
/** the run's next step (state.md's last line) */
|
|
37
|
+
next: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
41
|
+
const now = deps.now();
|
|
42
|
+
const mine = (d: Dispatch) => name === undefined || d.admit.name === name;
|
|
43
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
44
|
+
const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
|
|
45
|
+
const native = agents.at(-1);
|
|
46
|
+
const r = reentry(run, now);
|
|
47
|
+
return {
|
|
48
|
+
questions: r.questions,
|
|
49
|
+
run: run.id,
|
|
50
|
+
title: run.meta.title,
|
|
51
|
+
owner: runOwner(run)?.sessionId ?? null,
|
|
52
|
+
live: liveDispatches(run, now)
|
|
53
|
+
.filter(mine)
|
|
54
|
+
.map((d) => ({
|
|
55
|
+
name: d.admit.name,
|
|
56
|
+
role: d.admit.role,
|
|
57
|
+
rung: d.admit.rung,
|
|
58
|
+
state: d.state,
|
|
59
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
60
|
+
lastEvent: lastActivity(d),
|
|
61
|
+
})),
|
|
62
|
+
unread: listDispatches(run)
|
|
63
|
+
.filter(mine)
|
|
64
|
+
.flatMap((d) => {
|
|
65
|
+
const r = records.get(d.admit.dispatchId);
|
|
66
|
+
return r && awaitsCollect(d.dir)
|
|
67
|
+
? [{ name: r.name, dispatchId: r.dispatchId, header: noticeHeader(finishedNotice(run, d, r)) }]
|
|
68
|
+
: [];
|
|
69
|
+
}),
|
|
70
|
+
native: native
|
|
71
|
+
? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
|
|
72
|
+
: null,
|
|
73
|
+
next: readNotes(run).next,
|
|
74
|
+
protocol: r.protocol,
|
|
75
|
+
verifier: r.verifier,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spec §3.7 `peek(run?, name?)`: never waits, never marks a record read. With a run it makes this session the
|
|
81
|
+
* run's owner (spec §3.3) and watches the roles another session's server launched; without one it shows every run
|
|
82
|
+
* this session owns, else the newest run.
|
|
83
|
+
*/
|
|
84
|
+
export async function peek(
|
|
85
|
+
deps: Deps,
|
|
86
|
+
i: { run?: string; name?: string },
|
|
87
|
+
): Promise<{ runs: PeekRun[]; hints: string[] }> {
|
|
88
|
+
if (i.name !== undefined) assertId("role name", i.name);
|
|
89
|
+
let runs: Run[];
|
|
90
|
+
if (i.run) {
|
|
91
|
+
const run = findRun(i.run);
|
|
92
|
+
// ownership now; what the run's earlier owner left is taken care of after the call returns
|
|
93
|
+
await claim(deps, run, { background: true });
|
|
94
|
+
runs = [run];
|
|
95
|
+
} else {
|
|
96
|
+
const all = listRuns().runs;
|
|
97
|
+
const me = currentSession(deps);
|
|
98
|
+
const owned = me ? all.filter((r) => runOwner(r)?.sessionId === me.sessionId) : [];
|
|
99
|
+
runs = owned.length ? owned : all.slice(0, 1);
|
|
100
|
+
}
|
|
101
|
+
const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
|
|
102
|
+
return { runs: runs.map((r) => peekRun(deps, r, i.name)), hints };
|
|
103
|
+
}
|
package/src/services/ports.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Budget } from "../domain/budget.ts";
|
|
2
|
+
import type { SessionEnv } from "../infra/claude-session.ts";
|
|
2
3
|
import type { BillingMode } from "../domain/cost.ts";
|
|
3
4
|
import type { Verdict } from "../domain/jev.ts";
|
|
4
5
|
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
@@ -13,7 +14,9 @@ export interface ProfileView {
|
|
|
13
14
|
name: string;
|
|
14
15
|
objective: "cost" | "speed";
|
|
15
16
|
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
16
|
-
roles: Partial<
|
|
17
|
+
roles: Partial<
|
|
18
|
+
Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string; network?: false }>
|
|
19
|
+
>;
|
|
17
20
|
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
18
21
|
billing: Partial<Record<string, BillingMode>>;
|
|
19
22
|
jev: { use: "auto" | "off" };
|
|
@@ -124,9 +127,9 @@ export interface Deps {
|
|
|
124
127
|
profiles: ProfilePort;
|
|
125
128
|
routing: RoutingPort;
|
|
126
129
|
version: string;
|
|
127
|
-
/** how often the supervisor and the dispatch
|
|
130
|
+
/** how often the supervisor and the dispatch watchers poll, in ms */
|
|
128
131
|
pollMs: number;
|
|
129
|
-
/**
|
|
130
|
-
|
|
132
|
+
/** the Claude Code session this process serves (spec §3.3), from its environment; null outside one */
|
|
133
|
+
session: SessionEnv | null;
|
|
131
134
|
now: () => number;
|
|
132
135
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
4
|
+
import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
|
|
4
5
|
import { checkEnv } from "../infra/env.ts";
|
|
5
6
|
import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
|
|
6
7
|
import { killGroup } from "../infra/proc.ts";
|
|
@@ -47,6 +48,8 @@ interface LaneCheck {
|
|
|
47
48
|
check: string | null;
|
|
48
49
|
owns: string[];
|
|
49
50
|
problem: string | null;
|
|
51
|
+
/** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
|
|
52
|
+
invalid: string | null;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function laneChecks(run: Run): LaneCheck[] {
|
|
@@ -57,16 +60,24 @@ function laneChecks(run: Run): LaneCheck[] {
|
|
|
57
60
|
.sort()
|
|
58
61
|
.map((f) => {
|
|
59
62
|
const lane = f.slice(0, -".md".length);
|
|
63
|
+
let invalid: string | null = null;
|
|
60
64
|
try {
|
|
61
|
-
const
|
|
65
|
+
const text = readFileSync(join(dir, f), "utf8");
|
|
66
|
+
try {
|
|
67
|
+
assertLaneHeader(text, `lanes/${f}`);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
invalid = errorMessage(e);
|
|
70
|
+
}
|
|
71
|
+
const h = parseLaneHeader(text);
|
|
62
72
|
return {
|
|
63
73
|
lane,
|
|
64
74
|
check: h.fastCheck,
|
|
65
75
|
owns: h.owns,
|
|
66
76
|
problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
|
|
77
|
+
invalid,
|
|
67
78
|
};
|
|
68
79
|
} catch (e) {
|
|
69
|
-
return { lane, check: null, owns: [], problem: (e as Error).message };
|
|
80
|
+
return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
|
|
70
81
|
}
|
|
71
82
|
});
|
|
72
83
|
}
|
|
@@ -159,6 +170,9 @@ export async function preflight(
|
|
|
159
170
|
const run = findRun(i.run);
|
|
160
171
|
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
161
172
|
const lanes = laneChecks(run);
|
|
173
|
+
// spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
|
|
174
|
+
const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
|
|
175
|
+
if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
|
|
162
176
|
const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
|
|
163
177
|
if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
|
|
164
178
|
const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
|