@staix/agent-hub 0.12.41 → 0.12.43
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/CHANGELOG.md +26 -0
- package/README.md +7 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -2
- package/docs/agent-notes/tasks.md +1 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/operations.md +98 -24
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +3 -3
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +51 -9
- package/docs/verification/2026-10-20-0.12.42.md +48 -0
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +79 -29
- package/src/adapters/agy/entry.ts +11 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/pi.ts +46 -16
- package/src/cli/completion.ts +173 -0
- package/src/cli/console-state.ts +107 -20
- package/src/cli/console.ts +9 -6
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +2 -2
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +6 -0
- package/src/cli/terminal-recovery.ts +18 -9
- package/src/hub/daemon.ts +138 -33
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/settings.ts +1 -1
- package/src/hub/snapshots.ts +252 -17
- package/src/hub/tasks.ts +16 -2
|
@@ -4300,7 +4300,7 @@ function ownerStillAlive(pid, signature) {
|
|
|
4300
4300
|
return false;
|
|
4301
4301
|
}
|
|
4302
4302
|
}
|
|
4303
|
-
var PiToolSettlementFailure, PiPromptNotStarted, PiPromptUncertain, PiPeer;
|
|
4303
|
+
var PiToolSettlementFailure, PiPromptNotStarted, PiPromptUncertain, TERMINAL_OWNER_MAX_AGE_MS = 1000, PiPeer;
|
|
4304
4304
|
var init_pi = __esm(() => {
|
|
4305
4305
|
init_launch();
|
|
4306
4306
|
init_envelope();
|
|
@@ -4381,6 +4381,7 @@ var init_pi = __esm(() => {
|
|
|
4381
4381
|
ownerPid;
|
|
4382
4382
|
ownerSignature;
|
|
4383
4383
|
ownerMonitor;
|
|
4384
|
+
terminalOwnerObservation;
|
|
4384
4385
|
tuiExit;
|
|
4385
4386
|
resolveTuiExit;
|
|
4386
4387
|
tuiCommands = new Map;
|
|
@@ -4409,7 +4410,7 @@ var init_pi = __esm(() => {
|
|
|
4409
4410
|
if (this.opts.mode === "headless")
|
|
4410
4411
|
return this.state === "idle" || !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
4411
4412
|
if (this.state === "idle")
|
|
4412
|
-
return this.ownerClaimed && !!this.ownerPid &&
|
|
4413
|
+
return this.ownerClaimed && !!this.ownerPid && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
|
|
4413
4414
|
return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
|
|
4414
4415
|
}
|
|
4415
4416
|
get replacementReady() {
|
|
@@ -4633,6 +4634,7 @@ var init_pi = __esm(() => {
|
|
|
4633
4634
|
return Response.json({ decisions, ...reservation ? { reservation } : {} });
|
|
4634
4635
|
}
|
|
4635
4636
|
async startImpl() {
|
|
4637
|
+
this.terminalOwnerObservation = undefined;
|
|
4636
4638
|
this.stopping = false;
|
|
4637
4639
|
this.rosterDetached = false;
|
|
4638
4640
|
this.rosterUnownedRun = false;
|
|
@@ -4687,7 +4689,7 @@ var init_pi = __esm(() => {
|
|
|
4687
4689
|
return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 });
|
|
4688
4690
|
if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || this.sessionId && body.sessionId !== this.sessionId || this.sessionFile && body.sessionFile !== this.sessionFile))
|
|
4689
4691
|
return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 });
|
|
4690
|
-
if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature ||
|
|
4692
|
+
if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature || this.readOwnerSignature(body.pid) !== body.signature))
|
|
4691
4693
|
return Response.json({ ok: false, error: "Pi process identity is not verified" }, { status: 409 });
|
|
4692
4694
|
this.handleBridgeEvent(body);
|
|
4693
4695
|
return Response.json({ ok: true });
|
|
@@ -4743,7 +4745,7 @@ var init_pi = __esm(() => {
|
|
|
4743
4745
|
this.sessionId = state.data.sessionId;
|
|
4744
4746
|
this.sessionFile = state.data.sessionFile;
|
|
4745
4747
|
if (!this.ownerClaimed) {
|
|
4746
|
-
const pid = this.proc?.pid, signature = pid ?
|
|
4748
|
+
const pid = this.proc?.pid, signature = pid ? this.readOwnerSignature(pid) : undefined;
|
|
4747
4749
|
if (!pid || !signature)
|
|
4748
4750
|
throw new Error("Pi headless owner process identity is not verified");
|
|
4749
4751
|
this.ownerPid = pid;
|
|
@@ -4757,11 +4759,27 @@ var init_pi = __esm(() => {
|
|
|
4757
4759
|
this.started = true;
|
|
4758
4760
|
this.setState("idle");
|
|
4759
4761
|
}
|
|
4762
|
+
readOwnerSignature(pid) {
|
|
4763
|
+
return (this.opts.signature ?? processSignature)(pid);
|
|
4764
|
+
}
|
|
4760
4765
|
verifiedOwner() {
|
|
4761
|
-
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature &&
|
|
4766
|
+
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
|
|
4767
|
+
}
|
|
4768
|
+
rememberTerminalOwner() {
|
|
4769
|
+
if (!this.ownerClaimed || !this.ownerPid || !this.ownerSignature)
|
|
4770
|
+
return;
|
|
4771
|
+
this.terminalOwnerObservation = {
|
|
4772
|
+
pid: this.ownerPid,
|
|
4773
|
+
signature: this.ownerSignature,
|
|
4774
|
+
sessionId: this.sessionId,
|
|
4775
|
+
sessionFile: this.sessionFile,
|
|
4776
|
+
ownerToken: this.ownerToken,
|
|
4777
|
+
checkedAt: performance.now()
|
|
4778
|
+
};
|
|
4762
4779
|
}
|
|
4763
4780
|
terminalOwner(_launchId) {
|
|
4764
|
-
|
|
4781
|
+
const observed = this.terminalOwnerObservation;
|
|
4782
|
+
if (this.opts.mode !== "tui" || !this.started || this.stopping || this.rosterDetached || this.state === "offline" || !this.ownerClaimed || !observed || performance.now() - observed.checkedAt > TERMINAL_OWNER_MAX_AGE_MS || observed.pid !== this.ownerPid || observed.signature !== this.ownerSignature || observed.sessionId !== this.sessionId || observed.sessionFile !== this.sessionFile || observed.ownerToken !== this.ownerToken || !this.sessionId || !this.sessionFile || this.opts.sessionId && this.opts.sessionId !== this.sessionId || this.opts.sessionFile && this.opts.sessionFile !== this.sessionFile)
|
|
4765
4783
|
return;
|
|
4766
4784
|
const generation = this.sessionGeneration;
|
|
4767
4785
|
return generation && this.ownerPid ? { generation, nativePid: this.ownerPid } : undefined;
|
|
@@ -4780,6 +4798,7 @@ var init_pi = __esm(() => {
|
|
|
4780
4798
|
async detachForRoster() {
|
|
4781
4799
|
if (this.opts.mode !== "tui")
|
|
4782
4800
|
throw new Error("Pi roster detach requires a personal TUI");
|
|
4801
|
+
this.terminalOwnerObservation = undefined;
|
|
4783
4802
|
this.rosterDetached = true;
|
|
4784
4803
|
this.rosterEpoch++;
|
|
4785
4804
|
this.setState("offline");
|
|
@@ -4841,6 +4860,7 @@ var init_pi = __esm(() => {
|
|
|
4841
4860
|
if (reason !== undefined)
|
|
4842
4861
|
this.requestedStopReason = reason;
|
|
4843
4862
|
this.rosterEpoch++;
|
|
4863
|
+
this.terminalOwnerObservation = undefined;
|
|
4844
4864
|
this.stopping = true;
|
|
4845
4865
|
for (const id of this.activeDeliveryIds)
|
|
4846
4866
|
this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
@@ -5159,6 +5179,7 @@ var init_pi = __esm(() => {
|
|
|
5159
5179
|
this.opts.log?.(`[${this.id}] Pi session identity mismatch`);
|
|
5160
5180
|
return;
|
|
5161
5181
|
}
|
|
5182
|
+
this.rememberTerminalOwner();
|
|
5162
5183
|
this.startOwnerMonitor();
|
|
5163
5184
|
if (this.opts.mode === "tui") {
|
|
5164
5185
|
this.started = true;
|
|
@@ -5177,7 +5198,7 @@ var init_pi = __esm(() => {
|
|
|
5177
5198
|
if (this.opts.mode === "tui")
|
|
5178
5199
|
this.reportExit(exit);
|
|
5179
5200
|
}
|
|
5180
|
-
if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature &&
|
|
5201
|
+
if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature && this.readOwnerSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration && typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
|
|
5181
5202
|
const pending = this.pendingToolGenerations.get(event.toolCallId);
|
|
5182
5203
|
if (pending && pending.generation === event.generation)
|
|
5183
5204
|
pending.abort.abort();
|
|
@@ -5416,6 +5437,7 @@ var init_pi = __esm(() => {
|
|
|
5416
5437
|
} catch {}
|
|
5417
5438
|
}
|
|
5418
5439
|
fail(error) {
|
|
5440
|
+
this.terminalOwnerObservation = undefined;
|
|
5419
5441
|
const approvalStopReason = this.approvalStopReason;
|
|
5420
5442
|
this.abortTools();
|
|
5421
5443
|
this.agentRunning = false;
|
|
@@ -5445,24 +5467,39 @@ var init_pi = __esm(() => {
|
|
|
5445
5467
|
this.clearOwnerMonitor();
|
|
5446
5468
|
if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature)
|
|
5447
5469
|
return;
|
|
5448
|
-
this.ownerMonitor = setInterval(() =>
|
|
5449
|
-
if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature))
|
|
5450
|
-
return;
|
|
5451
|
-
const exit = this.exitMetadata("owner_lost");
|
|
5452
|
-
this.stopping = true;
|
|
5453
|
-
this.ownerClaimed = false;
|
|
5454
|
-
this.clearOwnerMonitor();
|
|
5455
|
-
this.resolveTuiExit?.();
|
|
5456
|
-
this.resolveTuiExit = undefined;
|
|
5457
|
-
for (const pending of this.tuiCommands.values())
|
|
5458
|
-
pending.reject(new Error("Pi owner exited"));
|
|
5459
|
-
this.tuiCommands.clear();
|
|
5460
|
-
this.tuiQueue = [];
|
|
5461
|
-
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
5462
|
-
this.reportExit(exit);
|
|
5463
|
-
}, 500);
|
|
5470
|
+
this.ownerMonitor = setInterval(() => this.checkOwner(), 500);
|
|
5464
5471
|
this.ownerMonitor.unref?.();
|
|
5465
5472
|
}
|
|
5473
|
+
checkOwner() {
|
|
5474
|
+
if (!this.ownerPid) {
|
|
5475
|
+
this.terminalOwnerObservation = undefined;
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
5478
|
+
const current = this.readOwnerSignature(this.ownerPid);
|
|
5479
|
+
if (current !== undefined && current === this.ownerSignature) {
|
|
5480
|
+
this.rememberTerminalOwner();
|
|
5481
|
+
return;
|
|
5482
|
+
}
|
|
5483
|
+
this.terminalOwnerObservation = undefined;
|
|
5484
|
+
if (current === undefined) {
|
|
5485
|
+
try {
|
|
5486
|
+
process.kill(this.ownerPid, 0);
|
|
5487
|
+
return;
|
|
5488
|
+
} catch {}
|
|
5489
|
+
}
|
|
5490
|
+
const exit = this.exitMetadata("owner_lost");
|
|
5491
|
+
this.stopping = true;
|
|
5492
|
+
this.ownerClaimed = false;
|
|
5493
|
+
this.clearOwnerMonitor();
|
|
5494
|
+
this.resolveTuiExit?.();
|
|
5495
|
+
this.resolveTuiExit = undefined;
|
|
5496
|
+
for (const pending of this.tuiCommands.values())
|
|
5497
|
+
pending.reject(new Error("Pi owner exited"));
|
|
5498
|
+
this.tuiCommands.clear();
|
|
5499
|
+
this.tuiQueue = [];
|
|
5500
|
+
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
5501
|
+
this.reportExit(exit);
|
|
5502
|
+
}
|
|
5466
5503
|
clearOwnerMonitor() {
|
|
5467
5504
|
if (this.ownerMonitor)
|
|
5468
5505
|
clearInterval(this.ownerMonitor);
|
|
@@ -7868,7 +7905,7 @@ var init_hub_tools = __esm(() => {
|
|
|
7868
7905
|
CONDUCTOR_TOOLS = [
|
|
7869
7906
|
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
7870
7907
|
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
7871
|
-
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str2, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
7908
|
+
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named while attached or when they claim it; a peer that cannot take the task is refused with the reason, and the task is unchanged. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str2, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
7872
7909
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
7873
7910
|
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
|
|
7874
7911
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str2 }, ["peer"]),
|
|
@@ -8562,7 +8599,9 @@ function decodeAgyHook(event, value) {
|
|
|
8562
8599
|
if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record3(call.args)))
|
|
8563
8600
|
return;
|
|
8564
8601
|
const native = call?.args;
|
|
8565
|
-
const
|
|
8602
|
+
const readPath = call?.name === "view_file" ? native?.AbsolutePath : call?.name === "read_file" ? native?.Path : undefined;
|
|
8603
|
+
const observedRead = typeof readPath === "string";
|
|
8604
|
+
const range = observedRead && Number.isSafeInteger(native?.StartLine) && native.StartLine > 0 ? { offset: native.StartLine, ...Number.isSafeInteger(native?.EndLine) && native.EndLine >= native.StartLine ? { limit: native.EndLine - native.StartLine + 1 } : {} } : {};
|
|
8566
8605
|
const phase = { SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" }[event];
|
|
8567
8606
|
return {
|
|
8568
8607
|
event,
|
|
@@ -8571,7 +8610,7 @@ function decodeAgyHook(event, value) {
|
|
|
8571
8610
|
transcriptPath: value.transcriptPath,
|
|
8572
8611
|
workspacePaths: value.workspacePaths,
|
|
8573
8612
|
tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "",
|
|
8574
|
-
input: observedRead ? { file_path:
|
|
8613
|
+
input: observedRead ? { file_path: readPath, ...range } : native ?? {},
|
|
8575
8614
|
...Number.isSafeInteger(value.stepIdx) && value.stepIdx >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {},
|
|
8576
8615
|
...Number.isSafeInteger(value.invocationNum) && value.invocationNum >= 0 ? { invocation: value.invocationNum } : {},
|
|
8577
8616
|
...event === "Stop" ? { fullyIdle: value.fullyIdle, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}
|
|
@@ -9190,6 +9229,14 @@ function runAgyTui(argv, cwd, env) {
|
|
|
9190
9229
|
});
|
|
9191
9230
|
});
|
|
9192
9231
|
}
|
|
9232
|
+
function agyVersionGate(stdout) {
|
|
9233
|
+
const version = stdout.trim();
|
|
9234
|
+
if (version === "1.4.2")
|
|
9235
|
+
return { ok: true };
|
|
9236
|
+
if (/^1\.4\.\d+$/.test(version))
|
|
9237
|
+
return { ok: true, message: `agy ${version}: only 1.4.2 is T0-verified; launching on the 1.4.x line unverified` };
|
|
9238
|
+
return { ok: false, message: "agy P1 requires the Antigravity CLI 1.4.x line (1.4.2 verified)" };
|
|
9239
|
+
}
|
|
9193
9240
|
async function runAgyEntry(ctx) {
|
|
9194
9241
|
const { args, cwd, stateDir, cliDir, connect, fail } = ctx;
|
|
9195
9242
|
if (ctx.instance)
|
|
@@ -9207,8 +9254,11 @@ async function runAgyEntry(ctx) {
|
|
|
9207
9254
|
return;
|
|
9208
9255
|
}
|
|
9209
9256
|
const version = spawnSync4("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
|
|
9210
|
-
|
|
9211
|
-
|
|
9257
|
+
const gate = agyVersionGate(version.status === 0 ? version.stdout : "");
|
|
9258
|
+
if (!gate.ok)
|
|
9259
|
+
fail(gate.message);
|
|
9260
|
+
if (gate.message)
|
|
9261
|
+
console.error(gate.message);
|
|
9212
9262
|
const hub = await connect();
|
|
9213
9263
|
let options;
|
|
9214
9264
|
let pluginWritten = false;
|
|
@@ -18416,7 +18466,7 @@ init_spec_read();
|
|
|
18416
18466
|
// package.json
|
|
18417
18467
|
var package_default = {
|
|
18418
18468
|
name: "@staix/agent-hub",
|
|
18419
|
-
version: "0.12.
|
|
18469
|
+
version: "0.12.43",
|
|
18420
18470
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
18421
18471
|
license: "MIT",
|
|
18422
18472
|
type: "module",
|
|
@@ -33,6 +33,14 @@ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
|
|
37
|
+
export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
|
|
38
|
+
const version = stdout.trim();
|
|
39
|
+
if (version === "1.4.2") return { ok: true };
|
|
40
|
+
if (/^1\.4\.\d+$/.test(version)) return { ok: true, message: `agy ${version}: only 1.4.2 is T0-verified; launching on the 1.4.x line unverified` };
|
|
41
|
+
return { ok: false, message: "agy P1 requires the Antigravity CLI 1.4.x line (1.4.2 verified)" };
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
37
45
|
const { args, cwd, stateDir, cliDir, connect, fail } = ctx;
|
|
38
46
|
if (ctx.instance) fail("extra Antigravity TUI instances are not verified in P1");
|
|
@@ -45,7 +53,9 @@ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
45
53
|
const argv = nativeArgs;
|
|
46
54
|
if (preview) { console.log(JSON.stringify({ command: "agy", args: argv.map(publicArg), mode: "tui", permission: "unmanaged", mcp: ".agents/plugins/agent-hub/", writes: false })); return; }
|
|
47
55
|
const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
|
|
48
|
-
|
|
56
|
+
const gate = agyVersionGate(version.status === 0 ? version.stdout : "");
|
|
57
|
+
if (!gate.ok) fail(gate.message);
|
|
58
|
+
if (gate.message) console.error(gate.message);
|
|
49
59
|
const hub = await connect();
|
|
50
60
|
let options: AgyMcpOptions | undefined;
|
|
51
61
|
let pluginWritten = false;
|
|
@@ -19,10 +19,15 @@ export function decodeAgyHook(event: string, value: unknown): NativeFactsInput |
|
|
|
19
19
|
if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record(call.args))) return;
|
|
20
20
|
const native = call?.args as Record<string, unknown> | undefined;
|
|
21
21
|
// Only the observed native read is classified. Unobserved edits never receive a scoped grant or effect attribution.
|
|
22
|
-
|
|
22
|
+
// 1.4.2 names it view_file(AbsolutePath); 1.4.3 renamed it read_file(Path, optional StartLine/EndLine).
|
|
23
|
+
const readPath = call?.name === "view_file" ? native?.AbsolutePath : call?.name === "read_file" ? native?.Path : undefined;
|
|
24
|
+
const observedRead = typeof readPath === "string";
|
|
25
|
+
// A line range is a partial read: carry it as offset/limit so it never counts as a whole-file view.
|
|
26
|
+
const range = observedRead && Number.isSafeInteger(native?.StartLine) && (native!.StartLine as number) > 0
|
|
27
|
+
? { offset: native!.StartLine as number, ...(Number.isSafeInteger(native?.EndLine) && (native!.EndLine as number) >= (native!.StartLine as number) ? { limit: (native!.EndLine as number) - (native!.StartLine as number) + 1 } : {}) } : {};
|
|
23
28
|
const phase = ({ SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" } as const)[event as typeof EVENTS[number]];
|
|
24
29
|
return { event: event as NativeFactsInput["event"], phase, sessionId: value.conversationId, transcriptPath: value.transcriptPath,
|
|
25
|
-
workspacePaths: value.workspacePaths as string[], tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path:
|
|
30
|
+
workspacePaths: value.workspacePaths as string[], tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path: readPath, ...range } : native ?? {},
|
|
26
31
|
...(Number.isSafeInteger(value.stepIdx) && (value.stepIdx as number) >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {}),
|
|
27
32
|
...(Number.isSafeInteger(value.invocationNum) && (value.invocationNum as number) >= 0 ? { invocation: value.invocationNum as number } : {}),
|
|
28
33
|
...(event === "Stop" ? { fullyIdle: value.fullyIdle as boolean, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}) };
|
package/src/adapters/pi.ts
CHANGED
|
@@ -52,10 +52,14 @@ export interface PiOptions {
|
|
|
52
52
|
onApprovalStop?: (reason: string) => void;
|
|
53
53
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
54
54
|
stopGraceMs?: number;
|
|
55
|
+
/** Host process-identity reader; never supplied by bridge events or peer config. */
|
|
56
|
+
signature?: typeof processSignature;
|
|
55
57
|
}
|
|
56
58
|
export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
|
|
57
59
|
type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
|
|
58
60
|
type VerifiedEmptyResume = { sessionId: string };
|
|
61
|
+
type TerminalOwnerObservation = { pid: number; signature: string; sessionId: string; sessionFile: string; ownerToken: string; checkedAt: number };
|
|
62
|
+
const TERMINAL_OWNER_MAX_AGE_MS = 1_000;
|
|
59
63
|
function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
60
64
|
|
|
61
65
|
|
|
@@ -121,6 +125,7 @@ export class PiPeer extends BasePeer {
|
|
|
121
125
|
private ownerPid?: number;
|
|
122
126
|
private ownerSignature?: string;
|
|
123
127
|
private ownerMonitor?: ReturnType<typeof setInterval>;
|
|
128
|
+
private terminalOwnerObservation?: TerminalOwnerObservation;
|
|
124
129
|
private tuiExit?: Promise<void>;
|
|
125
130
|
private resolveTuiExit?: () => void;
|
|
126
131
|
private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
@@ -139,7 +144,7 @@ export class PiPeer extends BasePeer {
|
|
|
139
144
|
if (this.state === "busy" || this.starting || this.activeTools) return false;
|
|
140
145
|
if (this.sessionFile && !existsSync(this.sessionFile) && (this.activityObserved || this.persistedOnce)) return false;
|
|
141
146
|
if (this.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
142
|
-
if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid &&
|
|
147
|
+
if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
|
|
143
148
|
return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
|
|
144
149
|
}
|
|
145
150
|
/** Explicit replacement may retain an unpersisted identity, but never a live/unknown owner or active effects. */
|
|
@@ -337,6 +342,7 @@ export class PiPeer extends BasePeer {
|
|
|
337
342
|
}
|
|
338
343
|
|
|
339
344
|
private async startImpl(): Promise<void> {
|
|
345
|
+
this.terminalOwnerObservation = undefined;
|
|
340
346
|
this.stopping = false;
|
|
341
347
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
342
348
|
this.requestedStopReason = undefined;
|
|
@@ -366,7 +372,7 @@ export class PiPeer extends BasePeer {
|
|
|
366
372
|
}
|
|
367
373
|
if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
368
374
|
const body = await request.json() as any;
|
|
369
|
-
if (url.pathname === "/event") { if (body.type === "session_start" && this.stopping) return Response.json({ ok: false, error: "Pi launch has ended" }, { status: 409 }); if (body.type === "session_start" && (typeof body.sessionId !== "string" || !body.sessionId || typeof body.sessionFile !== "string" || !body.sessionFile)) return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 }); if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || (this.sessionId && body.sessionId !== this.sessionId) || (this.sessionFile && body.sessionFile !== this.sessionFile))) return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 }); if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature ||
|
|
375
|
+
if (url.pathname === "/event") { if (body.type === "session_start" && this.stopping) return Response.json({ ok: false, error: "Pi launch has ended" }, { status: 409 }); if (body.type === "session_start" && (typeof body.sessionId !== "string" || !body.sessionId || typeof body.sessionFile !== "string" || !body.sessionFile)) return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 }); if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || (this.sessionId && body.sessionId !== this.sessionId) || (this.sessionFile && body.sessionFile !== this.sessionFile))) return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 }); if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature || this.readOwnerSignature(body.pid) !== body.signature)) return Response.json({ ok: false, error: "Pi process identity is not verified" }, { status: 409 }); this.handleBridgeEvent(body); return Response.json({ ok: true }); }
|
|
370
376
|
if (url.pathname === "/ack") {
|
|
371
377
|
const pending = this.tuiCommands.get(String(body.id));
|
|
372
378
|
if (!pending) return Response.json({ ok: false }, { status: 404 });
|
|
@@ -415,7 +421,7 @@ export class PiPeer extends BasePeer {
|
|
|
415
421
|
// The hub spawned this headless owner and get_state proved its session. Pin the same
|
|
416
422
|
// identity a native extension claim uses, including fakes that do not load the extension.
|
|
417
423
|
if (!this.ownerClaimed) {
|
|
418
|
-
const pid = this.proc?.pid, signature = pid ?
|
|
424
|
+
const pid = this.proc?.pid, signature = pid ? this.readOwnerSignature(pid) : undefined;
|
|
419
425
|
if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
|
|
420
426
|
this.ownerPid = pid; this.ownerSignature = signature;
|
|
421
427
|
this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
|
|
@@ -426,12 +432,26 @@ export class PiPeer extends BasePeer {
|
|
|
426
432
|
this.setState("idle");
|
|
427
433
|
}
|
|
428
434
|
|
|
435
|
+
private readOwnerSignature(pid: number): string | undefined { return (this.opts.signature ?? processSignature)(pid); }
|
|
436
|
+
|
|
429
437
|
private verifiedOwner(): boolean {
|
|
430
|
-
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature &&
|
|
438
|
+
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private rememberTerminalOwner(): void {
|
|
442
|
+
if (!this.ownerClaimed || !this.ownerPid || !this.ownerSignature) return;
|
|
443
|
+
this.terminalOwnerObservation = { pid: this.ownerPid, signature: this.ownerSignature, sessionId: this.sessionId,
|
|
444
|
+
sessionFile: this.sessionFile, ownerToken: this.ownerToken, checkedAt: performance.now() };
|
|
431
445
|
}
|
|
432
446
|
|
|
433
447
|
terminalOwner(_launchId: string): { generation: string; nativePid: number } | undefined {
|
|
434
|
-
|
|
448
|
+
const observed = this.terminalOwnerObservation;
|
|
449
|
+
// Reconciliation reads the monitor's bounded observation, never spawning ps on its 1 s tick.
|
|
450
|
+
// Effect admission and teardown keep their fresh process-identity checks.
|
|
451
|
+
if (this.opts.mode !== "tui" || !this.started || this.stopping || this.rosterDetached || this.state === "offline" ||
|
|
452
|
+
!this.ownerClaimed || !observed || performance.now() - observed.checkedAt > TERMINAL_OWNER_MAX_AGE_MS ||
|
|
453
|
+
observed.pid !== this.ownerPid || observed.signature !== this.ownerSignature || observed.sessionId !== this.sessionId ||
|
|
454
|
+
observed.sessionFile !== this.sessionFile || observed.ownerToken !== this.ownerToken || !this.sessionId || !this.sessionFile ||
|
|
435
455
|
this.opts.sessionId && this.opts.sessionId !== this.sessionId || this.opts.sessionFile && this.opts.sessionFile !== this.sessionFile) return undefined;
|
|
436
456
|
const generation = this.sessionGeneration;
|
|
437
457
|
return generation && this.ownerPid ? { generation, nativePid: this.ownerPid } : undefined;
|
|
@@ -448,6 +468,7 @@ export class PiPeer extends BasePeer {
|
|
|
448
468
|
/** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
|
|
449
469
|
async detachForRoster(): Promise<void> {
|
|
450
470
|
if (this.opts.mode !== "tui") throw new Error("Pi roster detach requires a personal TUI");
|
|
471
|
+
this.terminalOwnerObservation = undefined;
|
|
451
472
|
this.rosterDetached = true; this.rosterEpoch++;
|
|
452
473
|
this.setState("offline");
|
|
453
474
|
clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
|
|
@@ -489,6 +510,7 @@ export class PiPeer extends BasePeer {
|
|
|
489
510
|
async stop(reason?: string): Promise<void> {
|
|
490
511
|
if (reason !== undefined) this.requestedStopReason = reason;
|
|
491
512
|
this.rosterEpoch++;
|
|
513
|
+
this.terminalOwnerObservation = undefined;
|
|
492
514
|
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
493
515
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
494
516
|
this.activeDeliveryIds.clear();
|
|
@@ -713,6 +735,7 @@ export class PiPeer extends BasePeer {
|
|
|
713
735
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
714
736
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
715
737
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
738
|
+
this.rememberTerminalOwner();
|
|
716
739
|
this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
|
|
717
740
|
}
|
|
718
741
|
if (event.type === "session_shutdown") {
|
|
@@ -723,7 +746,7 @@ export class PiPeer extends BasePeer {
|
|
|
723
746
|
if (this.opts.mode === "tui") this.reportExit(exit);
|
|
724
747
|
}
|
|
725
748
|
if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
726
|
-
&&
|
|
749
|
+
&& this.readOwnerSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
|
|
727
750
|
&& typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
|
|
728
751
|
const pending = this.pendingToolGenerations.get(event.toolCallId);
|
|
729
752
|
if (pending && pending.generation === event.generation) pending.abort.abort();
|
|
@@ -846,6 +869,7 @@ export class PiPeer extends BasePeer {
|
|
|
846
869
|
}
|
|
847
870
|
|
|
848
871
|
private fail(error: Error): void {
|
|
872
|
+
this.terminalOwnerObservation = undefined;
|
|
849
873
|
const approvalStopReason = this.approvalStopReason;
|
|
850
874
|
this.abortTools();
|
|
851
875
|
this.agentRunning = false;
|
|
@@ -866,18 +890,24 @@ export class PiPeer extends BasePeer {
|
|
|
866
890
|
private startOwnerMonitor(): void {
|
|
867
891
|
this.clearOwnerMonitor();
|
|
868
892
|
if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
|
|
869
|
-
this.ownerMonitor = setInterval(() =>
|
|
870
|
-
if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
|
|
871
|
-
const exit = this.exitMetadata("owner_lost");
|
|
872
|
-
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
|
|
873
|
-
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
874
|
-
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
|
|
875
|
-
this.tuiCommands.clear(); this.tuiQueue = [];
|
|
876
|
-
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
877
|
-
this.reportExit(exit);
|
|
878
|
-
}, 500);
|
|
893
|
+
this.ownerMonitor = setInterval(() => this.checkOwner(), 500);
|
|
879
894
|
this.ownerMonitor.unref?.();
|
|
880
895
|
}
|
|
896
|
+
private checkOwner(): void {
|
|
897
|
+
if (!this.ownerPid) { this.terminalOwnerObservation = undefined; return; }
|
|
898
|
+
const current = this.readOwnerSignature(this.ownerPid);
|
|
899
|
+
if (current !== undefined && current === this.ownerSignature) { this.rememberTerminalOwner(); return; }
|
|
900
|
+
this.terminalOwnerObservation = undefined;
|
|
901
|
+
// Unknown process identity retains the existing live-owner fence, but is never terminal proof.
|
|
902
|
+
if (current === undefined) { try { process.kill(this.ownerPid, 0); return; } catch { /* positively gone */ } }
|
|
903
|
+
const exit = this.exitMetadata("owner_lost");
|
|
904
|
+
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
|
|
905
|
+
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
906
|
+
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
|
|
907
|
+
this.tuiCommands.clear(); this.tuiQueue = [];
|
|
908
|
+
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
909
|
+
this.reportExit(exit);
|
|
910
|
+
}
|
|
881
911
|
private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
|
|
882
912
|
private async endApprovalTurn(generation: number, reason: string): Promise<void> {
|
|
883
913
|
if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || !this.acceptingTools || this.state === "offline") return;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { HELP } from "./help.ts";
|
|
2
|
+
|
|
3
|
+
/** An option's value: a fixed list, a path, free text, or none (a flag). */
|
|
4
|
+
type Value = readonly string[] | "path" | "text" | null;
|
|
5
|
+
interface Node { children: Set<string>; options: Map<string, Value>; equals: Set<string> }
|
|
6
|
+
|
|
7
|
+
/** Printed instead of candidates when the shell should complete file names. */
|
|
8
|
+
export const FILES = "__files__";
|
|
9
|
+
const WORD = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
const ALTERNATION = /^[a-z][a-z0-9-]*(\|[a-z][a-z0-9-]*)*$/;
|
|
11
|
+
|
|
12
|
+
/** Split at spaces outside brackets; a bracket group stays one token. */
|
|
13
|
+
function tokens(text: string): string[] {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
let depth = 0, current = "";
|
|
16
|
+
for (const ch of text) {
|
|
17
|
+
if (ch === "[") depth++;
|
|
18
|
+
if (ch === "]") depth--;
|
|
19
|
+
if (ch === " " && depth === 0) { if (current) out.push(current); current = ""; } else current += ch;
|
|
20
|
+
}
|
|
21
|
+
if (current) out.push(current);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Split at ` | ` outside brackets: `ahub sdlc status [task] | explain <task>` has two alternatives. */
|
|
26
|
+
function alternatives(text: string): string[] {
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
let depth = 0, start = 0;
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
if (text[i] === "[") depth++;
|
|
31
|
+
if (text[i] === "]") depth--;
|
|
32
|
+
if (depth === 0 && text.startsWith(" | ", i)) { out.push(text.slice(start, i)); start = i + 3; i += 2; }
|
|
33
|
+
}
|
|
34
|
+
return [...out, text.slice(start)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A value spec: `a|b|<iso>` offers the literals a and b; an upper-case word (`N`) is a placeholder. Inside angle
|
|
39
|
+
* brackets the words are placeholders (`<path|id>` is a path, `<id|node>` free text), except a list of three or more
|
|
40
|
+
* (`<blue|teal|violet|ochre>`) and the keywords none and inherit.
|
|
41
|
+
*/
|
|
42
|
+
// ponytail: the keyword list is what help uses today; a new keyword inside <> needs adding here.
|
|
43
|
+
function value(spec: string): Value {
|
|
44
|
+
if (spec.startsWith("<")) {
|
|
45
|
+
const parts = spec.slice(1, -1).split("|");
|
|
46
|
+
if (parts.length >= 3) return parts;
|
|
47
|
+
if (/path|dir|file/.test(spec)) return "path";
|
|
48
|
+
const keywords = parts.filter(part => part === "none" || part === "inherit");
|
|
49
|
+
return keywords.length ? keywords : "text";
|
|
50
|
+
}
|
|
51
|
+
const literal = spec.split("|").filter(part => /^[a-z0-9][\w.-]*$/.test(part));
|
|
52
|
+
if (literal.length) return literal;
|
|
53
|
+
return /path|dir|file/.test(spec) ? "path" : "text";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options in a token group: `--flag`, `--name <value>`, `--name a|b`, `--name=a|b`; a group never lends its value to another. */
|
|
57
|
+
function options(group: string[], into: Node): void {
|
|
58
|
+
for (let i = 0; i < group.length; i++) {
|
|
59
|
+
const token = group[i]!;
|
|
60
|
+
if (token.startsWith("[")) { options(tokens(token.slice(1, -1)), into); continue; }
|
|
61
|
+
const eq = /^(--?[a-zA-Z][\w-]*)=(.+)$/.exec(token);
|
|
62
|
+
if (eq) { into.options.set(eq[1]!, value(eq[2]!)); into.equals.add(eq[1]!); continue; }
|
|
63
|
+
if (!/^--?[a-zA-Z][\w-]*$/.test(token)) continue;
|
|
64
|
+
const next = group[i + 1];
|
|
65
|
+
if (next !== undefined && !next.startsWith("-") && !next.startsWith("[") && next !== "|") { into.options.set(token, value(next)); i++; }
|
|
66
|
+
else if (!into.options.has(token)) into.options.set(token, null);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let table: Map<string, Node> | undefined;
|
|
71
|
+
/** The completion table, read from the `ahub help` entries so it cannot drift from what help documents. */
|
|
72
|
+
export function completionTable(): Map<string, Node> {
|
|
73
|
+
if (table) return table;
|
|
74
|
+
const nodes = new Map<string, Node>();
|
|
75
|
+
const node = (path: readonly string[]) => {
|
|
76
|
+
const key = path.join(" ");
|
|
77
|
+
let found = nodes.get(key);
|
|
78
|
+
if (!found) { found = { children: new Set(), options: new Map(), equals: new Set() }; nodes.set(key, found); }
|
|
79
|
+
if (path.length) node(path.slice(0, -1)).children.add(path.at(-1)!);
|
|
80
|
+
return found;
|
|
81
|
+
};
|
|
82
|
+
const root = node([]);
|
|
83
|
+
root.options.set("--project", "path").set("--live-test", null).set("--help", null).set("-h", null);
|
|
84
|
+
for (const { usage } of HELP) {
|
|
85
|
+
if (usage.includes("<command>")) continue; // the global --project and --live-test forms, set above
|
|
86
|
+
let parent: string[] = [];
|
|
87
|
+
alternatives(usage.replace(/^ahub /, "")).forEach((alt, index) => {
|
|
88
|
+
const parts = tokens(alt);
|
|
89
|
+
if (parts[0]?.startsWith("-")) { options(parts, index ? node(parent.slice(0, -1)) : root); return; }
|
|
90
|
+
let prefixes: string[][] = [index ? parent.slice(0, -1) : []];
|
|
91
|
+
let i = 0;
|
|
92
|
+
for (; i < parts.length && ALTERNATION.test(parts[i]!); i++) prefixes = prefixes.flatMap(prefix => parts[i]!.split("|").map(word => [...prefix, word]));
|
|
93
|
+
const sub = parts[i]?.match(/^\[([a-z][a-z0-9-]*(?:\|[a-z][a-z0-9-]*)+)\]$/);
|
|
94
|
+
for (const path of prefixes) {
|
|
95
|
+
const target = node(path);
|
|
96
|
+
if (sub) for (const word of sub[1]!.split("|")) node([...path, word]);
|
|
97
|
+
options(parts.slice(i), target);
|
|
98
|
+
}
|
|
99
|
+
parent = prefixes[0]!;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return (table = nodes);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Candidates for the last word, given every word after `ahub`. Static: no daemon, project or network. */
|
|
106
|
+
export function completeWords(words: readonly string[]): string[] {
|
|
107
|
+
const nodes = completionTable(), root = nodes.get("")!;
|
|
108
|
+
const cur = words.at(-1) ?? "";
|
|
109
|
+
const path: string[] = [];
|
|
110
|
+
let pending: Value | undefined;
|
|
111
|
+
let positional = false; // after a positional argument no subcommand can follow
|
|
112
|
+
const optionOf = (name: string) => nodes.get(path.join(" "))?.options.get(name) ?? root.options.get(name);
|
|
113
|
+
for (const word of words.slice(0, -1)) {
|
|
114
|
+
if (pending !== undefined) { pending = undefined; continue; }
|
|
115
|
+
if (word.startsWith("-")) { const kind = optionOf(word); if (kind && !word.includes("=")) pending = kind; continue; }
|
|
116
|
+
if (!positional && nodes.get(path.join(" "))?.children.has(word)) path.push(word);
|
|
117
|
+
else positional = true;
|
|
118
|
+
}
|
|
119
|
+
const values = (kind: Value, prefix = "") => kind === "path" ? [FILES] : Array.isArray(kind) ? kind.map(v => prefix + v) : [];
|
|
120
|
+
if (pending !== undefined) return values(pending).filter(v => v === FILES || v.startsWith(cur));
|
|
121
|
+
const eq = /^(--?[\w-]+)=/.exec(cur);
|
|
122
|
+
if (eq) return values(optionOf(eq[1]!) ?? null, eq[0]).filter(v => v === FILES || v.startsWith(cur));
|
|
123
|
+
const here = nodes.get(path.join(" "))!;
|
|
124
|
+
// An option documented as `--name=value` is offered as `--name=`; the shell adds no space after it.
|
|
125
|
+
const opts = [...new Set([...here.options.keys(), ...(path.length ? [] : root.options.keys())])].map(o => here.equals.has(o) ? `${o}=` : o);
|
|
126
|
+
const children = positional ? [] : [...here.children];
|
|
127
|
+
const candidates = cur.startsWith("-") ? opts : [...children, ...(cur && children.length ? [] : opts)];
|
|
128
|
+
const matched = candidates.filter(c => c.startsWith(cur)).sort();
|
|
129
|
+
return matched.length || cur.startsWith("-") || children.length ? matched : [FILES];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SCRIPTS: Record<string, string> = {
|
|
133
|
+
zsh: `#compdef ahub
|
|
134
|
+
# ahub zsh completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
|
|
135
|
+
_ahub() {
|
|
136
|
+
local -a c
|
|
137
|
+
c=("\${(@f)$(ahub __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)}")
|
|
138
|
+
c=(\${c:#})
|
|
139
|
+
if [[ "\${c[1]}" == ${FILES} ]]; then _files; return; fi
|
|
140
|
+
compadd -Q -S '' -- \${(M)c:#*=}
|
|
141
|
+
compadd -Q -- \${c:#*=}
|
|
142
|
+
}
|
|
143
|
+
compdef _ahub ahub
|
|
144
|
+
`,
|
|
145
|
+
bash: `# ahub bash completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
|
|
146
|
+
_ahub() {
|
|
147
|
+
local IFS=$'\\n' cur=\${COMP_WORDS[COMP_CWORD]}
|
|
148
|
+
local c=($(ahub __complete -- "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null))
|
|
149
|
+
if [ "\${c[0]}" = ${FILES} ]; then COMPREPLY=($(compgen -f -- "$cur")); else COMPREPLY=("\${c[@]}"); fi
|
|
150
|
+
case "\${COMPREPLY[*]}" in *=) compopt -o nospace 2>/dev/null ;; esac
|
|
151
|
+
}
|
|
152
|
+
complete -o default -F _ahub ahub
|
|
153
|
+
`,
|
|
154
|
+
fish: `# ahub fish completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
|
|
155
|
+
function __ahub_complete
|
|
156
|
+
set -l cur (commandline -ct)
|
|
157
|
+
# "$cur" keeps an empty current word as an argument: Tab after a space completes the next word
|
|
158
|
+
set -l out (ahub __complete -- (commandline -opc)[2..-1] "$cur" 2>/dev/null)
|
|
159
|
+
if test "$out[1]" = ${FILES}
|
|
160
|
+
__fish_complete_path (commandline -ct)
|
|
161
|
+
else
|
|
162
|
+
printf '%s\\n' $out
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
complete -c ahub -f -a '(__ahub_complete)'
|
|
166
|
+
`,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const COMPLETION_SHELLS = Object.keys(SCRIPTS);
|
|
170
|
+
/** The script for one shell, or undefined for a shell ahub does not support. */
|
|
171
|
+
export function completionScript(shell: string | undefined): string | undefined {
|
|
172
|
+
return shell === undefined ? undefined : SCRIPTS[shell];
|
|
173
|
+
}
|