@floh-solutions/pharos-cli 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -7
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +31 -8
- package/dist/cli.js.map +1 -1
- package/dist/commands/delegate.d.ts +77 -10
- package/dist/commands/delegate.d.ts.map +1 -1
- package/dist/commands/delegate.js +435 -33
- package/dist/commands/delegate.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +109 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/delegate/argus.d.ts +327 -0
- package/dist/delegate/argus.d.ts.map +1 -0
- package/dist/delegate/argus.js +611 -0
- package/dist/delegate/argus.js.map +1 -0
- package/dist/delegate/vsix.d.ts +21 -0
- package/dist/delegate/vsix.d.ts.map +1 -1
- package/dist/delegate/vsix.js +34 -0
- package/dist/delegate/vsix.js.map +1 -1
- package/package.json +2 -2
- package/skill/SKILL.md +16 -5
- package/vscode/pharos-bridge.json +3 -3
- package/vscode/pharos-bridge.vsix +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { stat } from "node:fs/promises";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, isAbsolute, join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { findApp, HOSTS, HOST_IDS, AGENT_IDS, MODES, isAgentId, isHostId, isMode, } from "../delegate/hosts.js";
|
|
6
|
+
import { ARGUS_REASONS, ARGUS_TEXT_LIMIT, ARGUS_WAIT_SECONDS, ArgusUnreachableError, askArgus, delegateRequest, delegateStatusRequest, fleetStatusRequest, isFailure, parseFleet, PROBE_REQUEST, rankWorkers, readDelegation, readTimeoutMs, servesDelegate, systemArgusDeps, unreachable, } from "../delegate/argus.js";
|
|
6
7
|
import { asTypedInput, LAUNCH_SCRIPT, launchCommand, SEND_TO_TAB_SCRIPT } from "../delegate/quote.js";
|
|
7
8
|
import { findSessions, rankSessions, systemProbes, } from "../delegate/sessions.js";
|
|
8
|
-
import { BRIDGE_EXTENSION_ID, CLAUDE_EXTENSION_ID, extensionsDirectory, installedExtensions, } from "../delegate/vsix.js";
|
|
9
|
+
import { BRIDGE_EXTENSION_ID, bridgeAtLeast, CLAUDE_EXTENSION_ID, extensionsDirectory, installedExtensions, SESSION_BRIDGE_VERSION, } from "../delegate/vsix.js";
|
|
9
10
|
import { CliError, EXIT_FAILED, EXIT_USAGE, emit, emitText, usageError } from "../output.js";
|
|
10
11
|
import { resolveOnPath } from "./doctor.js";
|
|
11
12
|
const run = promisify(execFile);
|
|
@@ -22,6 +23,17 @@ export const REASONS = [
|
|
|
22
23
|
"unsupported",
|
|
23
24
|
/** `--session <id>` named a session that is no longer a target on this folder. */
|
|
24
25
|
"session-gone",
|
|
26
|
+
/**
|
|
27
|
+
* `--session` named a Navarch worker that is not the one the daemon's own
|
|
28
|
+
* find would pick, or asked for a new one — neither of which the `delegate`
|
|
29
|
+
* op can express: it takes no target worker (argus #1402). The id is real
|
|
30
|
+
* and the session is alive, which is why this is not `session-gone`.
|
|
31
|
+
*/
|
|
32
|
+
"session-not-targetable",
|
|
33
|
+
// …and the Argus socket's own, each naming a different fix. See
|
|
34
|
+
// `delegate/argus.js` — they are defined there because that is where the
|
|
35
|
+
// daemon's reasons are mapped onto them.
|
|
36
|
+
...ARGUS_REASONS,
|
|
25
37
|
];
|
|
26
38
|
export const SYSTEM_DEPS = {
|
|
27
39
|
findApp,
|
|
@@ -49,6 +61,7 @@ export const SYSTEM_DEPS = {
|
|
|
49
61
|
// never appears in `claude agents --json`. Scrubbed here at the source.
|
|
50
62
|
await run("/usr/bin/open", [uri], { timeout: 30_000, env: childEnv(process.env) });
|
|
51
63
|
},
|
|
64
|
+
argus: systemArgusDeps,
|
|
52
65
|
};
|
|
53
66
|
/**
|
|
54
67
|
* The environment to hand a launched app or shell, with this session's
|
|
@@ -74,8 +87,29 @@ export function childEnv(env) {
|
|
|
74
87
|
}
|
|
75
88
|
return clean;
|
|
76
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Navarch is the only host whose ids are worker uuids, and its route has
|
|
92
|
+
* already returned by the time either pid host runs — so a pick reaching them
|
|
93
|
+
* can only be a pid or `new`.
|
|
94
|
+
*
|
|
95
|
+
* A guard rather than a cast, because the day that stops being true this says
|
|
96
|
+
* so here instead of handing `undefined` to a `find` and reporting the wrong
|
|
97
|
+
* session as gone.
|
|
98
|
+
*/
|
|
99
|
+
function tabPick(pick) {
|
|
100
|
+
if (pick === undefined || pick.kind !== "worker")
|
|
101
|
+
return pick;
|
|
102
|
+
throw new CliError(`--session ${pick.id} is a Navarch worker id, and this route delivers by process id.`, "internal", EXIT_FAILED);
|
|
103
|
+
}
|
|
77
104
|
/** The reserved id. `--list` never prints it, so it can never shadow a real session. */
|
|
78
105
|
export const NEW_SESSION = "new";
|
|
106
|
+
/**
|
|
107
|
+
* Any C0 or C1 control character, which is what the daemon refuses `text` for
|
|
108
|
+
* (`CharacterSet.controlCharacters`) — a tab as much as a newline.
|
|
109
|
+
*/
|
|
110
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
111
|
+
/** A worker id as `fleet_status` spells it — canonical uuid, upper case. */
|
|
112
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
79
113
|
/**
|
|
80
114
|
* `--session <id>` → a pick, or a usage error.
|
|
81
115
|
*
|
|
@@ -94,10 +128,22 @@ export function sessionPick(value, host) {
|
|
|
94
128
|
}
|
|
95
129
|
if (given === NEW_SESSION)
|
|
96
130
|
return { kind: "new" };
|
|
131
|
+
// **Navarch's ids are worker uuids**, so a uuid is WELL FORMED there and a
|
|
132
|
+
// pid is not. That distinction is the whole point of parsing per host: an id
|
|
133
|
+
// this verb could have printed must reach the route and be answered with a
|
|
134
|
+
// fact about the machine, and only an id it could never have printed is the
|
|
135
|
+
// caller getting the call wrong.
|
|
136
|
+
if (host === "navarch") {
|
|
137
|
+
if (!UUID.test(given)) {
|
|
138
|
+
throw usageError(`--session takes the \`id\` \`pharos delegate --list\` printed, which in ${HOSTS[host].name} is a worker `
|
|
139
|
+
+ `UUID, or the reserved ${NEW_SESSION} — not ${JSON.stringify(given)}.`, { session: given, host });
|
|
140
|
+
}
|
|
141
|
+
return { kind: "worker", id: given.toUpperCase() };
|
|
142
|
+
}
|
|
97
143
|
if (!/^[0-9]+$/.test(given) || !Number.isSafeInteger(Number(given)) || Number(given) <= 0) {
|
|
98
144
|
throw usageError(`--session takes the \`id\` \`pharos delegate --list\` printed, which in ${HOSTS[host].name} is a process `
|
|
99
|
-
+ `id, or the reserved ${NEW_SESSION} — not ${JSON.stringify(given)}. (Navarch's ids are worker UUIDs
|
|
100
|
-
+ "
|
|
145
|
+
+ `id, or the reserved ${NEW_SESSION} — not ${JSON.stringify(given)}. (Navarch's ids are worker UUIDs; `
|
|
146
|
+
+ "pass --host navarch for those.)", { session: given, host });
|
|
101
147
|
}
|
|
102
148
|
return { kind: "pid", pid: Number(given) };
|
|
103
149
|
}
|
|
@@ -113,11 +159,26 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
113
159
|
throw usageError("--list and --session are different questions: --list reports what is running and sends nothing, "
|
|
114
160
|
+ "--session sends to one of the ids it printed. Pass one.");
|
|
115
161
|
}
|
|
162
|
+
const status = (options.status ?? "").trim();
|
|
163
|
+
const asking = options.status !== undefined;
|
|
164
|
+
if (asking && (options.list || options.session !== undefined)) {
|
|
165
|
+
throw usageError("--status asks what became of a delegation that is already in flight, so it takes neither "
|
|
166
|
+
+ "--list nor --session. Pass the request id on its own.");
|
|
167
|
+
}
|
|
168
|
+
if (asking && host !== "navarch") {
|
|
169
|
+
throw usageError(`--status is a Navarch question: it is the only route that can answer \`pending\`, because it is `
|
|
170
|
+
+ `the only one where a person may have to approve the hand-off first. ${HOSTS[host].name} `
|
|
171
|
+
+ "answers sent or launched on the call itself.", { host });
|
|
172
|
+
}
|
|
173
|
+
if (asking && status === "") {
|
|
174
|
+
throw usageError("--status needs the `request` id the delegation answered with.");
|
|
175
|
+
}
|
|
116
176
|
const pick = sessionPick(options.session, host);
|
|
117
177
|
// **`--list` needs no prompt**, and demanding one would make the app build a
|
|
118
|
-
// message it is only going to throw away to refresh a menu.
|
|
178
|
+
// message it is only going to throw away to refresh a menu. Nor does
|
|
179
|
+
// `--status`, which is a read about a delegation whose text was sent already.
|
|
119
180
|
const text = (options.text ?? "").replace(/\r\n/g, "\n").trim();
|
|
120
|
-
if (text === "" && !options.list) {
|
|
181
|
+
if (text === "" && !options.list && !asking) {
|
|
121
182
|
throw usageError("Nothing to send. Pass the prompt with --text, --file or --stdin.");
|
|
122
183
|
}
|
|
123
184
|
const folder = (options.folder ?? "").trim();
|
|
@@ -127,13 +188,27 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
127
188
|
throw usageError(`--folder must be absolute, got ${JSON.stringify(folder)}.`, { folder });
|
|
128
189
|
}
|
|
129
190
|
const base = { host, agent, mode, folder };
|
|
130
|
-
const refuse = (reason, detail) => emitRefusal(io, options.pretty, {
|
|
191
|
+
const refuse = (reason, detail, delegation) => emitRefusal(io, options.pretty, {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason,
|
|
194
|
+
detail,
|
|
195
|
+
...base,
|
|
196
|
+
...(delegation === undefined ? {} : { delegation }),
|
|
197
|
+
});
|
|
131
198
|
const succeed = (outcome) => emitOutcome(io, options.pretty, {
|
|
132
199
|
ok: true,
|
|
133
200
|
...base,
|
|
134
201
|
...outcome,
|
|
135
202
|
...(options.dryRun ? { dryRun: true } : {}),
|
|
136
203
|
});
|
|
204
|
+
// **`--status` asks the daemon, and nothing else.** It deliberately skips
|
|
205
|
+
// both checks below: the folder may well have been unlinked in the minutes a
|
|
206
|
+
// person spent deciding at the approval card, and Navarch itself need not be
|
|
207
|
+
// running — the row it reads is argusd's own. Refusing a status read for
|
|
208
|
+
// either would leave the one delegation that mattered permanently
|
|
209
|
+
// unobservable, which is the failure `delegate_status` exists to prevent.
|
|
210
|
+
if (asking)
|
|
211
|
+
return navarchStatus(io, env, status, base, deps, refuse, succeed);
|
|
137
212
|
// Cheapest fact first: nothing else is worth asking about a folder that is
|
|
138
213
|
// not there, and it is the one refusal that is entirely the caller's to fix.
|
|
139
214
|
const info = await stat(folder).catch(() => null);
|
|
@@ -147,23 +222,14 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
147
222
|
+ "Spotlight and at the usual paths under /Applications and ~/Applications."
|
|
148
223
|
+ (HOSTS[host].cask === undefined ? "" : ` \`brew install --cask ${HOSTS[host].cask}\` installs it.`));
|
|
149
224
|
}
|
|
150
|
-
// Navarch
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// name as its `name`, and both come from `fleet_status` over the Argus socket
|
|
157
|
-
// — which is #1393's half of this. Listing the pids instead would hand the
|
|
158
|
-
// app ids that `--session` can never accept, so the whole route is one honest
|
|
159
|
-
// refusal until #1393 lifts it in one place.
|
|
225
|
+
// **Navarch takes both verbs before the agent is even resolved**, and that
|
|
226
|
+
// is not an oversight. Nothing here runs the agent: argusd spawns a worker
|
|
227
|
+
// through Navarch, from the app's own login shell and its own PATH. A
|
|
228
|
+
// `resolveOnPath` refusal would report a fact about THIS process's PATH as
|
|
229
|
+
// though it were a fact about the machine — and a GUI-launched Pharos has
|
|
230
|
+
// roughly `/usr/bin:/bin`, so it would refuse on nearly every real call.
|
|
160
231
|
if (host === "navarch") {
|
|
161
|
-
return
|
|
162
|
-
+ "it exists — #1378 added a `delegate` op that finds-or-creates a worker on a folder without "
|
|
163
|
-
+ "a board todo, which every other operator action there still needs — but this verb does not "
|
|
164
|
-
+ "speak that socket yet. #1393 on the ask-agent board is the todo that teaches it, and it is "
|
|
165
|
-
+ "also what supplies the worker ids `--session` would take; until it lands, choose Terminal "
|
|
166
|
-
+ "or VS Code.");
|
|
232
|
+
return delegateToNavarch(io, env, agent, folder, text, base, options, pick, deps, refuse, succeed);
|
|
167
233
|
}
|
|
168
234
|
// **May be null, and that is not always a refusal.** The Terminal route runs
|
|
169
235
|
// the agent by this path, so it must exist there; the VS Code extension route
|
|
@@ -174,11 +240,11 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
174
240
|
return listSessions(io, host, agent, mode, folder, agentPath, env, deps, options.pretty, refuse);
|
|
175
241
|
}
|
|
176
242
|
if (host === "vscode" || host === "vscode-insiders") {
|
|
177
|
-
return delegateToVsCode(host, agent, mode, folder, text, agentPath, env, deps, options.dryRun, pick, refuse, succeed);
|
|
243
|
+
return delegateToVsCode(host, agent, mode, folder, text, agentPath, env, deps, options.dryRun, tabPick(pick), refuse, succeed);
|
|
178
244
|
}
|
|
179
245
|
if (agentPath === null)
|
|
180
246
|
return refuse("agent-not-installed", agentNotInstalled(agent, env));
|
|
181
|
-
return delegateToTerminal(agent, folder, text, agentPath, env, deps, options.dryRun, pick, refuse, succeed);
|
|
247
|
+
return delegateToTerminal(agent, folder, text, agentPath, env, deps, options.dryRun, tabPick(pick), refuse, succeed);
|
|
182
248
|
}
|
|
183
249
|
/**
|
|
184
250
|
* `--list` — the inventory, and nothing else.
|
|
@@ -263,19 +329,336 @@ function displayName(session) {
|
|
|
263
329
|
function nameOf(agent) {
|
|
264
330
|
return agent === "claude" ? "Claude" : "Codex";
|
|
265
331
|
}
|
|
332
|
+
// MARK: - Navarch, over the Argus socket
|
|
333
|
+
/**
|
|
334
|
+
* `--host navarch` — the send, the inventory and the dry run.
|
|
335
|
+
*
|
|
336
|
+
* All three go through one function because all three need the same two facts,
|
|
337
|
+
* and the order they are established in is the whole contract:
|
|
338
|
+
*
|
|
339
|
+
* 1. **Does this argusd serve the verb?** `fleet_status` answers on every
|
|
340
|
+
* generation of the daemon — measured against one built from the commit
|
|
341
|
+
* before `delegate` — so a `--list` that skipped this would happily
|
|
342
|
+
* enumerate workers that a send is about to refuse to touch. The probe is a
|
|
343
|
+
* `delegate_status` read about an id that cannot exist: free, ungated,
|
|
344
|
+
* side-effect-free. See {@link PROBE_REQUEST}.
|
|
345
|
+
* 2. **Who is on the folder**, which is a PREDICTION and never the find
|
|
346
|
+
* itself. The daemon does the find that counts, against a snapshot that may
|
|
347
|
+
* have moved since this read.
|
|
348
|
+
*
|
|
349
|
+
* A plain send needs neither and asks for neither: one round trip, and the
|
|
350
|
+
* daemon's own answer says where it landed.
|
|
351
|
+
*/
|
|
352
|
+
async function delegateToNavarch(io, env, agent, folder, text, base, options, pick, deps, refuse, succeed) {
|
|
353
|
+
const kind = agent;
|
|
354
|
+
const argus = deps.argus(env);
|
|
355
|
+
// The daemon's own compare resolves `~`, `.`/`..` and the three symlinked
|
|
356
|
+
// prefixes macOS ships — and no others. A folder reached through a symlink
|
|
357
|
+
// somebody made would miss every worker on it, so it is canonicalised here,
|
|
358
|
+
// exactly as the Terminal route canonicalises before matching a cwd.
|
|
359
|
+
const directory = await canonicalFolder(folder);
|
|
360
|
+
try {
|
|
361
|
+
// `--session new`, and any pick that is not the worker the daemon would
|
|
362
|
+
// choose, are refused BEFORE anything is sent — see {@link notTargetable}.
|
|
363
|
+
const listing = options.list || options.dryRun || pick !== undefined
|
|
364
|
+
? await navarchFleet(argus, kind, directory)
|
|
365
|
+
: null;
|
|
366
|
+
if (listing !== null && isFailure(listing))
|
|
367
|
+
return refuseFailure(refuse, listing);
|
|
368
|
+
const ranked = listing ?? [];
|
|
369
|
+
if (options.list)
|
|
370
|
+
return emitNavarchList(io, base, agent, ranked, options.pretty);
|
|
371
|
+
if (pick !== undefined) {
|
|
372
|
+
const gap = notTargetable(pick, ranked, agent);
|
|
373
|
+
if (gap !== null)
|
|
374
|
+
return refuse("session-not-targetable", gap);
|
|
375
|
+
}
|
|
376
|
+
const oneLine = navarchText(text);
|
|
377
|
+
const target = ranked[0] ?? null;
|
|
378
|
+
if (options.dryRun) {
|
|
379
|
+
return succeed({
|
|
380
|
+
action: target === null ? "launched" : "sent",
|
|
381
|
+
session: null,
|
|
382
|
+
sessions: ranked.map((worker, index) => workerRow(worker, agent, index === 0)),
|
|
383
|
+
detail: target === null
|
|
384
|
+
? `Would ask Navarch to start a ${nameOf(agent)} worker in ${folder} and type the prompt `
|
|
385
|
+
+ "into it. No worker of that kind is free on this folder — a worker holding a board "
|
|
386
|
+
+ "mission and an Argus Agent are both deliberately not candidates."
|
|
387
|
+
: `Would ask Navarch to type the prompt into “${target.name}” (${statusWord(target.status)}). `
|
|
388
|
+
+ "The find is argusd's rather than this CLI's, so this is the worker it would pick from "
|
|
389
|
+
+ "the snapshot as it stands now.",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const reply = await askArgus(argus, delegateRequest({ directory, kind, text: oneLine }), readTimeoutMs(ARGUS_WAIT_SECONDS));
|
|
393
|
+
const outcome = readDelegation(reply);
|
|
394
|
+
if (isFailure(outcome))
|
|
395
|
+
return refuseFailure(refuse, outcome);
|
|
396
|
+
return succeed({
|
|
397
|
+
action: outcome.action,
|
|
398
|
+
session: null,
|
|
399
|
+
detail: navarchDetail(outcome, agent, folder),
|
|
400
|
+
delegation: reportOf(outcome),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
return socketFailure(error, refuse);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** `--status <request>` — what became of a delegation that answered `pending`. */
|
|
408
|
+
async function navarchStatus(io, env, request, base, deps, refuse, succeed) {
|
|
409
|
+
void io;
|
|
410
|
+
try {
|
|
411
|
+
// **`wait` is 0 here, deliberately.** The daemon will park a status read
|
|
412
|
+
// until the action settles, and that is the right tool for a program that
|
|
413
|
+
// can afford to sit still — but this one is called by an app refreshing a
|
|
414
|
+
// row, and a call that blocks for a minute is a spinner nobody asked for.
|
|
415
|
+
// The app polls instead; argus keeps the row for an hour, which is sized
|
|
416
|
+
// for a person at lunch rather than for a park.
|
|
417
|
+
const reply = await askArgus(deps.argus(env), delegateStatusRequest(request, 0), readTimeoutMs(0));
|
|
418
|
+
const outcome = readDelegation(reply);
|
|
419
|
+
if (isFailure(outcome))
|
|
420
|
+
return refuseFailure(refuse, outcome);
|
|
421
|
+
return succeed({
|
|
422
|
+
action: outcome.action,
|
|
423
|
+
session: null,
|
|
424
|
+
detail: navarchDetail(outcome, base.agent, outcome.directory === "" ? base.folder : outcome.directory),
|
|
425
|
+
delegation: reportOf(outcome),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
return socketFailure(error, refuse);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The probe, then the fleet, then the find — ranked the way the daemon ranks
|
|
434
|
+
* it. The candidates on that folder with `[0]` first, or the refusal that
|
|
435
|
+
* stopped it.
|
|
436
|
+
*/
|
|
437
|
+
async function navarchFleet(argus, kind, directory) {
|
|
438
|
+
const probe = await askArgus(argus, delegateStatusRequest(PROBE_REQUEST, 0), readTimeoutMs(0));
|
|
439
|
+
if (!servesDelegate(probe)) {
|
|
440
|
+
// `servesDelegate` is false only for a refusal, and a refusal with no
|
|
441
|
+
// `reason` is a pre-delegate daemon whatever its prose says — which
|
|
442
|
+
// `readDelegation` has already turned into `argusd-outdated`.
|
|
443
|
+
const failure = readDelegation(probe);
|
|
444
|
+
if (isFailure(failure))
|
|
445
|
+
return failure;
|
|
446
|
+
}
|
|
447
|
+
const fleet = await askArgus(argus, fleetStatusRequest(), readTimeoutMs(0));
|
|
448
|
+
if (!fleet.ok) {
|
|
449
|
+
const failure = readDelegation(fleet);
|
|
450
|
+
if (isFailure(failure))
|
|
451
|
+
return failure;
|
|
452
|
+
}
|
|
453
|
+
return fleet.ok ? rankWorkers(parseFleet(fleet), kind, directory) : [];
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Why `--session` cannot be honoured here yet, or null when it can.
|
|
457
|
+
*
|
|
458
|
+
* **The `delegate` op takes no target worker.** The find lives entirely in the
|
|
459
|
+
* daemon (`BoardStore.delegateTarget`) and Navarch types into whatever it
|
|
460
|
+
* named, so the only pick this CLI can promise is the one the daemon would
|
|
461
|
+
* have made anyway — verified against an isolated daemon, where two eligible
|
|
462
|
+
* workers on one folder always drew the lower uuid. Answering `sent` about any
|
|
463
|
+
* other is answering about a pane the person did not choose, which is the one
|
|
464
|
+
* failure this verb is built around not producing.
|
|
465
|
+
*
|
|
466
|
+
* It is NOT `session-gone`: that id is a real, live worker, and telling
|
|
467
|
+
* somebody their session had died would send them looking for a corpse. argus
|
|
468
|
+
* #1402 adds the target field; when it lands this refusal goes and nothing
|
|
469
|
+
* else here changes.
|
|
470
|
+
*/
|
|
471
|
+
function notTargetable(pick, ranked, agent) {
|
|
472
|
+
const head = ranked[0] ?? null;
|
|
473
|
+
if (pick.kind === "worker" && head !== null && head.id === pick.id)
|
|
474
|
+
return null;
|
|
475
|
+
const what = pick.kind === "new"
|
|
476
|
+
? "Starting a fresh worker beside the ones already on this folder"
|
|
477
|
+
: `Delivering to worker ${pick.kind === "worker" ? pick.id : ""}`;
|
|
478
|
+
const instead = head === null
|
|
479
|
+
? `there is no ${nameOf(agent)} worker free on this folder, so a delegation starts one — which is `
|
|
480
|
+
+ "what `--session new` asks for, and already what would happen without it"
|
|
481
|
+
: `a delegation on this folder goes to “${head.name}” (${head.id}), because argusd does the find `
|
|
482
|
+
+ "itself and this verb cannot override it";
|
|
483
|
+
return (`${what} is not something the Argus \`delegate\` op can express yet: it takes a folder and a kind, `
|
|
484
|
+
+ `never a worker. Right now ${instead}. Nothing was sent. Board todo #1402 in the argus repo adds `
|
|
485
|
+
+ "the target field; until it lands, delegate without `--session` to use the worker `--list` marks "
|
|
486
|
+
+ "`recommended`.");
|
|
487
|
+
}
|
|
488
|
+
/** The inventory, in the shape every host answers `--list` in. */
|
|
489
|
+
function emitNavarchList(io, base, agent, ranked, pretty) {
|
|
490
|
+
const rows = ranked.map((worker, index) => workerRow(worker, agent, index === 0));
|
|
491
|
+
const agentName = nameOf(agent);
|
|
492
|
+
const detail = rows.length === 0
|
|
493
|
+
? `No ${agentName} worker is free in Navarch on ${base.folder}. A delegation would start one. `
|
|
494
|
+
+ "A worker holding a board mission, and an Argus Agent, are running agents that are "
|
|
495
|
+
+ "deliberately not candidates — so this can read empty with panes open on that folder."
|
|
496
|
+
: `${rows.length} ${agentName} worker${rows.length === 1 ? "" : "s"} in Navarch on ${base.folder}. `
|
|
497
|
+
+ `A plain delegate would use ${rows[0].name} (${rows[0].status}).`;
|
|
498
|
+
// **`incomplete` is false and means it.** Every other host infers the list
|
|
499
|
+
// from `ps` and a probe that can fail; this one is the daemon's own register
|
|
500
|
+
// of what it is running, so a short answer is an answer and not a gap.
|
|
501
|
+
const listing = { ok: true, ...base, sessions: rows, incomplete: false, detail };
|
|
502
|
+
if (!pretty)
|
|
503
|
+
return emit(io, listing);
|
|
504
|
+
return emitText(io, [
|
|
505
|
+
detail,
|
|
506
|
+
...rows.map((row) => `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`),
|
|
507
|
+
].join("\n"));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* One worker as a menu row.
|
|
511
|
+
*
|
|
512
|
+
* `status` is the contract's three words, and the mapping is not a translation
|
|
513
|
+
* exercise: **`needs_input` is reported `busy`**. A worker sitting on a trust
|
|
514
|
+
* dialog looks idle from every angle except the one that matters — text typed
|
|
515
|
+
* at it is swallowed by the dialog, which is the `failed` Navarch reports
|
|
516
|
+
* afterwards — so calling it idle in a menu invites the person to choose the
|
|
517
|
+
* one pane that cannot take the work. `starting` is `busy` for the same
|
|
518
|
+
* reason: there is no input box yet.
|
|
519
|
+
*
|
|
520
|
+
* `detail` stays a LOCATOR and never repeats the status word, because the app
|
|
521
|
+
* composes its second line as status + detail.
|
|
522
|
+
*/
|
|
523
|
+
function workerRow(worker, agent, recommended) {
|
|
524
|
+
return {
|
|
525
|
+
id: worker.id,
|
|
526
|
+
name: worker.name === "" ? `${nameOf(agent)} · ${worker.id.slice(0, 8)}` : worker.name,
|
|
527
|
+
agent,
|
|
528
|
+
host: "navarch",
|
|
529
|
+
status: statusWord(worker.status),
|
|
530
|
+
detail: worker.status === "needs_input"
|
|
531
|
+
? "Navarch, on a prompt"
|
|
532
|
+
: worker.stage === ""
|
|
533
|
+
? "Navarch"
|
|
534
|
+
: `Navarch · ${worker.stage}`,
|
|
535
|
+
recommended,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function statusWord(status) {
|
|
539
|
+
switch (status) {
|
|
540
|
+
case "waiting":
|
|
541
|
+
case "idle":
|
|
542
|
+
return "idle";
|
|
543
|
+
case "running":
|
|
544
|
+
case "starting":
|
|
545
|
+
case "needs_input":
|
|
546
|
+
return "busy";
|
|
547
|
+
default:
|
|
548
|
+
return "unknown";
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Prose for a person, on top of what argus already wrote. */
|
|
552
|
+
function navarchDetail(outcome, agent, folder) {
|
|
553
|
+
const said = outcome.detail.trim();
|
|
554
|
+
if (outcome.action !== "pending") {
|
|
555
|
+
return said === ""
|
|
556
|
+
? `Navarch ${outcome.action === "launched" ? "started a worker in" : "sent the prompt to a worker in"} ${folder}.`
|
|
557
|
+
: said;
|
|
558
|
+
}
|
|
559
|
+
// The one answer the app must render as a FACT rather than as a failure: a
|
|
560
|
+
// card is up in Navarch and a person has to allow it. Nothing has gone wrong,
|
|
561
|
+
// and nothing has been delivered either.
|
|
562
|
+
const queued = said === "" ? "" : ` (${said})`;
|
|
563
|
+
return (`Navarch is asking you to approve this hand-off${queued}. Nothing has been typed yet: the first `
|
|
564
|
+
+ `delegation on a machine raises a card showing the folder, the ${nameOf(agent)} worker and the `
|
|
565
|
+
+ "line verbatim, and “Always allow” turns on nothing else. Approve it in Navarch, then ask again "
|
|
566
|
+
+ `with \`pharos delegate --host navarch --status ${outcome.request}\`.`);
|
|
567
|
+
}
|
|
568
|
+
function reportOf(outcome) {
|
|
569
|
+
return {
|
|
570
|
+
request: outcome.request,
|
|
571
|
+
worker: outcome.worker,
|
|
572
|
+
name: outcome.name,
|
|
573
|
+
created: outcome.created,
|
|
574
|
+
todo: outcome.todo,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/** A refusal that came off the socket, in this verb's own shape. */
|
|
578
|
+
function refuseFailure(refuse, failure) {
|
|
579
|
+
return refuse(failure.reason, failure.detail, failure.delegation === undefined ? undefined : reportOf(failure.delegation));
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* A socket that was not there, or a conversation that broke.
|
|
583
|
+
*
|
|
584
|
+
* The split is the CLI's ordinary one and it earns its keep here: **nothing
|
|
585
|
+
* listening is a fact about the machine with a fix on it** — open Navarch, and
|
|
586
|
+
* argusd comes with it — so it is a refusal on stdout. A park that outlived its
|
|
587
|
+
* own deadline, or a line that is not JSON, is a failure and takes stderr and
|
|
588
|
+
* exit 1. Reporting the second as the first would tell somebody to open an app
|
|
589
|
+
* that is already open.
|
|
590
|
+
*/
|
|
591
|
+
function socketFailure(error, refuse) {
|
|
592
|
+
if (error instanceof ArgusUnreachableError)
|
|
593
|
+
return refuseFailure(refuse, unreachable(error));
|
|
594
|
+
if (error instanceof CliError)
|
|
595
|
+
throw error;
|
|
596
|
+
throw new CliError(`The Argus socket did not answer: ${messageOf(error)}`, "internal", EXIT_FAILED, {
|
|
597
|
+
host: "navarch",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The prompt, held to what the wire takes: ONE line of printable characters.
|
|
602
|
+
*
|
|
603
|
+
* The daemon refuses anything else (`bad-text`) and is right to: the receiving
|
|
604
|
+
* TUI submits on Enter and Navarch owns that Enter, so a payload carrying its
|
|
605
|
+
* own control codes is a remote control rather than a message. Checked here so
|
|
606
|
+
* the refusal names the flag the caller passed rather than arriving as a wire
|
|
607
|
+
* error about a field — and **never silently flattened**, because a prompt
|
|
608
|
+
* rewritten on its way out is a prompt nobody proof-read.
|
|
609
|
+
*/
|
|
610
|
+
function navarchText(text) {
|
|
611
|
+
if (CONTROL.test(text)) {
|
|
612
|
+
throw usageError("Navarch takes the prompt as ONE line: the worker's TUI submits on Enter and Navarch owns that "
|
|
613
|
+
+ "Enter, so a prompt carrying its own newlines or tabs would arrive as several messages, or as "
|
|
614
|
+
+ "none. Fold it into one line — pharos will not do that for you, because a prompt rewritten on "
|
|
615
|
+
+ "its way out is a prompt nobody proof-read. (Terminal and the VS Code hosts take several "
|
|
616
|
+
+ "lines; they paste rather than type.)", { host: "navarch", lines: text.split("\n").length });
|
|
617
|
+
}
|
|
618
|
+
if (text.length > ARGUS_TEXT_LIMIT) {
|
|
619
|
+
throw usageError(`Navarch takes at most ${ARGUS_TEXT_LIMIT} characters in one line, and this prompt is `
|
|
620
|
+
+ `${text.length}. Shorten it, or point the agent at the work item and let it fetch the detail `
|
|
621
|
+
+ "itself — which is what the app's own prompt does.", { host: "navarch", characters: text.length, limit: ARGUS_TEXT_LIMIT });
|
|
622
|
+
}
|
|
623
|
+
return text;
|
|
624
|
+
}
|
|
625
|
+
/** `realpath`, falling back to the path as given when it cannot be resolved. */
|
|
626
|
+
async function canonicalFolder(folder) {
|
|
627
|
+
try {
|
|
628
|
+
return await realpath(folder);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return folder;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
266
634
|
/**
|
|
267
635
|
* The `session-gone` detail: what was asked for, what is actually there, and
|
|
268
636
|
* why nothing was started instead.
|
|
637
|
+
*
|
|
638
|
+
* **`all` is every session on the folder, not the ranked ones**, because the
|
|
639
|
+
* most useful thing this can say is often that the pid is alive somewhere
|
|
640
|
+
* else. "It is gone" and "it is running in Navarch, not in Terminal" lead to
|
|
641
|
+
* completely different next moves, and the ranked list — filtered to the
|
|
642
|
+
* requested host — cannot tell them apart.
|
|
269
643
|
*/
|
|
270
|
-
function sessionGone(pid, agent, host, ranked, incomplete, probeDetail) {
|
|
644
|
+
function sessionGone(pid, agent, host, ranked, all, incomplete, probeDetail) {
|
|
271
645
|
const agentName = nameOf(agent);
|
|
646
|
+
const found = all.find((session) => session.pid === pid);
|
|
647
|
+
const what = found === undefined
|
|
648
|
+
? `Session ${pid} is no longer a ${agentName} session on this folder — it exited, moved, or the list `
|
|
649
|
+
+ "it came from is stale."
|
|
650
|
+
: found.host !== host
|
|
651
|
+
? `Session ${pid} is running on this folder, but in `
|
|
652
|
+
+ `${found.host === null ? "an application this cannot delegate to" : HOSTS[found.host].name}, not in `
|
|
653
|
+
+ `${HOSTS[host].name} — ask for that host instead.`
|
|
654
|
+
: `Session ${pid} is running on this folder in ${HOSTS[host].name}, but it holds no controlling tty, `
|
|
655
|
+
+ "so there is nothing to type into.";
|
|
272
656
|
const now = ranked.length === 0
|
|
273
|
-
? `No ${agentName} session
|
|
657
|
+
? `No ${agentName} session can be delivered to in ${HOSTS[host].name} on this folder`
|
|
274
658
|
: `What is there now: ${ranked.map((session) => `${displayName(session)} (pid ${session.pid})`).join(", ")}`;
|
|
275
|
-
return (
|
|
276
|
-
+
|
|
277
|
-
+ "session
|
|
278
|
-
+ "with `pharos delegate --list`, or pass `--session new` to start a fresh session on purpose."
|
|
659
|
+
return (`${what} ${now}. Nothing was sent and nothing was launched: a chosen session is a choice, so this refuses `
|
|
660
|
+
+ "rather than quietly starting a second one. Re-read the inventory with `pharos delegate --list`, or pass "
|
|
661
|
+
+ "`--session new` to start a fresh session on purpose."
|
|
279
662
|
+ (incomplete
|
|
280
663
|
? ` Detection was also incomplete (${probeDetail ?? "a probe failed"}), so the session may be running and unseeable.`
|
|
281
664
|
: ""));
|
|
@@ -326,6 +709,25 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
|
|
|
326
709
|
+ (extensions === null ? ", which does not exist: that VS Code has never run" : "")
|
|
327
710
|
+ ". `pharos setup --install vscode-bridge` installs the one this pharos ships.");
|
|
328
711
|
}
|
|
712
|
+
// **A bridge older than `--session` does not ignore it — it refuses the whole
|
|
713
|
+
// URI.** It rejects any query key it does not know (the guard that makes a
|
|
714
|
+
// single-encoded `&` in a prompt loud instead of silently truncating one), so
|
|
715
|
+
// an unexpected `session` trips it and the prompt never reaches the editor.
|
|
716
|
+
// Nothing comes back from VS Code to say so — `open` succeeds either way — so
|
|
717
|
+
// without this check the verb answers `sent` about a message that was thrown
|
|
718
|
+
// away, and the app tells the person it was delivered. Every machine that
|
|
719
|
+
// installed the bridge before this release carries one of these.
|
|
720
|
+
//
|
|
721
|
+
// `bridge-not-installed` rather than a new reason id: the fix the app already
|
|
722
|
+
// renders for it — `pharos setup --install vscode-bridge` — is exactly the
|
|
723
|
+
// fix for this, and a reason id is a contract the app decodes.
|
|
724
|
+
if (pick !== undefined && !bridgeAtLeast(bridge.version, SESSION_BRIDGE_VERSION)) {
|
|
725
|
+
return refuse("bridge-not-installed", `The Pharos bridge installed in ${name} is ${bridge.version}, which predates \`--session\` — that `
|
|
726
|
+
+ `arrived in ${SESSION_BRIDGE_VERSION}. It would not ignore the session in the URI: it refuses `
|
|
727
|
+
+ "any query key it does not know, so the prompt would never reach the editor and this would have "
|
|
728
|
+
+ "reported it as sent. `pharos setup --install vscode-bridge` replaces it with the one this pharos "
|
|
729
|
+
+ "ships. Delegating WITHOUT --session works with the bridge you have.");
|
|
730
|
+
}
|
|
329
731
|
const claudeExt = extensions?.find((extension) => extension.id === CLAUDE_EXTENSION_ID);
|
|
330
732
|
const usesExtensionBinary = mode === "extension" && agent === "claude" && claudeExt !== undefined;
|
|
331
733
|
// **The extension route needs no agent on PATH.** The Claude Code extension
|
|
@@ -359,7 +761,7 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
|
|
|
359
761
|
else {
|
|
360
762
|
session = ranked.find((found) => found.pid === pick.pid) ?? null;
|
|
361
763
|
if (session === null) {
|
|
362
|
-
return refuse("session-gone", sessionGone(pick.pid, agent, host, ranked, incomplete, probeDetail));
|
|
764
|
+
return refuse("session-gone", sessionGone(pick.pid, agent, host, ranked, sessions, incomplete, probeDetail));
|
|
363
765
|
}
|
|
364
766
|
}
|
|
365
767
|
const uri = delegateUri(spec.vscode.scheme, { folder, agent, mode, text, session: pick });
|
|
@@ -434,7 +836,7 @@ async function delegateToTerminal(agent, folder, text, agentPath, env, deps, dry
|
|
|
434
836
|
else {
|
|
435
837
|
session = ranked.find((found) => found.pid === pick.pid) ?? null;
|
|
436
838
|
if (session === null) {
|
|
437
|
-
return refuse("session-gone", sessionGone(pick.pid, agent, "terminal", ranked, incomplete, probeDetail));
|
|
839
|
+
return refuse("session-gone", sessionGone(pick.pid, agent, "terminal", ranked, sessions, incomplete, probeDetail));
|
|
438
840
|
}
|
|
439
841
|
}
|
|
440
842
|
const agentName = nameOf(agent);
|