@floh-solutions/pharos-cli 0.31.1 → 0.33.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.
@@ -0,0 +1,709 @@
1
+ import { connect } from "node:net";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The Argus board socket — the route to a coding agent running in Navarch.
6
+ *
7
+ * ## Why there is a fourth route at all
8
+ *
9
+ * Terminal takes an Apple Event and VS Code takes a URI, because in both the
10
+ * thing that owns the terminal is the application. Navarch owns neither: a
11
+ * worker's PTY belongs to `argusd`, a daemon that outlives the app, and the
12
+ * app is what watches a screen settle before typing into it. So the delegation
13
+ * goes over `argusd`'s own socket as one verb — `delegate` — and the two
14
+ * halves split where the knowledge is: the daemon validates and does the find,
15
+ * the app starts a worker if there is none, waits for a screen that can take
16
+ * text, types, and reports back.
17
+ *
18
+ * The contract is `docs/delegation-plan.md` in the argus repo (board #1378).
19
+ * Everything below was measured against an isolated `argusd` built from argus
20
+ * master `2aaefd3` on 2026-09-11; the fixtures in `test/argus.test.ts` are that
21
+ * daemon's replies verbatim.
22
+ *
23
+ * ## Spoken directly, not through `argus-board`
24
+ *
25
+ * `~/.config/argus/bin/argus-board` frames exactly this — one newline-
26
+ * terminated JSON object in, one line back — and shelling out to it would have
27
+ * been half the code. It is wrong here for one measured reason: **that script
28
+ * stamps the CALLER'S identity onto every request**, from the environment.
29
+ * `_agent` from `$ARGUS_AGENT`, `_worker` from `$ARGUS_WORKER`, plus `_sub`,
30
+ * `_persona` and `_mission`. A `pharos delegate` run inside an Argus worker —
31
+ * which is precisely where Pharos.app's Ask Agent runs it — would therefore
32
+ * raise an approval card naming that worker, and file the ledger entry and the
33
+ * outcome note under it. The card is the whole point of the dial: it has to
34
+ * read *Pharos wants to hand work to a claude worker in …*, which is a
35
+ * question a person can answer.
36
+ *
37
+ * Speaking the socket means exactly the keys below travel, and nothing this
38
+ * process happens to have been started with. It also lets a park hold for the
39
+ * daemon's full wait (the script's own deadline is `wait + 5`, tuned for the
40
+ * board's reads) and keeps a transport failure distinguishable from a refusal,
41
+ * which a script that prints one line for both cannot be.
42
+ *
43
+ * ## `ARGUS_BOARD_SOCK` is honoured
44
+ *
45
+ * The same variable `argus-board` reads. It is how an isolated daemon is
46
+ * reached, and a session pointed at one should not have this verb quietly
47
+ * talking to the live fleet instead.
48
+ */
49
+ /** Where `argusd` binds its board socket, unless the environment says otherwise. */
50
+ export function argusSocketPath(env = process.env) {
51
+ const override = (env["ARGUS_BOARD_SOCK"] ?? "").trim();
52
+ if (override !== "")
53
+ return override;
54
+ return join(env["HOME"] ?? homedir(), ".config", "argus", "argus-board.sock");
55
+ }
56
+ /** The one-line limit the daemon holds `text` to (`BoardStore.sendTextLimit`). */
57
+ export const ARGUS_TEXT_LIMIT = 4000;
58
+ /**
59
+ * How long to ask the daemon to park, in seconds.
60
+ *
61
+ * **60, always, and never the default 10.** Two measured reasons. A delegation
62
+ * onto an empty folder has to start a worker and wait out a cold agent's first
63
+ * screen — `FleetControl.delegateSpawnWindow` is 45s — so a ten-second answer
64
+ * is `pending` with the real outcome landing half a minute later in a reply
65
+ * nobody is reading. And on the default autonomy dial the first hand-off on a
66
+ * machine raises an approval card, which answers `pending` the instant it goes
67
+ * up whatever the wait is; the wait is what catches the person who clicks
68
+ * *Allow* straight away, and ten seconds does not.
69
+ *
70
+ * 60 is also the daemon's ceiling (`maxFleetWait`), so this is "as long as it
71
+ * will hold", not a number picked here.
72
+ */
73
+ export const ARGUS_WAIT_SECONDS = 60;
74
+ /** What the request is stamped with. The card names this; an anonymous one reads "A program outside the fleet". */
75
+ export const ARGUS_AGENT = "Pharos";
76
+ /**
77
+ * Decode one line. Anything that is not a JSON object with a boolean-ish `ok`
78
+ * is a transport fault rather than an answer, and is reported as one.
79
+ */
80
+ export function parseArgusReply(line) {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(line);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
89
+ return null;
90
+ const row = parsed;
91
+ if (row["ok"] === true) {
92
+ const data = row["data"];
93
+ return { ok: true, data: isRecord(data) ? data : {} };
94
+ }
95
+ if (row["ok"] === false) {
96
+ return {
97
+ ok: false,
98
+ reason: typeof row["reason"] === "string" ? row["reason"] : null,
99
+ error: typeof row["error"] === "string" ? row["error"] : "",
100
+ data: isRecord(row["data"]) ? row["data"] : null,
101
+ };
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * The socket was there and the conversation still failed — a park that outlived
107
+ * its own deadline, a daemon that closed mid-answer, a line that is not JSON.
108
+ *
109
+ * Separate from a refusal on purpose: a refusal is a fact about this machine
110
+ * with a fix on it, and this is the CLI's ordinary failure.
111
+ */
112
+ export class ArgusTransportError extends Error {
113
+ code;
114
+ constructor(message, code) {
115
+ super(message);
116
+ this.name = "ArgusTransportError";
117
+ this.code = code;
118
+ }
119
+ }
120
+ /** Nothing is listening: the socket file is absent, or the connect was refused. */
121
+ export class ArgusUnreachableError extends Error {
122
+ code;
123
+ socketPath;
124
+ constructor(message, code, socketPath) {
125
+ super(message);
126
+ this.name = "ArgusUnreachableError";
127
+ this.code = code;
128
+ this.socketPath = socketPath;
129
+ }
130
+ }
131
+ /**
132
+ * A connect that was REFUSED is retried; one that answered is not.
133
+ *
134
+ * `ECONNREFUSED` on a unix socket does not mean "nothing is listening". A
135
+ * socket whose accept backlog is full refuses with the same errno, and argus
136
+ * measured 48 of 64 concurrent connects refused by a daemon that was running
137
+ * and answered normally seconds later (`argus-board`, #742). So a refusal is
138
+ * worth one more try — and it is the one failure that is safe to repeat for a
139
+ * WRITE, because a connect that was never accepted sent no bytes and there is
140
+ * nothing to duplicate.
141
+ */
142
+ const CONNECT_ATTEMPTS = 3;
143
+ const CONNECT_BACKOFF_MS = 120;
144
+ export function systemArgusDeps(env = process.env) {
145
+ const socketPath = argusSocketPath(env);
146
+ return {
147
+ ask: async (request, timeoutMs) => {
148
+ let last = null;
149
+ for (let attempt = 0; attempt < CONNECT_ATTEMPTS; attempt += 1) {
150
+ try {
151
+ return await askOnce(socketPath, request, timeoutMs);
152
+ }
153
+ catch (error) {
154
+ if (!(error instanceof ArgusUnreachableError) || error.code !== "ECONNREFUSED")
155
+ throw error;
156
+ last = error;
157
+ await pause(CONNECT_BACKOFF_MS * (attempt + 1));
158
+ }
159
+ }
160
+ throw last ?? new ArgusUnreachableError("the Argus socket refused every attempt", "ECONNREFUSED", socketPath);
161
+ },
162
+ };
163
+ }
164
+ function pause(ms) {
165
+ return new Promise((resolve) => {
166
+ setTimeout(resolve, ms).unref?.();
167
+ });
168
+ }
169
+ function askOnce(socketPath, request, timeoutMs) {
170
+ return new Promise((resolve, reject) => {
171
+ const socket = connect({ path: socketPath });
172
+ let buffer = "";
173
+ let settled = false;
174
+ const done = (error, line) => {
175
+ if (settled)
176
+ return;
177
+ settled = true;
178
+ socket.destroy();
179
+ if (error !== null)
180
+ reject(error);
181
+ else
182
+ resolve(line);
183
+ };
184
+ // An IDLE timeout, which is the right one: the daemon holds the reply for
185
+ // the whole park and sends nothing at all in the meantime, so any data is
186
+ // the answer and no data for this long is a park that outlived its own
187
+ // deadline.
188
+ socket.setTimeout(timeoutMs, () => {
189
+ done(new ArgusTransportError(`argusd did not answer within ${Math.round(timeoutMs / 1000)}s`, "ETIMEDOUT"));
190
+ });
191
+ socket.on("connect", () => {
192
+ socket.write(`${JSON.stringify(request)}\n`);
193
+ });
194
+ socket.on("data", (chunk) => {
195
+ buffer += chunk.toString("utf8");
196
+ const newline = buffer.indexOf("\n");
197
+ if (newline >= 0)
198
+ done(null, buffer.slice(0, newline));
199
+ });
200
+ socket.on("error", (error) => {
201
+ const code = error.code ?? "";
202
+ if (code === "ENOENT" || code === "ECONNREFUSED") {
203
+ done(new ArgusUnreachableError(error.message, code, socketPath));
204
+ return;
205
+ }
206
+ done(new ArgusTransportError(error.message, code === "" ? "EIO" : code));
207
+ });
208
+ socket.on("close", () => {
209
+ // Closed without a newline. A line that arrived whole but unterminated is
210
+ // still an answer; nothing at all is a daemon that hung up mid-reply.
211
+ if (buffer.trim() !== "")
212
+ done(null, buffer.trim());
213
+ else
214
+ done(new ArgusTransportError("argusd closed the connection without answering", "EPIPE"));
215
+ });
216
+ });
217
+ }
218
+ /**
219
+ * The refusal ids this route adds to `delegate`'s closed set.
220
+ *
221
+ * Every one names a DIFFERENT fix, which is the whole reason they are told
222
+ * apart — the app renders a fact and the fix beside it, and two states that
223
+ * share a word send somebody to the wrong Settings pane. `unsupported` and
224
+ * `folder-missing` are not here because the verb already has them and they
225
+ * mean the same thing on this route.
226
+ */
227
+ export const ARGUS_REASONS = [
228
+ /** No `argusd`. Nothing is listening on the socket — opening Navarch starts one. */
229
+ "argusd-not-running",
230
+ /** `argusd` is running and predates the verb. The fix is a reload, which ends every live session. */
231
+ "argusd-outdated",
232
+ /** `argusd` is up, Navarch is not. Only the app can start or type into a worker. */
233
+ "navarch-not-running",
234
+ /** The gate: this request did not carry operator authority. */
235
+ "not-an-operator",
236
+ /** Twelve operator writes a minute, and this one was the thirteenth. */
237
+ "rate-limited",
238
+ /** A person said no at the approval card, or the dial is set to Never. */
239
+ "declined",
240
+ /** Navarch tried and nothing was delivered — a trust dialog, a blank screen, a worker that exited. */
241
+ "delivery-failed",
242
+ /** `--status` for a delegation this core does not hold: another core's, or one that aged out. */
243
+ "request-unknown",
244
+ /**
245
+ * `--gh-account` named an identity this core has never heard of, AND a worker
246
+ * would be started (#1402).
247
+ *
248
+ * Passed through as its own id rather than folded into `argus-refused`,
249
+ * because it is the one socket refusal with a fix a PERSON performs and the
250
+ * app can name: add the account in Navarch ▸ Accounts, or ask with a
251
+ * spelling this core knows — the daemon puts every label and login it holds
252
+ * in `detail` and in `data.known`.
253
+ *
254
+ * There is deliberately no fallback on either side. A worker with no binding
255
+ * runs as whatever `gh` was last logged in as, and a hand-off that commits
256
+ * under the wrong identity is the failure this resolution exists to prevent.
257
+ */
258
+ "gh-account-unknown",
259
+ /** The daemon refused for a reason this pharos has no id for. Its own reason and prose travel in the detail. */
260
+ "argus-refused",
261
+ ];
262
+ /**
263
+ * Generic over what it is paired with, because three different reads return
264
+ * "the thing, or the refusal that stopped it": a delegation, a fleet, a list.
265
+ */
266
+ export function isFailure(value) {
267
+ return "reason" in value && "detail" in value && "argus" in value;
268
+ }
269
+ /**
270
+ * The delegation inside a reply, or the refusal it is instead.
271
+ *
272
+ * One function for all three answers — `delegate`, the end of its park and
273
+ * `delegate_status` — because the daemon builds all three from one builder and
274
+ * a caller that parsed them separately would be inventing a difference.
275
+ */
276
+ export function readDelegation(reply) {
277
+ if (reply.ok)
278
+ return readRow(reply.data);
279
+ const failure = mapRefusal(reply);
280
+ // `declined` and `failed` are reported WITH the row, so the app can still say
281
+ // which worker and which folder it was about.
282
+ if (reply.data !== null && typeof reply.data["action"] === "string") {
283
+ failure.delegation = readRow(reply.data);
284
+ }
285
+ return failure;
286
+ }
287
+ function readRow(data) {
288
+ const action = data["action"];
289
+ return {
290
+ action: action === "sent" || action === "launched" ? action : "pending",
291
+ request: text(data["request"]),
292
+ worker: optional(data["worker"]),
293
+ name: optional(data["name"]),
294
+ created: data["created"] === true,
295
+ kind: text(data["kind"]),
296
+ directory: text(data["directory"]),
297
+ host: text(data["host"]),
298
+ todo: typeof data["todo"] === "number" ? data["todo"] : null,
299
+ detail: text(data["detail"]),
300
+ };
301
+ }
302
+ /**
303
+ * A refusal, as an id this CLI's caller already renders.
304
+ *
305
+ * ## The rule that matters: **no `reason` key is an OLD DAEMON**
306
+ *
307
+ * `reason` arrived with `delegate` itself, so an `argusd` too old to serve the
308
+ * verb is also too old to stamp one. That is not a corner — it is the first
309
+ * thing this route meets on any machine whose daemon has not been reloaded,
310
+ * which on the machine this was written on was every machine the day it
311
+ * landed. And an old daemon says three different things, two of which accuse
312
+ * this CLI of a bug it does not have (measured against argus `2fa7bf0`, the
313
+ * commit before the verb):
314
+ *
315
+ * | what is attached | what comes back, with no `reason` |
316
+ * |---|---|
317
+ * | old daemon, old app or none | `unknown op 'delegate' — see argus-board help …` |
318
+ * | old daemon, **new Navarch publishing `delegate`** | `delegate requires a "todo" (id) — the work it serves` |
319
+ * | …the same, unstamped | `'delegate' is an Argus Agent action — this session was not started as one. …` |
320
+ *
321
+ * The middle row is the trap: an old daemon forwards any verb the app claims
322
+ * it can run, and that forwarder demands the `todo` this verb exists to
323
+ * remove. A caller that believed the prose would go and add one. So the test
324
+ * is the MISSING KEY and never the wording — the wording only chooses how
325
+ * sharply the detail can put it. A current daemon stamps a reason on every
326
+ * refusal, and argus asserts that in `Tests/delegate_test.py` precisely so this
327
+ * inference stays true.
328
+ */
329
+ export function mapRefusal(reply) {
330
+ const argus = { reason: reply.reason, error: reply.error };
331
+ const said = reply.error.trim();
332
+ const because = said === "" ? "" : ` argusd said: ${said}`;
333
+ if (reply.reason === null) {
334
+ const shape = /^unknown op '/.test(said)
335
+ ? "It does not know the verb at all"
336
+ : said.includes('requires a "todo"')
337
+ ? "It is forwarding the verb to Navarch, which is newer than it is, and its forwarder still "
338
+ + "demands the board todo this verb exists to do without — the message blames the request "
339
+ + "and the request is fine"
340
+ : "It refused without the stable reason every current refusal carries";
341
+ return {
342
+ reason: "argusd-outdated",
343
+ detail: "The argusd running on this machine predates the delegate verb. "
344
+ + `${shape}. Reload it — deliberately, because a reload ends every live worker session — `
345
+ + `and Navarch will come back with it.${because}`,
346
+ argus,
347
+ };
348
+ }
349
+ switch (reply.reason) {
350
+ case "unknown-op":
351
+ return {
352
+ reason: "argusd-outdated",
353
+ detail: "The argusd running on this machine does not serve the delegate verb. Reload it — "
354
+ + "deliberately, because a reload ends every live worker session."
355
+ + because,
356
+ argus,
357
+ };
358
+ case "no-core":
359
+ return {
360
+ reason: "navarch-not-running",
361
+ detail: "argusd is running but Navarch is not, and only the app can start a worker or type into "
362
+ + "one. Open Navarch and try again."
363
+ + because,
364
+ argus,
365
+ };
366
+ case "unsupported":
367
+ return {
368
+ reason: "unsupported",
369
+ detail: "The Navarch running on this machine is an older build that does not serve delegations. "
370
+ + "Update it — `./scripts/make-app.sh --install` in the argus repo — and try again."
371
+ + because,
372
+ argus,
373
+ };
374
+ case "not-an-operator":
375
+ return {
376
+ reason: "not-an-operator",
377
+ detail: "argusd refused the delegation for want of operator authority, which pharos stamps on "
378
+ + "every request it sends. That means the daemon did not read the stamp — it is almost "
379
+ + "always one that predates the verb."
380
+ + because,
381
+ argus,
382
+ };
383
+ case "rate-limited":
384
+ return {
385
+ reason: "rate-limited",
386
+ detail: "argusd caps operator writes at twelve a minute and this delegation was over it. Wait a "
387
+ + "minute and send it again; nothing was delivered."
388
+ + because,
389
+ argus,
390
+ };
391
+ case "declined":
392
+ return {
393
+ reason: "declined",
394
+ detail: said === "" ? "The delegation was declined in Navarch. Nothing was delivered." : said,
395
+ argus,
396
+ };
397
+ case "failed":
398
+ return {
399
+ reason: "delivery-failed",
400
+ detail: said === "" ? "Navarch could not deliver the prompt. Nothing was typed." : said,
401
+ argus,
402
+ };
403
+ case "bad-directory":
404
+ return {
405
+ reason: "folder-missing",
406
+ detail: "argusd does not see that folder on this machine. The linked folder for this project has "
407
+ + "moved or was never here — pick it again in Settings ▸ Agents."
408
+ + because,
409
+ argus,
410
+ };
411
+ // **The daemon's `worker-not-eligible` is this verb's `session-gone`** —
412
+ // the same meaning to a caller, which is the only question that decides
413
+ // whether two ids should be one. Both say: the row you named is not one
414
+ // this machine will deliver to, nothing was sent, and the fix is to read
415
+ // the list again and pick from it. An app that renders `session-gone` for
416
+ // a Terminal pid that has exited renders exactly the right thing here.
417
+ //
418
+ // **The daemon's own sentence is kept verbatim rather than rewritten**,
419
+ // because it names WHICH of six it was — exited, an Argus Agent, holding
420
+ // mission #N, the wrong kind, on another Mac, in another folder — and each
421
+ // of those is a different next move for the person. Our own framing is
422
+ // added around it, not over it.
423
+ case "worker-not-eligible":
424
+ return {
425
+ reason: "session-gone",
426
+ detail: (said === "" ? "That worker is not one argusd will deliver to." : said)
427
+ + " Nothing was sent and nothing was started: a chosen session is a choice, so this refuses "
428
+ + "rather than quietly using another worker. Re-read the inventory with `pharos delegate "
429
+ + "--list --host navarch`, or pass `--session new` to start a fresh worker on purpose.",
430
+ argus,
431
+ };
432
+ // Passed through as itself. The fix is a person's — add the account in
433
+ // Navarch ▸ Accounts, or ask with a spelling this core knows — and argusd
434
+ // has already named every label and login it holds, so rewriting the
435
+ // sentence could only lose the list.
436
+ case "gh-account-unknown":
437
+ return {
438
+ reason: "gh-account-unknown",
439
+ detail: said === ""
440
+ ? "Navarch has no GitHub account by that name, and this delegation would start a worker — "
441
+ + "which must not run as whichever account `gh` happens to be logged in as."
442
+ : said,
443
+ argus,
444
+ };
445
+ case "unknown-request":
446
+ return {
447
+ reason: "request-unknown",
448
+ detail: "argusd holds no delegation with that id — it was made on another core, or it has aged "
449
+ + "out (delegations are kept for an hour). A status read cannot recover one; delegate "
450
+ + "again."
451
+ + because,
452
+ argus,
453
+ };
454
+ default:
455
+ return {
456
+ reason: "argus-refused",
457
+ detail: `argusd refused the delegation (${reply.reason}). This pharos has no fix for that reason, `
458
+ + `so here is what it said verbatim: ${said === "" ? "(nothing)" : said}`,
459
+ argus,
460
+ };
461
+ }
462
+ }
463
+ /**
464
+ * The word that means "start one anyway", beside the uuids `worker` also takes
465
+ * (`BoardStore.delegateFreshWorker`).
466
+ *
467
+ * It is the same spelling as this CLI's own reserved `--session new`, and that
468
+ * is a coincidence worth keeping rather than a shared constant: one is a value
469
+ * a person types at a flag, the other is a value on argus's wire, and the day
470
+ * either side renames its own the other must not silently follow.
471
+ */
472
+ export const ARGUS_NEW_WORKER = "new";
473
+ /**
474
+ * The request, built in one place.
475
+ *
476
+ * `host`, `name`, `stage` and `project` are deliberately never sent. `host`
477
+ * would name a remote machine and this CLI's `--host` means the application, so
478
+ * spelling one here would be a collision waiting to pick the wrong worker;
479
+ * `name` names a NEW worker, which Navarch derives better than a caller can;
480
+ * `project` and `stage` belong to a board this delegation does not have.
481
+ *
482
+ * **`worker` and `gh_account` are omitted when absent rather than sent null.**
483
+ * The daemon reads a JSON null as absent, so either would work — but a daemon
484
+ * that predates #1402 IGNORES both keys silently, and an omitted key keeps the
485
+ * two requests byte-identical to what that daemon has always been sent. The
486
+ * capability check that stops us sending them to a daemon that would ignore
487
+ * them is the caller's; see `delegate.ts`.
488
+ */
489
+ export function delegateRequest(job) {
490
+ const aimed = (job.worker ?? "").trim();
491
+ const account = (job.ghAccount ?? "").trim();
492
+ return {
493
+ op: "delegate",
494
+ _fleet: "1",
495
+ _agent: ARGUS_AGENT,
496
+ directory: job.directory,
497
+ kind: job.kind,
498
+ text: job.text,
499
+ wait: job.wait ?? ARGUS_WAIT_SECONDS,
500
+ ...(aimed === "" ? {} : { worker: aimed }),
501
+ ...(account === "" ? {} : { gh_account: account }),
502
+ };
503
+ }
504
+ /**
505
+ * `delegate_candidates` — **the find, asked instead of performed** (#1402).
506
+ *
507
+ * A read: ungated, not rate-limited, and answered from the same fleet snapshot
508
+ * `fleet_status` is served from, so it says strictly less than this socket
509
+ * already tells everyone on it. It is stamped like every other request from
510
+ * here all the same, so a refusal cannot be about the stamp.
511
+ *
512
+ * `host` is deliberately not sent. It would name a remote machine, and this
513
+ * CLI's `--host` means the application — the daemon defaults it to empty,
514
+ * which is this Mac, and that is the only fleet this verb can reach.
515
+ */
516
+ export function delegateCandidatesRequest(directory, kind) {
517
+ return { op: "delegate_candidates", _fleet: "1", _agent: ARGUS_AGENT, directory, kind };
518
+ }
519
+ export function delegateStatusRequest(request, wait = 0) {
520
+ return { op: "delegate_status", _fleet: "1", _agent: ARGUS_AGENT, request, wait };
521
+ }
522
+ export function fleetStatusRequest() {
523
+ // A read, and the daemon never gates one — but it is stamped like every other
524
+ // request from here so a refusal cannot be about the stamp.
525
+ return { op: "fleet_status", _fleet: "1", _agent: ARGUS_AGENT };
526
+ }
527
+ /**
528
+ * How long to wait on the socket for a park of `wait` seconds.
529
+ *
530
+ * The daemon has to notice its own deadline and write a reply after it, so the
531
+ * read has to outlast the park or every timed-out delegation reads as a broken
532
+ * socket. Ten seconds of margin, the same shape `argus-board` uses (it allows
533
+ * five, for reads that are not parked on a person).
534
+ */
535
+ export function readTimeoutMs(waitSeconds) {
536
+ return (waitSeconds + 10) * 1000;
537
+ }
538
+ export async function askArgus(deps, request, timeoutMs) {
539
+ const line = await deps.ask(request, timeoutMs);
540
+ const reply = parseArgusReply(line);
541
+ if (reply === null) {
542
+ throw new ArgusTransportError(`argusd answered with something that is not a reply: ${line.slice(0, 200)}`, "EBADMSG");
543
+ }
544
+ return reply;
545
+ }
546
+ /** The refusal for a socket nothing is listening on. */
547
+ export function unreachable(error) {
548
+ return {
549
+ reason: "argusd-not-running",
550
+ detail: `Nothing is listening on the Argus socket at ${error.socketPath} (${error.code}). argusd is `
551
+ + "what holds a Navarch worker's terminal, and opening Navarch starts it — so open Navarch "
552
+ + "and try again. If it IS open, the socket has moved: ARGUS_BOARD_SOCK overrides where this "
553
+ + "looks.",
554
+ argus: { reason: null, error: `${error.code}: ${error.message}` },
555
+ };
556
+ }
557
+ function isRecord(value) {
558
+ return typeof value === "object" && value !== null && !Array.isArray(value);
559
+ }
560
+ function text(value) {
561
+ return typeof value === "string" ? value : "";
562
+ }
563
+ /** Empty is not absent, and on this wire the difference is load-bearing. */
564
+ function optional(value) {
565
+ return typeof value === "string" && value !== "" ? value : null;
566
+ }
567
+ export function parseFleet(reply) {
568
+ return parseWorkers(reply.data["workers"]);
569
+ }
570
+ export function parseCandidates(reply) {
571
+ return { workers: parseWorkers(reply.data["candidates"]), target: optional(reply.data["target"]) };
572
+ }
573
+ function parseWorkers(value) {
574
+ const workers = value;
575
+ if (!Array.isArray(workers))
576
+ return [];
577
+ const rows = [];
578
+ for (const entry of workers) {
579
+ if (!isRecord(entry))
580
+ continue;
581
+ const id = text(entry["id"]);
582
+ if (id === "")
583
+ continue;
584
+ rows.push({
585
+ id,
586
+ name: text(entry["name"]),
587
+ kind: text(entry["kind"]),
588
+ status: text(entry["status"]),
589
+ stage: text(entry["stage"]),
590
+ directory: text(entry["directory"]),
591
+ host: text(entry["host"]),
592
+ todo: typeof entry["todo"] === "number" ? entry["todo"] : null,
593
+ fleet: entry["fleet"] === true,
594
+ });
595
+ }
596
+ return rows;
597
+ }
598
+ /**
599
+ * Is this worker a candidate for a delegation onto `directory`?
600
+ *
601
+ * `BoardStore.delegateTarget`, rule for rule. Two of the five exclusions are
602
+ * the interesting ones, and both look like perfectly good targets from here:
603
+ *
604
+ * - **a mission worker** runs in a shared base checkout and would match, and
605
+ * typing an unrelated work item into an agent mid-branch derails the todo it
606
+ * is holding;
607
+ * - **an Argus Agent** is a claude worker with no mission sitting in a base
608
+ * checkout — which is exactly the folder a person links in Pharos. A
609
+ * delegation landing there hands the work item to the thing that *drives* the
610
+ * fleet, in the session whose next turn can start missions and type into
611
+ * every other worker.
612
+ *
613
+ * **Mission-hood is weaker here than it is inside the daemon**, and the
614
+ * difference is worth knowing. The daemon tests `missionTodo` on the worker
615
+ * itself; `fleet_status` only writes `todo` onto the row when the board still
616
+ * holds that todo, so a mission whose todo was deleted reads as free here and
617
+ * is excluded there. The direction of the error is the safe one — this can
618
+ * only list or predict a worker the daemon would decline to use, never hide
619
+ * one it would pick — but it is why a prediction is all this is, and why the
620
+ * send itself never names a worker: the daemon does the find that counts.
621
+ */
622
+ export function eligible(worker, kind, directory) {
623
+ return (worker.status !== "exited"
624
+ && worker.kind === kind
625
+ && worker.todo === null
626
+ && !worker.fleet
627
+ // Local only. This CLI has no way to name a remote machine — `--host` here
628
+ // means the application — so a worker on one is never a candidate.
629
+ && worker.host.trim() === ""
630
+ && sameFolder(worker.directory, directory));
631
+ }
632
+ /**
633
+ * Two spellings of one folder.
634
+ *
635
+ * The daemon compares `standardizingPath` on both sides, which expands `~`,
636
+ * drops `.`/`..` and a trailing slash and resolves the symlinked prefixes
637
+ * macOS ships (`/tmp` → `/private/tmp`) — but NOT an arbitrary symlink
638
+ * somebody made. That is why the caller canonicalises the folder with
639
+ * `realpath` before it gets here, the same way the Terminal route does; this
640
+ * is the rest of the daemon's normalisation, and it is case-insensitive
641
+ * because `caseInsensitiveCompare` is what the daemon uses.
642
+ */
643
+ export function sameFolder(a, b) {
644
+ return standardize(a).toLowerCase() === standardize(b).toLowerCase();
645
+ }
646
+ function standardize(path) {
647
+ let value = path.trim();
648
+ if (value === "")
649
+ return "";
650
+ // `/tmp`, `/var` and `/etc` are symlinks into `/private` on macOS, and
651
+ // `standardizingPath` resolves exactly those three.
652
+ for (const prefix of ["/tmp", "/var", "/etc"]) {
653
+ if (value === prefix || value.startsWith(`${prefix}/`)) {
654
+ value = `/private${value}`;
655
+ break;
656
+ }
657
+ }
658
+ while (value.length > 1 && value.endsWith("/"))
659
+ value = value.slice(0, -1);
660
+ return value;
661
+ }
662
+ /**
663
+ * The candidates for a delegation onto `directory`, in the order the daemon
664
+ * would pick them. `[0]` is the worker a plain delegate takes.
665
+ *
666
+ * **The tie-break is the id and never the status**, because two calls a second
667
+ * apart must pick the same pane. A worker that has not painted yet is worst —
668
+ * there is no box to type into — and everything else is equal, so `starting`
669
+ * sinks and the rest sort on a uuid that never changes.
670
+ */
671
+ export function rankWorkers(workers, kind, directory) {
672
+ return workers
673
+ .filter((worker) => eligible(worker, kind, directory))
674
+ .sort((a, b) => {
675
+ const starting = Number(a.status === "starting") - Number(b.status === "starting");
676
+ if (starting !== 0)
677
+ return starting;
678
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
679
+ });
680
+ }
681
+ // MARK: - Does this daemon serve the verb at all?
682
+ /**
683
+ * The read that answers "has this argusd been reloaded since `delegate`
684
+ * landed?" without delegating anything.
685
+ *
686
+ * `delegate_status` is a pure read of the daemon's own table — not gated, not
687
+ * rate-limited, and about an id it minted — so asking it about one that cannot
688
+ * exist costs nothing and changes nothing. A current daemon answers
689
+ * `unknown-request`; one that predates the verb answers `unknown op
690
+ * 'delegate_status'` with no `reason` at all, which is the same tell the send
691
+ * path uses.
692
+ *
693
+ * It earns its round trip on `--list` and `--dry-run`, which otherwise read
694
+ * `fleet_status` — a verb every generation of the daemon serves — and would
695
+ * cheerfully list workers that a send is about to refuse to deliver to.
696
+ */
697
+ export const PROBE_REQUEST = "00000000-0000-0000-0000-000000000000";
698
+ /** True when the reply says this daemon knows the delegate family. */
699
+ export function servesDelegate(reply) {
700
+ return reply.ok || reply.reason !== null;
701
+ }
702
+ export function candidatesSupport(reply) {
703
+ if (reply.ok)
704
+ return "yes";
705
+ if (reply.reason === null)
706
+ return "pre-delegate";
707
+ return reply.reason === "unknown-op" ? "no-op" : "yes";
708
+ }
709
+ //# sourceMappingURL=argus.js.map