@staix/agent-hub 0.12.42 → 0.12.44
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 +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +808 -255
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +50 -16
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +33 -21
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +471 -338
- package/src/hub/hub-tools.ts +37 -1
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
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;
|
|
@@ -915,3 +945,7 @@ export class PiPeer extends BasePeer {
|
|
|
915
945
|
void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
|
|
916
946
|
}
|
|
917
947
|
}
|
|
948
|
+
|
|
949
|
+
export function isPiPeer(value: unknown): value is PiPeer {
|
|
950
|
+
return value instanceof PiPeer;
|
|
951
|
+
}
|
|
@@ -90,6 +90,7 @@ export interface VersionContext {
|
|
|
90
90
|
env: Readonly<Record<string, string>>;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Compatibility type for frozen A1 permission metadata, not a runtime permission port. */
|
|
93
94
|
export type NativeModeSpec =
|
|
94
95
|
| { kind: "acp-mode"; modeId: string }
|
|
95
96
|
| { kind: "codex-approval"; policy: string }
|
|
@@ -100,7 +101,6 @@ export type NativeModeSpec =
|
|
|
100
101
|
export interface QuotaReader {
|
|
101
102
|
source: string;
|
|
102
103
|
read(value: unknown): UsageWindow[];
|
|
103
|
-
resetOnWindow?: boolean;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export interface ContextReader {
|
|
@@ -145,12 +145,7 @@ export interface NativeFactsPort {
|
|
|
145
145
|
|
|
146
146
|
/** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
|
|
147
147
|
export interface NativeHooks {
|
|
148
|
-
agentIdentity(init: unknown): void;
|
|
149
|
-
onAttach(instance: string): void;
|
|
150
|
-
onDetach(instance: string): void;
|
|
151
148
|
factsHook: NativeFactsPort;
|
|
152
|
-
conversationRevert(turnId: string): Promise<void>;
|
|
153
|
-
replaceSession(req: StartRequest): Promise<unknown>;
|
|
154
149
|
pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
|
|
155
150
|
recallSource: string;
|
|
156
151
|
recallInjected: boolean;
|
|
@@ -182,8 +177,8 @@ export interface AdapterDefinition {
|
|
|
182
177
|
start: {
|
|
183
178
|
forms: readonly StartForm[];
|
|
184
179
|
defaultForm: StartForm;
|
|
185
|
-
|
|
186
|
-
launch(ctx: LaunchContext): LaunchPlan;
|
|
180
|
+
/** A real native argv planner; absent when startup belongs to a separate verified host port. */
|
|
181
|
+
launch?(ctx: LaunchContext): LaunchPlan;
|
|
187
182
|
create?(ctx: CreateContext): PeerAdapter;
|
|
188
183
|
daemonPort?: DaemonEngine;
|
|
189
184
|
preflight?(ctx: StartRequest): string | undefined;
|
|
@@ -203,16 +198,20 @@ export interface AdapterDefinition {
|
|
|
203
198
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
204
199
|
};
|
|
205
200
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
201
|
+
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
202
|
+
instances?: { mode: "external" | "headless"; roles?: readonly string[] };
|
|
206
203
|
attach?: {
|
|
204
|
+
/** The authenticated external hello may claim this id; independent of MCP configuration shape. */
|
|
205
|
+
externalHello?: boolean;
|
|
207
206
|
pullOnlyForms?: readonly StartForm[];
|
|
208
207
|
recoveryLaunch?(instance: string): Record<string, unknown>;
|
|
209
208
|
pullOnlyNotice?: string;
|
|
210
209
|
admission?: { reservedRefusal: string; watchRefusal: string };
|
|
211
210
|
};
|
|
212
211
|
permission: {
|
|
213
|
-
|
|
212
|
+
/** Consumed configured-ACP mode capability; native runtime and instance ceiling use the same validated mapping. */
|
|
213
|
+
genericAcp?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string };
|
|
214
214
|
grants?: Partial<Record<PermissionMode, string>>;
|
|
215
|
-
readOnly?: NativeModeSpec;
|
|
216
215
|
unmanaged?: string;
|
|
217
216
|
unmanagedForms?: readonly StartForm[];
|
|
218
217
|
unmanagedNote?: string;
|
|
@@ -273,12 +272,6 @@ export interface AdapterDefinition {
|
|
|
273
272
|
validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
|
|
274
273
|
};
|
|
275
274
|
capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
|
|
276
|
-
init?: {
|
|
277
|
-
templateFragment?: string;
|
|
278
|
-
mcpFile?: string;
|
|
279
|
-
/** Pure planner; the CLI host owns every write and cleanup. */
|
|
280
|
-
mcpPlan?(ctx: { projectRoot: string; stateDir: string; entrypoint: string; form: StartForm }): { file: string; tracked: boolean; changed: boolean; action: string };
|
|
281
|
-
};
|
|
282
275
|
cli: {
|
|
283
276
|
command: string;
|
|
284
277
|
/** Configuration leaves that select this peer's next native launch command or provider profile. */
|
|
@@ -302,3 +295,12 @@ export interface AdapterDefinition {
|
|
|
302
295
|
|
|
303
296
|
/** Explicit project declarations, passed by the bound host. No process-global instance registration. */
|
|
304
297
|
export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
|
|
298
|
+
|
|
299
|
+
/** Immutable per-project resolver; exported globals remain builtin-only. */
|
|
300
|
+
export interface ProjectAdapterCatalog {
|
|
301
|
+
readonly definitions: readonly AdapterDefinition[];
|
|
302
|
+
definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
|
|
303
|
+
kindOf(id: string, instances?: KindMap): string | undefined;
|
|
304
|
+
familyOf(id: string, instances?: KindMap): string | undefined;
|
|
305
|
+
isSupportedPeer(id: unknown, instances?: KindMap): id is string;
|
|
306
|
+
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { DAEMON_PORTS } from "./definitions/native-factories.ts";
|
|
|
6
6
|
export { DAEMON_PORTS };
|
|
7
7
|
import { NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN } from "./definitions/builtin-metadata.ts";
|
|
8
8
|
import { QWEN_DEFINITION } from "./definitions/qwen.ts";
|
|
9
|
-
import
|
|
9
|
+
import { configuredAcpDefinitions } from "./configured-acp.ts";
|
|
10
|
+
import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./registry-types.ts";
|
|
10
11
|
import { types } from "node:util";
|
|
11
12
|
|
|
12
13
|
export type * from "./registry-types.ts";
|
|
@@ -41,9 +42,9 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
|
41
42
|
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
|
|
42
43
|
const definitions = new Map<string, AdapterDefinition>();
|
|
43
44
|
for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
44
|
-
|
|
45
|
+
assertDefinitionCoherence(definition);
|
|
45
46
|
assertNativeCapabilityScope(definition);
|
|
46
|
-
if (definition.reviewerVariant) {
|
|
47
|
+
if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
47
48
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
48
49
|
definitions.set(definition.kind, definition);
|
|
49
50
|
}
|
|
@@ -65,7 +66,7 @@ function ownField(value: unknown, key: string): unknown {
|
|
|
65
66
|
return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
|
|
66
67
|
}
|
|
67
68
|
/** Declaration reads never execute caller accessors or inherit another project's mapping. */
|
|
68
|
-
function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
|
|
69
|
+
function declaredKind(id: string, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition> = definitions): string | undefined {
|
|
69
70
|
if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
|
|
70
71
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(instances))) return undefined;
|
|
71
72
|
const field = Object.getOwnPropertyDescriptor(instances, id);
|
|
@@ -74,7 +75,7 @@ function declaredKind(id: string, instances: KindMap | undefined): string | unde
|
|
|
74
75
|
if (!entry || typeof entry !== "object" || types.isProxy(entry) || Array.isArray(entry)
|
|
75
76
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return undefined;
|
|
76
77
|
const kind = Object.getOwnPropertyDescriptor(entry, "kind");
|
|
77
|
-
if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !
|
|
78
|
+
if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !catalog.has(kind.value)) return undefined;
|
|
78
79
|
const prefix = `${kind.value}-`;
|
|
79
80
|
return /^[a-z][a-z0-9-]{0,31}$/.test(id) && id.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)) ? kind.value : undefined;
|
|
80
81
|
}
|
|
@@ -84,7 +85,10 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
|
|
|
84
85
|
}
|
|
85
86
|
export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
|
|
86
87
|
const kind = kindOf(id, instances);
|
|
87
|
-
|
|
88
|
+
return resolvedDefinition(id, kind, instances, definitions);
|
|
89
|
+
}
|
|
90
|
+
function resolvedDefinition(id: string, kind: string | undefined, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition>): AdapterDefinition | undefined {
|
|
91
|
+
const definition = kind === undefined ? undefined : catalog.get(kind);
|
|
88
92
|
// #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
|
|
89
93
|
const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
|
|
90
94
|
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
@@ -96,6 +100,24 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
|
96
100
|
return definitionOf(id, instances)?.family;
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/** Compile one captured, host-trusted declaration set; never register native policy globally. */
|
|
104
|
+
export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapterCatalog {
|
|
105
|
+
const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
|
|
106
|
+
for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
|
|
107
|
+
const entries = Object.freeze([...ADAPTERS, ...configured]);
|
|
108
|
+
const catalog = new Map([...definitions, ...configured.map(definition => [definition.kind, definition] as const)]);
|
|
109
|
+
const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
|
|
110
|
+
const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
|
|
111
|
+
return Object.freeze({ definitions: entries, kindOf, definitionOf,
|
|
112
|
+
familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
|
|
113
|
+
isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
|
|
114
|
+
if (typeof id !== "string") return false;
|
|
115
|
+
const kind = kindOf(id, instances), runtime = kind === undefined ? undefined : catalog.get(kind), resolved = definitionOf(id, instances);
|
|
116
|
+
return runtime !== undefined && resolved !== undefined && (kind === id || resolved === runtime || resolved.reviewer?.unavailable === undefined);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
/** Compatibility projection order is observable in default role maps and planner selection. */
|
|
100
122
|
const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
|
|
101
123
|
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
|
@@ -126,6 +148,46 @@ export function assertDaemonEngine(definition: Pick<AdapterDefinition, "start">)
|
|
|
126
148
|
if (definition.start.create && engine === undefined || engine !== undefined && !Object.hasOwn(DAEMON_PORTS, engine)) throw new Error("invalid adapter daemon engine");
|
|
127
149
|
}
|
|
128
150
|
|
|
151
|
+
/** Structural registry contract only: native telemetry readers are never executed during loading. */
|
|
152
|
+
export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
153
|
+
const fail = (field: string, reason: string): never => { throw new Error(`${definition.kind}.${field}: ${reason}`); };
|
|
154
|
+
const nonempty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
|
155
|
+
assertDaemonEngine(definition);
|
|
156
|
+
if (definition.identity.mcp === "channel-plugin" && definition.transport !== "claude-channel" && definition.transport !== "ws-pull") {
|
|
157
|
+
fail("identity.mcp", "channel-plugin requires claude-channel or ws-pull transport");
|
|
158
|
+
}
|
|
159
|
+
if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
|
|
160
|
+
fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
|
|
161
|
+
}
|
|
162
|
+
const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
|
|
163
|
+
if (pullOnly !== undefined) {
|
|
164
|
+
if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
|
|
165
|
+
}
|
|
166
|
+
if (definition.transport === "ws-pull" || definition.transport === "claude-channel" || definition.instances?.mode === "external") {
|
|
167
|
+
if (attach?.externalHello !== true) fail("attach.externalHello", "external transport or instance admission requires an explicit hello policy");
|
|
168
|
+
}
|
|
169
|
+
if (definition.transport === "ws-pull" && !pullOnly?.length) fail("attach.pullOnlyForms", "ws-pull requires a nonempty pull-only contract");
|
|
170
|
+
if (attach?.admission !== undefined) {
|
|
171
|
+
if (!attach.admission || typeof attach.admission !== "object") fail("attach.admission", "admission must declare its refusal policy");
|
|
172
|
+
if (!definition.start.forms.includes("tui") || !pullOnly?.includes("tui")) fail("attach.admission", "native admission requires a supported pull-only TUI form");
|
|
173
|
+
for (const field of ["reservedRefusal", "watchRefusal"] as const) {
|
|
174
|
+
if (!nonempty(attach.admission[field])) fail(`attach.admission.${field}`, "admission refusal text must be nonempty");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (definition.budget !== undefined) {
|
|
178
|
+
if (!nonempty(definition.budget?.account)) fail("budget.account", "declared budget account must be a nonempty string");
|
|
179
|
+
for (const field of ["reader", "context"] as const) {
|
|
180
|
+
const reader = definition.budget[field];
|
|
181
|
+
if (reader === undefined) continue;
|
|
182
|
+
if (!reader || typeof reader !== "object") fail(`budget.${field}`, "telemetry reader must be an object");
|
|
183
|
+
if (!nonempty(reader.source)) fail(`budget.${field}.source`, "telemetry source must be a nonempty string");
|
|
184
|
+
if (typeof reader.read !== "function") fail(`budget.${field}.read`, "telemetry read must be callable");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
|
|
188
|
+
if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
/** Host-global native quirks are not capabilities a config-only or external definition may opt into. */
|
|
130
192
|
export function assertNativeCapabilityScope(definition: AdapterDefinition): void {
|
|
131
193
|
if (fixedBuiltinDefinitions.has(definition)) return;
|
|
@@ -380,3 +380,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
380
380
|
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
|
|
384
|
+
export function isReviewerPrintPeer(value: unknown): value is ReviewerPrintPeer {
|
|
385
|
+
return value instanceof ReviewerPrintPeer;
|
|
386
|
+
}
|
package/src/cli/agy-mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AGY_DEFINITION } from "../adapters/agy/definition.ts";
|
|
1
2
|
import type { Stats } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -53,7 +54,7 @@ function shellUnquote(value: string): string | undefined {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function validateOptions(options: AgyMcpOptions) {
|
|
56
|
-
const peer = options.peer ??
|
|
57
|
+
const peer = options.peer ?? AGY_DEFINITION.kind;
|
|
57
58
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
|
|
58
59
|
for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
|
|
59
60
|
if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
|
|
@@ -326,7 +327,7 @@ export function cleanupAgyMcp(options: AgyMcpOptions): void {
|
|
|
326
327
|
if (!present.length) return;
|
|
327
328
|
if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
|
|
328
329
|
const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
|
|
329
|
-
const launch = managedHookLaunch(currentHooks, options, options.peer ??
|
|
330
|
+
const launch = managedHookLaunch(currentHooks, options, options.peer ?? AGY_DEFINITION.kind);
|
|
330
331
|
if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
|
|
331
332
|
// A stale cleanup never removes files a newer config update has replaced.
|
|
332
333
|
if (plan.changed) 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
|
+
}
|