@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.
- package/README.md +223 -9
- package/dist/capabilities.d.ts +27 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +144 -41
- package/dist/capabilities.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +79 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/delegate.d.ts +211 -13
- package/dist/commands/delegate.d.ts.map +1 -1
- package/dist/commands/delegate.js +798 -52
- package/dist/commands/delegate.js.map +1 -1
- package/dist/commands/doctor.d.ts +30 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +382 -39
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/pr.d.ts +56 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +202 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/setup.js +21 -3
- package/dist/commands/setup.js.map +1 -1
- package/dist/delegate/argus.d.ts +436 -0
- package/dist/delegate/argus.d.ts.map +1 -0
- package/dist/delegate/argus.js +709 -0
- package/dist/delegate/argus.js.map +1 -0
- package/dist/delegate/vsix.d.ts +26 -0
- package/dist/delegate/vsix.d.ts.map +1 -1
- package/dist/delegate/vsix.js +22 -5
- package/dist/delegate/vsix.js.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +44 -10
- package/vscode/pharos-bridge.json +1 -1
- 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_NEW_WORKER, ARGUS_REASONS, ARGUS_TEXT_LIMIT, ARGUS_WAIT_SECONDS, ArgusUnreachableError, askArgus, candidatesSupport, delegateCandidatesRequest, delegateRequest, delegateStatusRequest, fleetStatusRequest, isFailure, mapRefusal, parseCandidates, parseFleet, rankWorkers, readDelegation, readTimeoutMs, 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, bridgeAtLeast, CLAUDE_EXTENSION_ID, extensionsDirectory, installedExtensions, SESSION_BRIDGE_VERSION, } from "../delegate/vsix.js";
|
|
9
|
+
import { BRIDGE_EXTENSION_ID, bridgeAtLeast, bundledBridge, bundledVsixPath, CLAUDE_EXTENSION_ID, extensionsDirectory, installBridge, 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);
|
|
@@ -20,14 +21,47 @@ export const REASONS = [
|
|
|
20
21
|
"automation-denied",
|
|
21
22
|
"folder-missing",
|
|
22
23
|
"unsupported",
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* `--session <id>` named a session that is no longer a target on this folder.
|
|
26
|
+
*
|
|
27
|
+
* On Navarch it is also what the daemon's `worker-not-eligible` becomes: the
|
|
28
|
+
* row you picked is not one this machine will deliver to, whether it exited,
|
|
29
|
+
* took a mission, or was never a candidate. Same fact, same fix — read the
|
|
30
|
+
* list again and pick from it.
|
|
31
|
+
*/
|
|
24
32
|
"session-gone",
|
|
33
|
+
// **`session-not-targetable` was here and is gone** (#1412). It said the
|
|
34
|
+
// `delegate` op could not express a target worker at all; argus #1402 added
|
|
35
|
+
// `worker`, so no call can produce it any more. A daemon that predates that
|
|
36
|
+
// field is `argusd-outdated`, which is the truth and carries the fix (reload
|
|
37
|
+
// the daemon) — where this one carried none.
|
|
38
|
+
//
|
|
39
|
+
// …and the Argus socket's own, each naming a different fix. See
|
|
40
|
+
// `delegate/argus.js` — they are defined there because that is where the
|
|
41
|
+
// daemon's reasons are mapped onto them.
|
|
42
|
+
...ARGUS_REASONS,
|
|
25
43
|
];
|
|
44
|
+
/**
|
|
45
|
+
* A second, narrower axis on a refusal: WHICH shape of a reason this is.
|
|
46
|
+
*
|
|
47
|
+
* One value so far, and it exists because one `bridge-not-installed` now has a
|
|
48
|
+
* different fix from the others. The app renders that reason with an Install
|
|
49
|
+
* button; `stale-window` means the install has already happened and what is
|
|
50
|
+
* needed is a window reload, so the app can swap the button for a reload hint
|
|
51
|
+
* (#1409, #1413) without reading the prose.
|
|
52
|
+
*
|
|
53
|
+
* **Optional, and absent on every refusal that does not need it.** A decoder
|
|
54
|
+
* that has never seen it keeps working — which is what lets the app adopt it
|
|
55
|
+
* whenever it gets to it rather than in lockstep with this release.
|
|
56
|
+
*/
|
|
57
|
+
export const CAUSES = ["stale-window"];
|
|
26
58
|
export const SYSTEM_DEPS = {
|
|
27
59
|
findApp,
|
|
28
60
|
resolveOnPath,
|
|
29
61
|
probes: systemProbes,
|
|
30
62
|
installedExtensions,
|
|
63
|
+
bundledBridge,
|
|
64
|
+
installBridge: (host, env) => installBridge({ env, hosts: [host] }),
|
|
31
65
|
osascript: async (source, args) => {
|
|
32
66
|
// `--` ends option parsing, so a prompt that begins with a dash is an
|
|
33
67
|
// argument and not a flag. Every variable value travels as argv; nothing
|
|
@@ -49,6 +83,7 @@ export const SYSTEM_DEPS = {
|
|
|
49
83
|
// never appears in `claude agents --json`. Scrubbed here at the source.
|
|
50
84
|
await run("/usr/bin/open", [uri], { timeout: 30_000, env: childEnv(process.env) });
|
|
51
85
|
},
|
|
86
|
+
argus: systemArgusDeps,
|
|
52
87
|
};
|
|
53
88
|
/**
|
|
54
89
|
* The environment to hand a launched app or shell, with this session's
|
|
@@ -74,8 +109,42 @@ export function childEnv(env) {
|
|
|
74
109
|
}
|
|
75
110
|
return clean;
|
|
76
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Navarch is the only host whose ids are worker uuids, and its route has
|
|
114
|
+
* already returned by the time either pid host runs — so a pick reaching them
|
|
115
|
+
* can only be a pid or `new`.
|
|
116
|
+
*
|
|
117
|
+
* A guard rather than a cast, because the day that stops being true this says
|
|
118
|
+
* so here instead of handing `undefined` to a `find` and reporting the wrong
|
|
119
|
+
* session as gone.
|
|
120
|
+
*/
|
|
121
|
+
function tabPick(pick) {
|
|
122
|
+
if (pick === undefined || pick.kind !== "worker")
|
|
123
|
+
return pick;
|
|
124
|
+
throw new CliError(`--session ${pick.id} is a Navarch worker id, and this route delivers by process id.`, "internal", EXIT_FAILED);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The mirror of {@link tabPick}: {@link sessionPick} parses a navarch `--session`
|
|
128
|
+
* as a uuid or `new` and nothing else, so a pid cannot reach that route.
|
|
129
|
+
*
|
|
130
|
+
* A guard rather than a cast for the same reason as its twin — the day the
|
|
131
|
+
* parse changes, this says so here instead of sending a process id to argusd
|
|
132
|
+
* as a worker uuid and getting `bad-worker` back about a value nobody typed.
|
|
133
|
+
*/
|
|
134
|
+
function workerPick(pick) {
|
|
135
|
+
if (pick === undefined || pick.kind !== "pid")
|
|
136
|
+
return pick;
|
|
137
|
+
throw new CliError(`--session ${pick.pid} is a process id, and Navarch delivers by worker uuid.`, "internal", EXIT_FAILED);
|
|
138
|
+
}
|
|
77
139
|
/** The reserved id. `--list` never prints it, so it can never shadow a real session. */
|
|
78
140
|
export const NEW_SESSION = "new";
|
|
141
|
+
/**
|
|
142
|
+
* Any C0 or C1 control character, which is what the daemon refuses `text` for
|
|
143
|
+
* (`CharacterSet.controlCharacters`) — a tab as much as a newline.
|
|
144
|
+
*/
|
|
145
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
146
|
+
/** A worker id as `fleet_status` spells it — canonical uuid, upper case. */
|
|
147
|
+
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
148
|
/**
|
|
80
149
|
* `--session <id>` → a pick, or a usage error.
|
|
81
150
|
*
|
|
@@ -94,13 +163,58 @@ export function sessionPick(value, host) {
|
|
|
94
163
|
}
|
|
95
164
|
if (given === NEW_SESSION)
|
|
96
165
|
return { kind: "new" };
|
|
166
|
+
// **Navarch's ids are worker uuids**, so a uuid is WELL FORMED there and a
|
|
167
|
+
// pid is not. That distinction is the whole point of parsing per host: an id
|
|
168
|
+
// this verb could have printed must reach the route and be answered with a
|
|
169
|
+
// fact about the machine, and only an id it could never have printed is the
|
|
170
|
+
// caller getting the call wrong.
|
|
171
|
+
if (host === "navarch") {
|
|
172
|
+
if (!UUID.test(given)) {
|
|
173
|
+
throw usageError(`--session takes the \`id\` \`pharos delegate --list\` printed, which in ${HOSTS[host].name} is a worker `
|
|
174
|
+
+ `UUID, or the reserved ${NEW_SESSION} — not ${JSON.stringify(given)}.`, { session: given, host });
|
|
175
|
+
}
|
|
176
|
+
return { kind: "worker", id: given.toUpperCase() };
|
|
177
|
+
}
|
|
97
178
|
if (!/^[0-9]+$/.test(given) || !Number.isSafeInteger(Number(given)) || Number(given) <= 0) {
|
|
98
179
|
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
|
-
+ "
|
|
180
|
+
+ `id, or the reserved ${NEW_SESSION} — not ${JSON.stringify(given)}. (Navarch's ids are worker UUIDs; `
|
|
181
|
+
+ "pass --host navarch for those.)", { session: given, host });
|
|
101
182
|
}
|
|
102
183
|
return { kind: "pid", pid: Number(given) };
|
|
103
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* `--gh-account <login>` → the value to send, or a usage error.
|
|
187
|
+
*
|
|
188
|
+
* **A usage error and never a refusal**, the same split `--session` makes: a
|
|
189
|
+
* refusal is a fact about this machine that the app renders with a fix beside
|
|
190
|
+
* it, and "you passed an empty string" is not a fact about the machine. The
|
|
191
|
+
* one refusal this flag can produce is `gh-account-unknown`, and it comes off
|
|
192
|
+
* the socket — the daemon holds the accounts, so only the daemon can say an
|
|
193
|
+
* account is not one of them.
|
|
194
|
+
*
|
|
195
|
+
* **A login-shaped token: no whitespace.** `gh` logins have none, and neither
|
|
196
|
+
* does any spelling this CLI derives — it resolves a project folder's origin
|
|
197
|
+
* against `repos.json` and gets a login. Navarch will ALSO match a human's
|
|
198
|
+
* account *label*, and a label may well contain a space; such a label cannot
|
|
199
|
+
* be named through this flag, and the login always can. That is the trade, and
|
|
200
|
+
* it is the right way round: a value with a space in it reaching argusd would
|
|
201
|
+
* be one quoting mistake away from arriving as two.
|
|
202
|
+
*/
|
|
203
|
+
export function ghAccountOption(value) {
|
|
204
|
+
if (value === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
const given = value.trim();
|
|
207
|
+
if (given === "") {
|
|
208
|
+
throw usageError("--gh-account needs a GitHub login, or the name of an account bound in Navarch ▸ Accounts. "
|
|
209
|
+
+ "Leave the flag off to let the session run as whoever this machine is logged in as.");
|
|
210
|
+
}
|
|
211
|
+
if (/\s/.test(given) || CONTROL.test(given)) {
|
|
212
|
+
throw usageError(`--gh-account takes one login-shaped token and ${JSON.stringify(value)} is not one: a GitHub `
|
|
213
|
+
+ "login has no spaces. (Navarch also matches an account's label, which may have one — pass "
|
|
214
|
+
+ "the login instead; it always resolves.)", { ghAccount: value });
|
|
215
|
+
}
|
|
216
|
+
return given;
|
|
217
|
+
}
|
|
104
218
|
export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
105
219
|
const host = oneOf(options.host, "--host", HOST_IDS, isHostId);
|
|
106
220
|
const agent = oneOf(options.agent, "--agent", AGENT_IDS, isAgentId);
|
|
@@ -113,11 +227,35 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
113
227
|
throw usageError("--list and --session are different questions: --list reports what is running and sends nothing, "
|
|
114
228
|
+ "--session sends to one of the ids it printed. Pass one.");
|
|
115
229
|
}
|
|
230
|
+
const status = (options.status ?? "").trim();
|
|
231
|
+
const asking = options.status !== undefined;
|
|
232
|
+
if (asking && (options.list || options.session !== undefined)) {
|
|
233
|
+
throw usageError("--status asks what became of a delegation that is already in flight, so it takes neither "
|
|
234
|
+
+ "--list nor --session. Pass the request id on its own.");
|
|
235
|
+
}
|
|
236
|
+
if (asking && host !== "navarch") {
|
|
237
|
+
throw usageError(`--status is a Navarch question: it is the only route that can answer \`pending\`, because it is `
|
|
238
|
+
+ `the only one where a person may have to approve the hand-off first. ${HOSTS[host].name} `
|
|
239
|
+
+ "answers sent or launched on the call itself.", { host });
|
|
240
|
+
}
|
|
241
|
+
if (asking && status === "") {
|
|
242
|
+
throw usageError("--status needs the `request` id the delegation answered with.");
|
|
243
|
+
}
|
|
116
244
|
const pick = sessionPick(options.session, host);
|
|
245
|
+
// **Accepted everywhere, honoured only by Navarch.** It is validated for
|
|
246
|
+
// shape on every host so a typo is caught where it was typed; what happens
|
|
247
|
+
// to it after that is the route's, and for the three hosts that cannot bind
|
|
248
|
+
// an identity it is a sentence on the outcome rather than a refusal. Only
|
|
249
|
+
// Navarch STARTS a session on the caller's behalf — a Terminal window and a
|
|
250
|
+
// VS Code terminal both inherit whatever `gh` this machine is logged in as,
|
|
251
|
+
// and there is nothing this verb could do about that short of writing to
|
|
252
|
+
// somebody's keyring.
|
|
253
|
+
const ghAccount = ghAccountOption(options.ghAccount);
|
|
117
254
|
// **`--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.
|
|
255
|
+
// message it is only going to throw away to refresh a menu. Nor does
|
|
256
|
+
// `--status`, which is a read about a delegation whose text was sent already.
|
|
119
257
|
const text = (options.text ?? "").replace(/\r\n/g, "\n").trim();
|
|
120
|
-
if (text === "" && !options.list) {
|
|
258
|
+
if (text === "" && !options.list && !asking) {
|
|
121
259
|
throw usageError("Nothing to send. Pass the prompt with --text, --file or --stdin.");
|
|
122
260
|
}
|
|
123
261
|
const folder = (options.folder ?? "").trim();
|
|
@@ -127,13 +265,43 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
127
265
|
throw usageError(`--folder must be absolute, got ${JSON.stringify(folder)}.`, { folder });
|
|
128
266
|
}
|
|
129
267
|
const base = { host, agent, mode, folder };
|
|
130
|
-
|
|
268
|
+
/**
|
|
269
|
+
* What `--gh-account` did on a host that cannot honour it: nothing, said out
|
|
270
|
+
* loud. Appended in ONE place rather than by each route, because a route
|
|
271
|
+
* that forgot it would be the silent case this sentence exists to prevent —
|
|
272
|
+
* and the app renders `detail` verbatim, so a value that travelled and did
|
|
273
|
+
* nothing has to be in it. `--list` and `--status` are not sends and do not
|
|
274
|
+
* get it; they never reach {@link succeed} with anything to say about a
|
|
275
|
+
* session this call started.
|
|
276
|
+
*/
|
|
277
|
+
const accountNote = ghAccount === undefined || host === "navarch"
|
|
278
|
+
? ""
|
|
279
|
+
: ` The GitHub account “${ghAccount}” was not applied: only Navarch starts a session on your `
|
|
280
|
+
+ `behalf and can bind one to it. A ${HOSTS[host].name} session runs as whichever account `
|
|
281
|
+
+ "`gh` on this machine is logged in as.";
|
|
282
|
+
const refuse = (reason, detail, extra = {}) => emitRefusal(io, options.pretty, {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason,
|
|
285
|
+
...(extra.cause === undefined ? {} : { cause: extra.cause }),
|
|
286
|
+
detail,
|
|
287
|
+
...base,
|
|
288
|
+
...(extra.delegation === undefined ? {} : { delegation: extra.delegation }),
|
|
289
|
+
});
|
|
131
290
|
const succeed = (outcome) => emitOutcome(io, options.pretty, {
|
|
132
291
|
ok: true,
|
|
133
292
|
...base,
|
|
134
293
|
...outcome,
|
|
294
|
+
detail: outcome.detail + accountNote,
|
|
135
295
|
...(options.dryRun ? { dryRun: true } : {}),
|
|
136
296
|
});
|
|
297
|
+
// **`--status` asks the daemon, and nothing else.** It deliberately skips
|
|
298
|
+
// both checks below: the folder may well have been unlinked in the minutes a
|
|
299
|
+
// person spent deciding at the approval card, and Navarch itself need not be
|
|
300
|
+
// running — the row it reads is argusd's own. Refusing a status read for
|
|
301
|
+
// either would leave the one delegation that mattered permanently
|
|
302
|
+
// unobservable, which is the failure `delegate_status` exists to prevent.
|
|
303
|
+
if (asking)
|
|
304
|
+
return navarchStatus(io, env, status, base, deps, refuse, succeed);
|
|
137
305
|
// Cheapest fact first: nothing else is worth asking about a folder that is
|
|
138
306
|
// not there, and it is the one refusal that is entirely the caller's to fix.
|
|
139
307
|
const info = await stat(folder).catch(() => null);
|
|
@@ -147,23 +315,14 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
147
315
|
+ "Spotlight and at the usual paths under /Applications and ~/Applications."
|
|
148
316
|
+ (HOSTS[host].cask === undefined ? "" : ` \`brew install --cask ${HOSTS[host].cask}\` installs it.`));
|
|
149
317
|
}
|
|
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.
|
|
318
|
+
// **Navarch takes both verbs before the agent is even resolved**, and that
|
|
319
|
+
// is not an oversight. Nothing here runs the agent: argusd spawns a worker
|
|
320
|
+
// through Navarch, from the app's own login shell and its own PATH. A
|
|
321
|
+
// `resolveOnPath` refusal would report a fact about THIS process's PATH as
|
|
322
|
+
// though it were a fact about the machine — and a GUI-launched Pharos has
|
|
323
|
+
// roughly `/usr/bin:/bin`, so it would refuse on nearly every real call.
|
|
160
324
|
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.");
|
|
325
|
+
return delegateToNavarch(io, env, agent, folder, text, base, options, workerPick(pick), ghAccount, deps, refuse, succeed);
|
|
167
326
|
}
|
|
168
327
|
// **May be null, and that is not always a refusal.** The Terminal route runs
|
|
169
328
|
// the agent by this path, so it must exist there; the VS Code extension route
|
|
@@ -174,11 +333,11 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
|
|
|
174
333
|
return listSessions(io, host, agent, mode, folder, agentPath, env, deps, options.pretty, refuse);
|
|
175
334
|
}
|
|
176
335
|
if (host === "vscode" || host === "vscode-insiders") {
|
|
177
|
-
return delegateToVsCode(host, agent, mode, folder, text, agentPath, env, deps, options.dryRun, pick, refuse, succeed);
|
|
336
|
+
return delegateToVsCode(host, agent, mode, folder, text, agentPath, env, deps, options.dryRun, tabPick(pick), refuse, succeed);
|
|
178
337
|
}
|
|
179
338
|
if (agentPath === null)
|
|
180
339
|
return refuse("agent-not-installed", agentNotInstalled(agent, env));
|
|
181
|
-
return delegateToTerminal(agent, folder, text, agentPath, env, deps, options.dryRun, pick, refuse, succeed);
|
|
340
|
+
return delegateToTerminal(agent, folder, text, agentPath, env, deps, options.dryRun, tabPick(pick), refuse, succeed);
|
|
182
341
|
}
|
|
183
342
|
/**
|
|
184
343
|
* `--list` — the inventory, and nothing else.
|
|
@@ -263,6 +422,442 @@ function displayName(session) {
|
|
|
263
422
|
function nameOf(agent) {
|
|
264
423
|
return agent === "claude" ? "Claude" : "Codex";
|
|
265
424
|
}
|
|
425
|
+
// MARK: - Navarch, over the Argus socket
|
|
426
|
+
/**
|
|
427
|
+
* `--host navarch` — the send, the inventory and the dry run.
|
|
428
|
+
*
|
|
429
|
+
* All three go through one function because all three rest on the same read,
|
|
430
|
+
* and which of them needs it is the whole contract:
|
|
431
|
+
*
|
|
432
|
+
* 1. **`delegate_candidates`** — the find, asked rather than performed
|
|
433
|
+
* (argus #1402). It answers two questions at once, and both matter here:
|
|
434
|
+
* *who is on this folder*, as the daemon's own ranked list rather than as
|
|
435
|
+
* this CLI's prediction of it; and *does this daemon read a target at
|
|
436
|
+
* all*, since the op landed in the same change as `worker` and
|
|
437
|
+
* `gh_account`. A daemon that answers `unknown-op` serves neither, and it
|
|
438
|
+
* would IGNORE both rather than refuse them — so `--session` and
|
|
439
|
+
* `--gh-account` are refused `argusd-outdated` there with nothing sent,
|
|
440
|
+
* while `--list` falls back to `fleet_status` and says so in `source`.
|
|
441
|
+
* 2. …and nothing else. **A plain send asks for none of it**: one round trip,
|
|
442
|
+
* the daemon does the find that counts, and its own answer says where the
|
|
443
|
+
* prompt landed.
|
|
444
|
+
*
|
|
445
|
+
* A pick is deliberately **not** re-checked against that list before a real
|
|
446
|
+
* send. The daemon validates `worker` against the very predicate the list came
|
|
447
|
+
* from — one rule, three callers — and its refusal names which of six reasons
|
|
448
|
+
* it was. A second copy of the rule here could only disagree with it, and the
|
|
449
|
+
* disagreeing copy is the one nobody is looking at.
|
|
450
|
+
*/
|
|
451
|
+
async function delegateToNavarch(io, env, agent, folder, text, base, options, pick, ghAccount, deps, refuse, succeed) {
|
|
452
|
+
const kind = agent;
|
|
453
|
+
const argus = deps.argus(env);
|
|
454
|
+
// The daemon's own compare resolves `~`, `.`/`..` and the three symlinked
|
|
455
|
+
// prefixes macOS ships — and no others. A folder reached through a symlink
|
|
456
|
+
// somebody made would miss every worker on it, so it is canonicalised here,
|
|
457
|
+
// exactly as the Terminal route canonicalises before matching a cwd.
|
|
458
|
+
const directory = await canonicalFolder(folder);
|
|
459
|
+
/** Does this call carry a field argus #1402 added? Both are ignored by an older daemon. */
|
|
460
|
+
const aims = pick !== undefined || ghAccount !== undefined;
|
|
461
|
+
try {
|
|
462
|
+
const found = options.list || options.dryRun || aims
|
|
463
|
+
? await navarchCandidates(argus, kind, directory)
|
|
464
|
+
: null;
|
|
465
|
+
if (found !== null && isFailure(found))
|
|
466
|
+
return refuseFailure(refuse, found);
|
|
467
|
+
const ranked = found?.workers ?? [];
|
|
468
|
+
const target = found?.target ?? null;
|
|
469
|
+
// `found!` below, three times and for one reason: `--list`, `--dry-run` and
|
|
470
|
+
// a call that `aims` are exactly the three that set it, so inside each of
|
|
471
|
+
// them the read has happened. A plain send is the only path where it is
|
|
472
|
+
// null, and it returns before any of them.
|
|
473
|
+
if (options.list)
|
|
474
|
+
return emitNavarchList(io, base, agent, found, options.pretty);
|
|
475
|
+
// **The version gate, and it is the reason a pick costs a round trip.**
|
|
476
|
+
// `delegate` ignores a key it does not know, so on a daemon that predates
|
|
477
|
+
// #1402 a `worker` is discarded and the find runs anyway — `sent`, about a
|
|
478
|
+
// pane the person did not choose — and a `gh_account` is discarded too,
|
|
479
|
+
// leaving a fresh worker running as whoever `gh` last logged in as. Both
|
|
480
|
+
// are exactly what these flags exist to prevent, and neither is visible in
|
|
481
|
+
// the reply, so the only place to catch it is here, before the send.
|
|
482
|
+
if (aims && found !== null && found.source === "fleet_status") {
|
|
483
|
+
return refuse("argusd-outdated", targetUnsupported(pick, ghAccount));
|
|
484
|
+
}
|
|
485
|
+
const oneLine = navarchText(text);
|
|
486
|
+
if (options.dryRun) {
|
|
487
|
+
const picked = pick === undefined
|
|
488
|
+
? headOf(ranked, target)
|
|
489
|
+
: pick.kind === "new"
|
|
490
|
+
? null
|
|
491
|
+
: (ranked.find((worker) => worker.id === pick.id) ?? null);
|
|
492
|
+
// The one refusal a dry run can predict exactly, and only because the
|
|
493
|
+
// gate above has already run: a named worker means `aims`, so the list
|
|
494
|
+
// in hand is `delegate_candidates`' own — the very set `delegate`
|
|
495
|
+
// validates `worker` against. A uuid missing from it is a uuid the send
|
|
496
|
+
// would refuse `worker-not-eligible`.
|
|
497
|
+
if (pick?.kind === "worker" && picked === null) {
|
|
498
|
+
return refuse("session-gone", wouldBeGone(pick.id, ranked, agent));
|
|
499
|
+
}
|
|
500
|
+
return succeed({
|
|
501
|
+
action: picked === null ? "launched" : "sent",
|
|
502
|
+
session: null,
|
|
503
|
+
sessions: rowsOf(ranked, agent, target),
|
|
504
|
+
source: found.source,
|
|
505
|
+
detail: dryRunDetail(picked, pick, ranked, agent, folder, ghAccount, found.source),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
const reply = await askArgus(argus, delegateRequest({
|
|
509
|
+
directory,
|
|
510
|
+
kind,
|
|
511
|
+
text: oneLine,
|
|
512
|
+
...(pick === undefined
|
|
513
|
+
? {}
|
|
514
|
+
: { worker: pick.kind === "new" ? ARGUS_NEW_WORKER : pick.id }),
|
|
515
|
+
...(ghAccount === undefined ? {} : { ghAccount }),
|
|
516
|
+
}), readTimeoutMs(ARGUS_WAIT_SECONDS));
|
|
517
|
+
const outcome = readDelegation(reply);
|
|
518
|
+
if (isFailure(outcome))
|
|
519
|
+
return refuseFailure(refuse, outcome);
|
|
520
|
+
return succeed({
|
|
521
|
+
action: outcome.action,
|
|
522
|
+
session: null,
|
|
523
|
+
detail: navarchDetail(outcome, agent, folder),
|
|
524
|
+
delegation: reportOf(outcome),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
return socketFailure(error, refuse);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** `--status <request>` — what became of a delegation that answered `pending`. */
|
|
532
|
+
async function navarchStatus(io, env, request, base, deps, refuse, succeed) {
|
|
533
|
+
void io;
|
|
534
|
+
try {
|
|
535
|
+
// **`wait` is 0 here, deliberately.** The daemon will park a status read
|
|
536
|
+
// until the action settles, and that is the right tool for a program that
|
|
537
|
+
// can afford to sit still — but this one is called by an app refreshing a
|
|
538
|
+
// row, and a call that blocks for a minute is a spinner nobody asked for.
|
|
539
|
+
// The app polls instead; argus keeps the row for an hour, which is sized
|
|
540
|
+
// for a person at lunch rather than for a park.
|
|
541
|
+
const reply = await askArgus(deps.argus(env), delegateStatusRequest(request, 0), readTimeoutMs(0));
|
|
542
|
+
const outcome = readDelegation(reply);
|
|
543
|
+
if (isFailure(outcome))
|
|
544
|
+
return refuseFailure(refuse, outcome);
|
|
545
|
+
return succeed({
|
|
546
|
+
action: outcome.action,
|
|
547
|
+
session: null,
|
|
548
|
+
detail: navarchDetail(outcome, base.agent, outcome.directory === "" ? base.folder : outcome.directory),
|
|
549
|
+
delegation: reportOf(outcome),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
return socketFailure(error, refuse);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* One `delegate_candidates` read, with the fallback an un-reloaded daemon
|
|
558
|
+
* needs — or the refusal that stopped it.
|
|
559
|
+
*
|
|
560
|
+
* Three daemons answer this, and `candidatesSupport` tells them apart by the
|
|
561
|
+
* `reason` KEY rather than by prose:
|
|
562
|
+
*
|
|
563
|
+
* - **it served the read** → the rows are the daemon's own, ranked by the
|
|
564
|
+
* daemon's own rule, and every #1402 field is honoured;
|
|
565
|
+
* - **`unknown-op`** → it serves `delegate` and predates #1402. `fleet_status`
|
|
566
|
+
* answers on every generation of argusd, so the list is still worth having —
|
|
567
|
+
* as this CLI's prediction, marked `fleet_status`;
|
|
568
|
+
* - **no `reason` at all** → it predates `delegate` itself, which
|
|
569
|
+
* {@link mapRefusal} already turns into `argusd-outdated`. Listing workers a
|
|
570
|
+
* send is about to refuse to touch would be worse than saying so.
|
|
571
|
+
*
|
|
572
|
+
* `bad-directory` and `bad-kind` are mapped like any other refusal: the read
|
|
573
|
+
* validates them exactly as `delegate` does, so an empty list stays an answer
|
|
574
|
+
* ("nothing eligible there") and a folder that is not there stays a typo.
|
|
575
|
+
*/
|
|
576
|
+
async function navarchCandidates(argus, kind, directory) {
|
|
577
|
+
const reply = await askArgus(argus, delegateCandidatesRequest(directory, kind), readTimeoutMs(0));
|
|
578
|
+
const support = candidatesSupport(reply);
|
|
579
|
+
if (support !== "no-op") {
|
|
580
|
+
if (!reply.ok)
|
|
581
|
+
return mapRefusal(reply);
|
|
582
|
+
const { workers, target } = parseCandidates(reply);
|
|
583
|
+
return { workers, target, source: "daemon" };
|
|
584
|
+
}
|
|
585
|
+
const fleet = await askArgus(argus, fleetStatusRequest(), readTimeoutMs(0));
|
|
586
|
+
if (!fleet.ok)
|
|
587
|
+
return mapRefusal(fleet);
|
|
588
|
+
const workers = rankWorkers(parseFleet(fleet), kind, directory);
|
|
589
|
+
return { workers, target: workers[0]?.id ?? null, source: "fleet_status" };
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* `--session` or `--gh-account` against an argusd that predates argus #1402 —
|
|
593
|
+
* the refusal, and it is `argusd-outdated` because a reload is the fix.
|
|
594
|
+
*
|
|
595
|
+
* **The danger is that it would not refuse.** `delegate` reads the keys it
|
|
596
|
+
* knows and ignores the rest, so a target it has never heard of is discarded
|
|
597
|
+
* and the find runs anyway. The caller gets `sent`, naming a worker the person
|
|
598
|
+
* did not pick — or a fresh worker running as whoever `gh` was last logged in
|
|
599
|
+
* as. Nothing in the reply distinguishes that from having been obeyed, which
|
|
600
|
+
* is why the check is here and not in a reading of the answer.
|
|
601
|
+
*/
|
|
602
|
+
function targetUnsupported(pick, ghAccount) {
|
|
603
|
+
const asked = [
|
|
604
|
+
pick === undefined ? "" : pick.kind === "new" ? "`--session new`" : `\`--session ${pick.id}\``,
|
|
605
|
+
ghAccount === undefined ? "" : `\`--gh-account ${ghAccount}\``,
|
|
606
|
+
].filter((part) => part !== "");
|
|
607
|
+
return (`${asked.join(" and ")} ${asked.length > 1 ? "name things" : "names something"} the argusd running `
|
|
608
|
+
+ "on this machine cannot be told. It serves the delegate verb — a plain hand-off works — but the "
|
|
609
|
+
+ "target worker and the GitHub account arrived with argus #1402, and this daemon predates it: it "
|
|
610
|
+
+ "does not serve `delegate_candidates` either. It would not refuse them, it would IGNORE them: do "
|
|
611
|
+
+ "its own find and report `sent` about a worker you did not choose, or start one running as "
|
|
612
|
+
+ "whichever account `gh` was last logged in as. So nothing was sent. Reload argusd — deliberately, "
|
|
613
|
+
+ "because a reload ends every live worker session — or delegate without these flags, which works "
|
|
614
|
+
+ "on the daemon you have.");
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* A `--dry-run --session <uuid>` whose worker is not on the daemon's own list.
|
|
618
|
+
*
|
|
619
|
+
* Predicted rather than performed, and exact where the list came from the
|
|
620
|
+
* daemon: `delegate_candidates` lists from the very predicate `delegate`
|
|
621
|
+
* validates `worker` against, so a uuid missing from it is a uuid the send
|
|
622
|
+
* would refuse `worker-not-eligible` — which is this verb's `session-gone`.
|
|
623
|
+
*/
|
|
624
|
+
function wouldBeGone(id, ranked, agent) {
|
|
625
|
+
const now = ranked.length === 0
|
|
626
|
+
? `No ${nameOf(agent)} worker is free in Navarch on this folder`
|
|
627
|
+
: `What it would accept: ${ranked.map((worker) => `${worker.name} (${worker.id})`).join(", ")}`;
|
|
628
|
+
return (`Worker ${id} is not one argusd would deliver to on this folder — it has exited, taken a board `
|
|
629
|
+
+ "mission, is an Argus Agent, or was never there. That is the daemon's own answer to its own "
|
|
630
|
+
+ `find, so a real run would be refused \`session-gone\` for exactly this. ${now}. Nothing was `
|
|
631
|
+
+ "sent and nothing would be started: a chosen session is a choice. Re-read the inventory with "
|
|
632
|
+
+ "`pharos delegate --list --host navarch`, or pass `--session new` to start a fresh worker on "
|
|
633
|
+
+ "purpose.");
|
|
634
|
+
}
|
|
635
|
+
/** What a dry run would do, in the dry run's own tense. */
|
|
636
|
+
function dryRunDetail(picked, pick, ranked, agent, folder, ghAccount, source) {
|
|
637
|
+
const core = picked !== null
|
|
638
|
+
? `Would ask Navarch to type the prompt into “${picked.name}” (${statusWord(picked.status)}).`
|
|
639
|
+
+ (pick === undefined
|
|
640
|
+
? source === "daemon"
|
|
641
|
+
? " That is the worker argusd itself names as the target for this folder."
|
|
642
|
+
: " The find is argusd's rather than this CLI's, so this is the worker it would pick from"
|
|
643
|
+
+ " the snapshot as it stands now."
|
|
644
|
+
: " It was named with `--session`, and argusd validates that pick against the same list it"
|
|
645
|
+
+ " came from.")
|
|
646
|
+
: pick?.kind === "new"
|
|
647
|
+
? `Would ask Navarch to start a FRESH ${nameOf(agent)} worker in ${folder}`
|
|
648
|
+
+ (ranked.length === 0
|
|
649
|
+
? ", which is what would have happened anyway: none is free on this folder."
|
|
650
|
+
: `, leaving the ${ranked.length === 1 ? "one" : String(ranked.length)} already there alone.`)
|
|
651
|
+
: `Would ask Navarch to start a ${nameOf(agent)} worker in ${folder} and type the prompt into `
|
|
652
|
+
+ "it. No worker of that kind is free on this folder — a worker holding a board mission and "
|
|
653
|
+
+ "an Argus Agent are both deliberately not candidates.";
|
|
654
|
+
// The account is only ever bound to a worker that is STARTED, so a dry run
|
|
655
|
+
// that predicts a send has to say the value would travel and do nothing —
|
|
656
|
+
// the same sentence argusd appends to the real answer.
|
|
657
|
+
const account = ghAccount === undefined
|
|
658
|
+
? ""
|
|
659
|
+
: picked === null
|
|
660
|
+
? ` It would run as the GitHub account “${ghAccount}”, and argusd refuses `
|
|
661
|
+
+ "`gh-account-unknown` rather than substituting one if it does not know that name."
|
|
662
|
+
: ` The GitHub account “${ghAccount}” would not be applied: the prompt would land in a worker `
|
|
663
|
+
+ "that is already running, and its account stands.";
|
|
664
|
+
return core + account;
|
|
665
|
+
}
|
|
666
|
+
/** The inventory, in the shape every host answers `--list` in. */
|
|
667
|
+
function emitNavarchList(io, base, agent, found, pretty) {
|
|
668
|
+
const rows = rowsOf(found.workers, agent, found.target);
|
|
669
|
+
const agentName = nameOf(agent);
|
|
670
|
+
const head = rows.length === 0
|
|
671
|
+
? `No ${agentName} worker is free in Navarch on ${base.folder}. A delegation would start one. `
|
|
672
|
+
+ "A worker holding a board mission, and an Argus Agent, are running agents that are "
|
|
673
|
+
+ "deliberately not candidates — so this can read empty with panes open on that folder."
|
|
674
|
+
: `${rows.length} ${agentName} worker${rows.length === 1 ? "" : "s"} in Navarch on ${base.folder}. `
|
|
675
|
+
+ `A plain delegate would use ${(rows.find((row) => row.recommended) ?? rows[0]).name} `
|
|
676
|
+
+ `(${(rows.find((row) => row.recommended) ?? rows[0]).status}).`;
|
|
677
|
+
// **Which side answered, in the list's own prose as well as in `source`.**
|
|
678
|
+
// The two lists are not the same claim: one is what argusd says it would
|
|
679
|
+
// accept, the other is what this CLI predicts it would — and on the older
|
|
680
|
+
// daemon the pick a caller makes from it cannot be honoured at all, which is
|
|
681
|
+
// the thing a person reading the list needs to know BEFORE they pick.
|
|
682
|
+
const provenance = found.source === "daemon"
|
|
683
|
+
? " argusd answered this list itself (`delegate_candidates`), so a row here is a row it accepts:"
|
|
684
|
+
+ " `--session` takes any of these ids."
|
|
685
|
+
: " This argusd predates `delegate_candidates` (argus #1402), so the list is this CLI's own"
|
|
686
|
+
+ " reading of `fleet_status` and a prediction of the daemon's find. `--session` and"
|
|
687
|
+
+ " `--gh-account` are refused `argusd-outdated` against it — reloading argusd is what lifts"
|
|
688
|
+
+ " that, and it ends every live worker session.";
|
|
689
|
+
const detail = head + provenance;
|
|
690
|
+
// **`incomplete` is false and means it.** Every other host infers the list
|
|
691
|
+
// from `ps` and a probe that can fail; both reads here are the daemon's own
|
|
692
|
+
// register of what it is running, so a short answer is an answer and not a
|
|
693
|
+
// gap.
|
|
694
|
+
const listing = {
|
|
695
|
+
ok: true,
|
|
696
|
+
...base,
|
|
697
|
+
sessions: rows,
|
|
698
|
+
incomplete: false,
|
|
699
|
+
source: found.source,
|
|
700
|
+
detail,
|
|
701
|
+
};
|
|
702
|
+
if (!pretty)
|
|
703
|
+
return emit(io, listing);
|
|
704
|
+
return emitText(io, [
|
|
705
|
+
detail,
|
|
706
|
+
...rows.map((row) => `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`),
|
|
707
|
+
].join("\n"));
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The rows, with `recommended` on the one the daemon would take.
|
|
711
|
+
*
|
|
712
|
+
* `target` is read rather than assumed to be `[0]`, even though the two are
|
|
713
|
+
* the same by construction — it is the daemon's own statement of what a plain
|
|
714
|
+
* delegate takes, and `recommended` is defined as exactly that. It falls back
|
|
715
|
+
* to the head for the `fleet_status` route, where the ranking is this CLI's
|
|
716
|
+
* and there is nobody else to ask.
|
|
717
|
+
*/
|
|
718
|
+
function rowsOf(ranked, agent, target) {
|
|
719
|
+
const head = headOf(ranked, target);
|
|
720
|
+
return ranked.map((worker) => workerRow(worker, agent, worker === head));
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* The worker a plain delegate would take: the daemon's own `target` where
|
|
724
|
+
* there is one to read, and the head of the ranking otherwise — which is the
|
|
725
|
+
* `fleet_status` fallback, where there is nobody to ask and this CLI's own
|
|
726
|
+
* rule is all there is.
|
|
727
|
+
*/
|
|
728
|
+
function headOf(ranked, target) {
|
|
729
|
+
if (target !== null) {
|
|
730
|
+
const named = ranked.find((worker) => worker.id === target);
|
|
731
|
+
if (named !== undefined)
|
|
732
|
+
return named;
|
|
733
|
+
}
|
|
734
|
+
return ranked[0] ?? null;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* One worker as a menu row.
|
|
738
|
+
*
|
|
739
|
+
* `status` is the contract's three words, and the mapping is not a translation
|
|
740
|
+
* exercise: **`needs_input` is reported `busy`**. A worker sitting on a trust
|
|
741
|
+
* dialog looks idle from every angle except the one that matters — text typed
|
|
742
|
+
* at it is swallowed by the dialog, which is the `failed` Navarch reports
|
|
743
|
+
* afterwards — so calling it idle in a menu invites the person to choose the
|
|
744
|
+
* one pane that cannot take the work. `starting` is `busy` for the same
|
|
745
|
+
* reason: there is no input box yet.
|
|
746
|
+
*
|
|
747
|
+
* `detail` stays a LOCATOR and never repeats the status word, because the app
|
|
748
|
+
* composes its second line as status + detail.
|
|
749
|
+
*/
|
|
750
|
+
function workerRow(worker, agent, recommended) {
|
|
751
|
+
return {
|
|
752
|
+
id: worker.id,
|
|
753
|
+
name: worker.name === "" ? `${nameOf(agent)} · ${worker.id.slice(0, 8)}` : worker.name,
|
|
754
|
+
agent,
|
|
755
|
+
host: "navarch",
|
|
756
|
+
status: statusWord(worker.status),
|
|
757
|
+
detail: worker.status === "needs_input"
|
|
758
|
+
? "Navarch, on a prompt"
|
|
759
|
+
: worker.stage === ""
|
|
760
|
+
? "Navarch"
|
|
761
|
+
: `Navarch · ${worker.stage}`,
|
|
762
|
+
recommended,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function statusWord(status) {
|
|
766
|
+
switch (status) {
|
|
767
|
+
case "waiting":
|
|
768
|
+
case "idle":
|
|
769
|
+
return "idle";
|
|
770
|
+
case "running":
|
|
771
|
+
case "starting":
|
|
772
|
+
case "needs_input":
|
|
773
|
+
return "busy";
|
|
774
|
+
default:
|
|
775
|
+
return "unknown";
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/** Prose for a person, on top of what argus already wrote. */
|
|
779
|
+
function navarchDetail(outcome, agent, folder) {
|
|
780
|
+
const said = outcome.detail.trim();
|
|
781
|
+
if (outcome.action !== "pending") {
|
|
782
|
+
return said === ""
|
|
783
|
+
? `Navarch ${outcome.action === "launched" ? "started a worker in" : "sent the prompt to a worker in"} ${folder}.`
|
|
784
|
+
: said;
|
|
785
|
+
}
|
|
786
|
+
// The one answer the app must render as a FACT rather than as a failure: a
|
|
787
|
+
// card is up in Navarch and a person has to allow it. Nothing has gone wrong,
|
|
788
|
+
// and nothing has been delivered either.
|
|
789
|
+
const queued = said === "" ? "" : ` (${said})`;
|
|
790
|
+
return (`Navarch is asking you to approve this hand-off${queued}. Nothing has been typed yet: the first `
|
|
791
|
+
+ `delegation on a machine raises a card showing the folder, the ${nameOf(agent)} worker and the `
|
|
792
|
+
+ "line verbatim, and “Always allow” turns on nothing else. Approve it in Navarch, then ask again "
|
|
793
|
+
+ `with \`pharos delegate --host navarch --status ${outcome.request}\`.`);
|
|
794
|
+
}
|
|
795
|
+
function reportOf(outcome) {
|
|
796
|
+
return {
|
|
797
|
+
request: outcome.request,
|
|
798
|
+
worker: outcome.worker,
|
|
799
|
+
name: outcome.name,
|
|
800
|
+
created: outcome.created,
|
|
801
|
+
todo: outcome.todo,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** A refusal that came off the socket, in this verb's own shape. */
|
|
805
|
+
function refuseFailure(refuse, failure) {
|
|
806
|
+
return refuse(failure.reason, failure.detail, failure.delegation === undefined ? undefined : { delegation: reportOf(failure.delegation) });
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* A socket that was not there, or a conversation that broke.
|
|
810
|
+
*
|
|
811
|
+
* The split is the CLI's ordinary one and it earns its keep here: **nothing
|
|
812
|
+
* listening is a fact about the machine with a fix on it** — open Navarch, and
|
|
813
|
+
* argusd comes with it — so it is a refusal on stdout. A park that outlived its
|
|
814
|
+
* own deadline, or a line that is not JSON, is a failure and takes stderr and
|
|
815
|
+
* exit 1. Reporting the second as the first would tell somebody to open an app
|
|
816
|
+
* that is already open.
|
|
817
|
+
*/
|
|
818
|
+
function socketFailure(error, refuse) {
|
|
819
|
+
if (error instanceof ArgusUnreachableError)
|
|
820
|
+
return refuseFailure(refuse, unreachable(error));
|
|
821
|
+
if (error instanceof CliError)
|
|
822
|
+
throw error;
|
|
823
|
+
throw new CliError(`The Argus socket did not answer: ${messageOf(error)}`, "internal", EXIT_FAILED, {
|
|
824
|
+
host: "navarch",
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* The prompt, held to what the wire takes: ONE line of printable characters.
|
|
829
|
+
*
|
|
830
|
+
* The daemon refuses anything else (`bad-text`) and is right to: the receiving
|
|
831
|
+
* TUI submits on Enter and Navarch owns that Enter, so a payload carrying its
|
|
832
|
+
* own control codes is a remote control rather than a message. Checked here so
|
|
833
|
+
* the refusal names the flag the caller passed rather than arriving as a wire
|
|
834
|
+
* error about a field — and **never silently flattened**, because a prompt
|
|
835
|
+
* rewritten on its way out is a prompt nobody proof-read.
|
|
836
|
+
*/
|
|
837
|
+
function navarchText(text) {
|
|
838
|
+
if (CONTROL.test(text)) {
|
|
839
|
+
throw usageError("Navarch takes the prompt as ONE line: the worker's TUI submits on Enter and Navarch owns that "
|
|
840
|
+
+ "Enter, so a prompt carrying its own newlines or tabs would arrive as several messages, or as "
|
|
841
|
+
+ "none. Fold it into one line — pharos will not do that for you, because a prompt rewritten on "
|
|
842
|
+
+ "its way out is a prompt nobody proof-read. (Terminal and the VS Code hosts take several "
|
|
843
|
+
+ "lines; they paste rather than type.)", { host: "navarch", lines: text.split("\n").length });
|
|
844
|
+
}
|
|
845
|
+
if (text.length > ARGUS_TEXT_LIMIT) {
|
|
846
|
+
throw usageError(`Navarch takes at most ${ARGUS_TEXT_LIMIT} characters in one line, and this prompt is `
|
|
847
|
+
+ `${text.length}. Shorten it, or point the agent at the work item and let it fetch the detail `
|
|
848
|
+
+ "itself — which is what the app's own prompt does.", { host: "navarch", characters: text.length, limit: ARGUS_TEXT_LIMIT });
|
|
849
|
+
}
|
|
850
|
+
return text;
|
|
851
|
+
}
|
|
852
|
+
/** `realpath`, falling back to the path as given when it cannot be resolved. */
|
|
853
|
+
async function canonicalFolder(folder) {
|
|
854
|
+
try {
|
|
855
|
+
return await realpath(folder);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return folder;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
266
861
|
/**
|
|
267
862
|
* The `session-gone` detail: what was asked for, what is actually there, and
|
|
268
863
|
* why nothing was started instead.
|
|
@@ -303,6 +898,64 @@ function agentNotInstalled(agent, env) {
|
|
|
303
898
|
+ (agent === "claude" ? "`npm i -g @anthropic-ai/claude-code`" : "`npm i -g @openai/codex`")
|
|
304
899
|
+ " puts it there. If it IS installed, `pharos doctor` prints the PATH that was searched.");
|
|
305
900
|
}
|
|
901
|
+
/**
|
|
902
|
+
* `--session` against a bridge that cannot carry one — the version gate's
|
|
903
|
+
* detail, which has three shapes because it has three different fixes.
|
|
904
|
+
*
|
|
905
|
+
* `running` is the version in place AFTER the route's own install attempt, so
|
|
906
|
+
* reaching here at all means one of three things: an upgrade was tried and
|
|
907
|
+
* failed (`upgradeFailure` says how), this pharos ships nothing to replace it
|
|
908
|
+
* with, or what it ships is no newer. Each of those is somebody doing a
|
|
909
|
+
* different thing next, which is why none of them is folded into the others.
|
|
910
|
+
*/
|
|
911
|
+
function sessionTooOld(name, running, shipped, upgradeFailure) {
|
|
912
|
+
const unreadable = running === null || running === "";
|
|
913
|
+
const head = `The Pharos bridge in ${name} `
|
|
914
|
+
+ (unreadable
|
|
915
|
+
? "carries no version this can read, so it cannot be shown to understand"
|
|
916
|
+
: `is ${running}, which predates`)
|
|
917
|
+
+ ` \`--session\` — that arrived in ${SESSION_BRIDGE_VERSION}. It does not ignore the session in the `
|
|
918
|
+
+ "URI: it refuses any query key it does not know, so the prompt would never reach the editor and "
|
|
919
|
+
+ "this would have reported it as sent.";
|
|
920
|
+
const fix = upgradeFailure !== null
|
|
921
|
+
? ` Replacing it with ${shipped ?? "the one this pharos ships"} was tried on the way here and failed: `
|
|
922
|
+
+ `${upgradeFailure} \`pharos setup --install vscode-bridge\` runs the same install on its own.`
|
|
923
|
+
: shipped === null
|
|
924
|
+
? " This pharos ships no bridge it can offer as a replacement, so updating pharos is what brings one."
|
|
925
|
+
: bridgeAtLeast(shipped, SESSION_BRIDGE_VERSION)
|
|
926
|
+
? ` \`pharos setup --install vscode-bridge\` replaces it with the one this pharos ships (${shipped}).`
|
|
927
|
+
: ` This pharos ships ${shipped}, which is no newer, so updating pharos is what brings one.`;
|
|
928
|
+
return `${head}${fix} Delegating WITHOUT --session works with the bridge you have.`;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The one state installing cannot fix, because the files are only half of it.
|
|
932
|
+
*
|
|
933
|
+
* VS Code replaces an extension's files immediately; the extension host in a
|
|
934
|
+
* window that is ALREADY OPEN goes on running the code it activated with until
|
|
935
|
+
* that window reloads. For an ordinary delegation that costs nothing — the old
|
|
936
|
+
* bridge handles the URI. For `--session` against a bridge that predated
|
|
937
|
+
* {@link SESSION_BRIDGE_VERSION} it costs the prompt: that code refuses the
|
|
938
|
+
* whole URI rather than ignoring the parameter, `open` succeeds anyway, and
|
|
939
|
+
* this verb would answer `sent` about a message nobody received.
|
|
940
|
+
*
|
|
941
|
+
* So it refuses once, and only once — the reload (or the next window) is all
|
|
942
|
+
* that stands between the person and a working send. `cause` is what tells the
|
|
943
|
+
* app this `bridge-not-installed` wants a reload rather than its Install
|
|
944
|
+
* button: the install has already happened.
|
|
945
|
+
*/
|
|
946
|
+
function staleWindow(name, replaced, now, dryRun) {
|
|
947
|
+
const installed = now === null || now === "" ? "the bundled bridge" : now;
|
|
948
|
+
return (`${dryRun ? `Installing ${installed} into ${name} would replace` : `The Pharos bridge in ${name} was just replaced with ${installed}, over`} `
|
|
949
|
+
+ `${replaced}, which predates \`--session\` (${SESSION_BRIDGE_VERSION}). A ${name} window that is `
|
|
950
|
+
+ `already open still runs ${replaced} until it reloads, and ${replaced} does not ignore a session in `
|
|
951
|
+
+ "the URI — it refuses the whole thing, so the prompt would be reported as sent and never arrive. "
|
|
952
|
+
+ (dryRun
|
|
953
|
+
? "A real run would install it and then refuse once, for this reason: reload that window "
|
|
954
|
+
+ "(Developer: Reload Window) or open a new one first. Delegating WITHOUT --session would work "
|
|
955
|
+
+ "either way."
|
|
956
|
+
: "Reload that window (Developer: Reload Window) or open a new one, then run this again. "
|
|
957
|
+
+ "Delegating WITHOUT --session works either way."));
|
|
958
|
+
}
|
|
306
959
|
/**
|
|
307
960
|
* The URI the bridge handles.
|
|
308
961
|
*
|
|
@@ -335,31 +988,35 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
|
|
|
335
988
|
}
|
|
336
989
|
const extensions = await deps.installedExtensions(host, env);
|
|
337
990
|
const bridge = extensions?.find((extension) => extension.id === BRIDGE_EXTENSION_ID);
|
|
338
|
-
|
|
991
|
+
const bundled = await deps.bundledBridge(env);
|
|
992
|
+
const shipped = bundled?.manifest.version ?? null;
|
|
993
|
+
// Nothing installed and nothing to install with: the one state this route can
|
|
994
|
+
// do nothing about. A package that ships no `.vsix` is a real state — the
|
|
995
|
+
// bridge's build writes it and a checkout that has not run it has none — so
|
|
996
|
+
// it is named rather than reported as an absent extension, which would send
|
|
997
|
+
// somebody to an install that refuses for a different reason.
|
|
998
|
+
if (bridge === undefined && bundled === null) {
|
|
339
999
|
const directory = extensionsDirectory(host, env) ?? "the extensions directory";
|
|
340
1000
|
return refuse("bridge-not-installed", `The Pharos bridge (${BRIDGE_EXTENSION_ID}) is not installed in ${name} — looked in ${directory}`
|
|
341
1001
|
+ (extensions === null ? ", which does not exist: that VS Code has never run" : "")
|
|
342
|
-
+
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// URI.** It rejects any query key it does not know (the guard that makes a
|
|
346
|
-
// single-encoded `&` in a prompt loud instead of silently truncating one), so
|
|
347
|
-
// an unexpected `session` trips it and the prompt never reaches the editor.
|
|
348
|
-
// Nothing comes back from VS Code to say so — `open` succeeds either way — so
|
|
349
|
-
// without this check the verb answers `sent` about a message that was thrown
|
|
350
|
-
// away, and the app tells the person it was delivered. Every machine that
|
|
351
|
-
// installed the bridge before this release carries one of these.
|
|
352
|
-
//
|
|
353
|
-
// `bridge-not-installed` rather than a new reason id: the fix the app already
|
|
354
|
-
// renders for it — `pharos setup --install vscode-bridge` — is exactly the
|
|
355
|
-
// fix for this, and a reason id is a contract the app decodes.
|
|
356
|
-
if (pick !== undefined && !bridgeAtLeast(bridge.version, SESSION_BRIDGE_VERSION)) {
|
|
357
|
-
return refuse("bridge-not-installed", `The Pharos bridge installed in ${name} is ${bridge.version}, which predates \`--session\` — that `
|
|
358
|
-
+ `arrived in ${SESSION_BRIDGE_VERSION}. It would not ignore the session in the URI: it refuses `
|
|
359
|
-
+ "any query key it does not know, so the prompt would never reach the editor and this would have "
|
|
360
|
-
+ "reported it as sent. `pharos setup --install vscode-bridge` replaces it with the one this pharos "
|
|
361
|
-
+ "ships. Delegating WITHOUT --session works with the bridge you have.");
|
|
1002
|
+
+ `. This pharos ships no bridge to install either (${bundledVsixPath(env)} is absent), so there is `
|
|
1003
|
+
+ "nothing here to put in: `pnpm --filter ./packages/pharos-vscode build` writes one in a checkout, "
|
|
1004
|
+
+ "and a published pharos packs it.");
|
|
362
1005
|
}
|
|
1006
|
+
// **What this pharos ships goes in, on the way.** Absent, or older than the
|
|
1007
|
+
// bundled one, and the same verified installer `setup --install
|
|
1008
|
+
// vscode-bridge` runs is run first — for this flavour only. That is the whole
|
|
1009
|
+
// of #1394: `npm i -g` runs no installer, so before this a CLI update carried
|
|
1010
|
+
// a bridge that never reached an editor.
|
|
1011
|
+
//
|
|
1012
|
+
// A version that cannot be compared is left alone. `shipped` is null when the
|
|
1013
|
+
// package has a `.vsix` whose version could not be read at all, and replacing
|
|
1014
|
+
// a working bridge on a guess is the one move that can make a machine worse.
|
|
1015
|
+
const wanted = bridge === undefined
|
|
1016
|
+
? "install"
|
|
1017
|
+
: shipped !== null && !bridgeAtLeast(bridge.version, shipped)
|
|
1018
|
+
? "upgrade"
|
|
1019
|
+
: null;
|
|
363
1020
|
const claudeExt = extensions?.find((extension) => extension.id === CLAUDE_EXTENSION_ID);
|
|
364
1021
|
const usesExtensionBinary = mode === "extension" && agent === "claude" && claudeExt !== undefined;
|
|
365
1022
|
// **The extension route needs no agent on PATH.** The Claude Code extension
|
|
@@ -396,6 +1053,88 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
|
|
|
396
1053
|
return refuse("session-gone", sessionGone(pick.pid, agent, host, ranked, sessions, incomplete, probeDetail));
|
|
397
1054
|
}
|
|
398
1055
|
}
|
|
1056
|
+
// **The install happens HERE, after every refusal that is a pure fact.** An
|
|
1057
|
+
// agent off PATH and a pick that has vanished would both have refused anyway,
|
|
1058
|
+
// and provisioning somebody's editor on the way to a refusal is a side effect
|
|
1059
|
+
// on a call that delivered nothing. Past this line the only thing left to do
|
|
1060
|
+
// is open the URI.
|
|
1061
|
+
//
|
|
1062
|
+
// It costs a few seconds — `code --install-extension` spawns Electron — and
|
|
1063
|
+
// it costs them once per CLI update per flavour, on the call that is already
|
|
1064
|
+
// about that editor.
|
|
1065
|
+
let running = bridge?.version ?? null;
|
|
1066
|
+
/** The version this replaced, when it replaced one. Null on a fresh install. */
|
|
1067
|
+
let replaced = null;
|
|
1068
|
+
let leadNote = "";
|
|
1069
|
+
let tailNote = "";
|
|
1070
|
+
/** Why an upgrade did not happen, when one was tried. The version gate quotes it. */
|
|
1071
|
+
let upgradeFailure = null;
|
|
1072
|
+
if (wanted !== null) {
|
|
1073
|
+
const into = `${shipped ?? "the bridge this pharos ships"} into ${name}`;
|
|
1074
|
+
if (dryRun) {
|
|
1075
|
+
running = shipped;
|
|
1076
|
+
replaced = wanted === "upgrade" ? bridge.version : null;
|
|
1077
|
+
leadNote =
|
|
1078
|
+
`Would install ${into} first`
|
|
1079
|
+
+ (replaced === null ? "" : `, replacing ${replaced}`)
|
|
1080
|
+
+ " — the same checksum-verified `--install-extension --force` that `pharos setup --install "
|
|
1081
|
+
+ "vscode-bridge` runs. ";
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
const outcome = await deps.installBridge(host, env);
|
|
1085
|
+
if (outcome.ok) {
|
|
1086
|
+
running = shipped;
|
|
1087
|
+
replaced = wanted === "upgrade" ? bridge.version : null;
|
|
1088
|
+
leadNote = `Installed the Pharos bridge ${into} first${replaced === null ? "" : `, replacing ${replaced}`}. `;
|
|
1089
|
+
}
|
|
1090
|
+
else if (wanted === "install") {
|
|
1091
|
+
// Nothing was there to fall back on, so this is the refusal the route
|
|
1092
|
+
// had before — with what was tried, rather than with an instruction to
|
|
1093
|
+
// try the same thing by hand.
|
|
1094
|
+
return refuse("bridge-not-installed", `The Pharos bridge (${BRIDGE_EXTENSION_ID}) is not installed in ${name}, and installing the one `
|
|
1095
|
+
+ `this pharos ships${shipped === null ? "" : ` (${shipped})`} failed. ${outcome.detail}`
|
|
1096
|
+
+ (outcome.command === null ? "" : ` The command was: ${outcome.command}.`)
|
|
1097
|
+
+ " `pharos setup --install vscode-bridge` runs the same install on its own.");
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
// **A failed UPGRADE is not a refusal.** The bridge that was there is
|
|
1101
|
+
// still there and still serves this delegation; refusing would take
|
|
1102
|
+
// away something that worked before this release existed.
|
|
1103
|
+
upgradeFailure = outcome.detail;
|
|
1104
|
+
tailNote =
|
|
1105
|
+
` This pharos ships ${shipped} and installing it over ${bridge.version} failed, so ${bridge.version} `
|
|
1106
|
+
+ `handled this. ${outcome.detail}`;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// The version gate, now asked about the bridge that is actually in place —
|
|
1111
|
+
// which after the block above may be the one just installed, or the old one
|
|
1112
|
+
// an upgrade failed to replace. **A bridge older than `--session` does not
|
|
1113
|
+
// ignore it: it refuses the whole URI**, because it rejects any query key it
|
|
1114
|
+
// does not know (the guard that makes a single-encoded `&` in a prompt loud
|
|
1115
|
+
// instead of silently truncating one). Nothing comes back from VS Code to say
|
|
1116
|
+
// so — `open` succeeds either way — so without this the verb answers `sent`
|
|
1117
|
+
// about a message that was thrown away and the app tells the person it
|
|
1118
|
+
// arrived.
|
|
1119
|
+
//
|
|
1120
|
+
// `bridge-not-installed` rather than a new reason id: the fix the app already
|
|
1121
|
+
// renders for it is the fix for this, and a reason id is a contract it
|
|
1122
|
+
// decodes.
|
|
1123
|
+
if (pick !== undefined && !bridgeAtLeast(running ?? "", SESSION_BRIDGE_VERSION)) {
|
|
1124
|
+
return refuse("bridge-not-installed", sessionTooOld(name, running, shipped, upgradeFailure));
|
|
1125
|
+
}
|
|
1126
|
+
// …and the case installing cannot fix, because the files are only half of it.
|
|
1127
|
+
if (pick !== undefined && replaced !== null && !bridgeAtLeast(replaced, SESSION_BRIDGE_VERSION)) {
|
|
1128
|
+
return refuse("bridge-not-installed", staleWindow(name, replaced, running, dryRun), { cause: "stale-window" });
|
|
1129
|
+
}
|
|
1130
|
+
// A replacement a window may not have picked up yet is worth saying whenever
|
|
1131
|
+
// it happened — but only as a note, because for a delegation that names no
|
|
1132
|
+
// session the old bridge handles the URI perfectly well.
|
|
1133
|
+
if (replaced !== null) {
|
|
1134
|
+
tailNote +=
|
|
1135
|
+
` A ${name} window that was already open keeps ${replaced} until it reloads (Developer: Reload Window);`
|
|
1136
|
+
+ ` the next window gets ${running ?? "the new one"}. Either serves this delegation.`;
|
|
1137
|
+
}
|
|
399
1138
|
const uri = delegateUri(spec.vscode.scheme, { folder, agent, mode, text, session: pick });
|
|
400
1139
|
const agentName = nameOf(agent);
|
|
401
1140
|
const claudeExtInstalled = mode === "extension" && agent === "claude" ? claudeExt !== undefined : true;
|
|
@@ -419,11 +1158,18 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
|
|
|
419
1158
|
+ `in an integrated terminal in ${folder}.`;
|
|
420
1159
|
// A deliberate new session makes "a session may exist that was not seen"
|
|
421
1160
|
// beside the point: one was not wanted. So the two notes are exclusive.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1161
|
+
//
|
|
1162
|
+
// **What was done first leads and the caveats trail.** The app renders this
|
|
1163
|
+
// sentence as-is, and "Installed the Pharos bridge 0.3.0 into VS Code
|
|
1164
|
+
// Insiders first." is the fact somebody needs before the one about where the
|
|
1165
|
+
// prompt went.
|
|
1166
|
+
const detail = leadNote
|
|
1167
|
+
+ (pick?.kind === "new"
|
|
1168
|
+
? `${core}${asked}`
|
|
1169
|
+
: session === null && incomplete
|
|
1170
|
+
? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}); a running session may exist that could not be found.`
|
|
1171
|
+
: core)
|
|
1172
|
+
+ tailNote;
|
|
427
1173
|
if (!dryRun) {
|
|
428
1174
|
try {
|
|
429
1175
|
await deps.open(uri);
|