@staix/agent-hub 0.12.33 → 0.12.34
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 +9 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/daemon.md +3 -3
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +20 -10
- package/docs/quickstart.md +4 -2
- package/docs/security.md +4 -1
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +92 -141
- package/src/cli/upgrade.ts +8 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +226 -232
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +13 -10
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
|
@@ -85,8 +85,13 @@ export class CodexPeer extends BasePeer {
|
|
|
85
85
|
private tokenTotal: number | undefined;
|
|
86
86
|
private freshThread = false;
|
|
87
87
|
private readonly activeTurns = new Set<string>();
|
|
88
|
+
private deliveryEpoch = 0;
|
|
89
|
+
/** Cancellation tombstones last until that exact native completion or session replacement. */
|
|
90
|
+
private readonly retiredTurns = new Set<string>();
|
|
91
|
+
private unidentifiedCancellation = false;
|
|
92
|
+
private unboundAccepted = false;
|
|
88
93
|
private nextId = -1;
|
|
89
|
-
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void }>();
|
|
94
|
+
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
|
|
90
95
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
91
96
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
92
97
|
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
@@ -189,16 +194,35 @@ export class CodexPeer extends BasePeer {
|
|
|
189
194
|
return Promise.reject(new Error(`${this.id} is not injectable`));
|
|
190
195
|
}
|
|
191
196
|
const text = this.render(envs);
|
|
192
|
-
const id = this.nextId
|
|
197
|
+
const id = this.nextId--, epoch = this.deliveryEpoch, session = this.contextEpoch;
|
|
193
198
|
this.setState("busy"); // claim the turn now so the bus stops draining
|
|
194
199
|
return new Promise<void>((resolve, reject) => {
|
|
200
|
+
let invalidated = false;
|
|
201
|
+
const retiredAtCancel = new Set<string>();
|
|
202
|
+
const invalidate = (reason: string) => {
|
|
203
|
+
if (invalidated) return;
|
|
204
|
+
invalidated = true;
|
|
205
|
+
for (const turn of [...this.activeTurns, ...this.turnDeliveries.keys()]) if (!turn.startsWith("unknown:")) retiredAtCancel.add(turn);
|
|
206
|
+
envs = []; // only the late native-id callback survives, never the cancelled audience/prompt
|
|
207
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
|
|
208
|
+
reject(new Error(reason));
|
|
209
|
+
};
|
|
195
210
|
this.pending.set(id, {
|
|
196
|
-
deliveryId, kind: "deliver",
|
|
211
|
+
deliveryId, kind: "deliver", invalidate,
|
|
197
212
|
resolve: (result) => {
|
|
213
|
+
const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
|
|
214
|
+
if (invalidated || epoch !== this.deliveryEpoch || session !== this.contextEpoch || link !== this.link) {
|
|
215
|
+
invalidate("Codex turn acceptance arrived after its delivery was invalidated");
|
|
216
|
+
if (nativeTurn && session === this.contextEpoch && link === this.link &&
|
|
217
|
+
(!retiredAtCancel.has(nativeTurn) || this.retiredTurns.has(nativeTurn))) this.retireTurn(nativeTurn);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
198
220
|
this.primed = true;
|
|
199
221
|
this.injected = replyParent(envs);
|
|
200
222
|
this.answering = [...envs];
|
|
201
|
-
|
|
223
|
+
// Native acceptance can precede turn/started, also for legacy journal-less callers.
|
|
224
|
+
if (nativeTurn && !this.turnDeliveries.has(nativeTurn)) this.turnDeliveries.set(nativeTurn, new Set());
|
|
225
|
+
if (!nativeTurn && !this.activeTurns.size) this.unboundAccepted = true;
|
|
202
226
|
if (deliveryId) {
|
|
203
227
|
if (nativeTurn) this.addTurnDelivery(nativeTurn, deliveryId);
|
|
204
228
|
else this.unboundDeliveries.add(deliveryId);
|
|
@@ -208,6 +232,10 @@ export class CodexPeer extends BasePeer {
|
|
|
208
232
|
},
|
|
209
233
|
reject: (e) => {
|
|
210
234
|
this.restoringApprovals.delete(id);
|
|
235
|
+
if (invalidated) return; // uncertainty already settled; a late refusal cannot relabel it failed_safe
|
|
236
|
+
if (epoch !== this.deliveryEpoch || session !== this.contextEpoch || link !== this.link) {
|
|
237
|
+
invalidate("Codex turn refusal arrived after its delivery was invalidated"); return;
|
|
238
|
+
}
|
|
211
239
|
if (!this.activeTurns.size) this.setState("idle");
|
|
212
240
|
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
213
241
|
reject(e);
|
|
@@ -298,19 +326,34 @@ export class CodexPeer extends BasePeer {
|
|
|
298
326
|
|
|
299
327
|
/** A silent turn is interrupted before the peer is declared idle, or the next turn/start would land inside it. */
|
|
300
328
|
protected override onWatchdog(): void {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
329
|
+
this.deliveryEpoch++;
|
|
330
|
+
const known = new Set([...this.activeTurns, ...this.turnDeliveries.keys()].filter(id => !id.startsWith("unknown:")));
|
|
331
|
+
const pendingStarts = [...this.pending.values()].filter(pending => pending.kind === "deliver");
|
|
332
|
+
if ([...this.activeTurns].some(id => id.startsWith("unknown:")) || (this.unboundDeliveries.size || this.unboundAccepted || pendingStarts.length) && !known.size) {
|
|
333
|
+
// ponytail: after one unidentifiable cancellation, lifecycle attribution requires a session reattach.
|
|
334
|
+
// Never guess that an unnamed late report or a later named start belongs to a new turn.
|
|
335
|
+
this.unidentifiedCancellation = true;
|
|
336
|
+
this.opts.log?.("Codex cancelled turn has no verified native id; reattach the native session before further hub delivery");
|
|
304
337
|
}
|
|
338
|
+
for (const turnId of known) this.retireTurn(turnId);
|
|
339
|
+
for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
|
|
305
340
|
this.activeTurns.clear();
|
|
341
|
+
this.unboundAccepted = false;
|
|
306
342
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
307
343
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
308
344
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
309
345
|
this.injected = undefined;
|
|
310
346
|
this.answering = [];
|
|
311
347
|
this.lastAnswer = "";
|
|
348
|
+
this.deltas.clear();
|
|
312
349
|
this.abandonSteers("turn went silent");
|
|
313
|
-
|
|
350
|
+
if (this.unidentifiedCancellation) this.setState("offline");
|
|
351
|
+
else super.onWatchdog();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private retireTurn(turnId: string): void {
|
|
355
|
+
this.retiredTurns.add(turnId);
|
|
356
|
+
if (this.link?.up.readyState === WebSocket.OPEN) this.link.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
|
|
314
357
|
}
|
|
315
358
|
|
|
316
359
|
/** A steer nobody answered must not vanish: rejecting it sends the envelope back to the queue. */
|
|
@@ -394,10 +437,13 @@ export class CodexPeer extends BasePeer {
|
|
|
394
437
|
this.restoringApprovals.clear();
|
|
395
438
|
this.threadId = "";
|
|
396
439
|
this.contextEpoch++;
|
|
440
|
+
this.deliveryEpoch++;
|
|
441
|
+
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
397
442
|
this.activeTurns.clear();
|
|
443
|
+
this.unboundAccepted = false;
|
|
398
444
|
for (const p of this.pending.values()) {
|
|
399
|
-
if (p.
|
|
400
|
-
p.reject(new Error(p.kind === "steer" ? "Codex TUI detached" : "codex TUI detached"));
|
|
445
|
+
if (p.kind === "deliver") p.invalidate?.("Codex TUI detached");
|
|
446
|
+
else p.reject(new Error(p.kind === "steer" ? "Codex TUI detached" : "codex TUI detached"));
|
|
401
447
|
}
|
|
402
448
|
this.pending.clear();
|
|
403
449
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
@@ -483,7 +529,8 @@ export class CodexPeer extends BasePeer {
|
|
|
483
529
|
|
|
484
530
|
private fromAppServer(link: Link, raw: string): void {
|
|
485
531
|
const msg = parse(raw);
|
|
486
|
-
|
|
532
|
+
const nativeId = msg?.params?.turnId ?? msg?.params?.turn?.id ?? msg?.result?.turn?.id;
|
|
533
|
+
if (this.state === "busy" && !(typeof nativeId === "string" && this.retiredTurns.has(nativeId))) this.touch();
|
|
487
534
|
if (!msg) return void link.tui.send(raw);
|
|
488
535
|
|
|
489
536
|
if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
|
|
@@ -511,12 +558,22 @@ export class CodexPeer extends BasePeer {
|
|
|
511
558
|
private adopt(link: Link, threadId: unknown, fresh = false): void {
|
|
512
559
|
if (typeof threadId !== "string" || !threadId) return;
|
|
513
560
|
this.abandonSteers("thread changed");
|
|
561
|
+
for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
|
|
562
|
+
pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
|
|
563
|
+
this.pending.delete(id); // a replaced session needs no late native-id callback
|
|
564
|
+
}
|
|
565
|
+
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
|
|
566
|
+
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
|
|
567
|
+
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
514
568
|
this.tokenTotal = undefined;
|
|
515
569
|
this.freshThread = fresh;
|
|
516
570
|
this.link = link;
|
|
517
571
|
this.threadId = threadId;
|
|
518
572
|
this.contextEpoch++;
|
|
573
|
+
this.deliveryEpoch++;
|
|
574
|
+
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
519
575
|
this.activeTurns.clear();
|
|
576
|
+
this.unboundAccepted = false;
|
|
520
577
|
this.forgetTurn();
|
|
521
578
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
522
579
|
this.opts.onThread?.(threadId, fresh);
|
|
@@ -560,6 +617,13 @@ export class CodexPeer extends BasePeer {
|
|
|
560
617
|
this.opts.onUsage?.({ rateLimitReachedType: "usageLimitExceeded" }, true);
|
|
561
618
|
}
|
|
562
619
|
if (link !== this.link || params.threadId !== this.threadId) return;
|
|
620
|
+
const nativeId = method === "turn/started" || method === "turn/completed" ? params.turn?.id : params.turnId;
|
|
621
|
+
if (typeof nativeId === "string" && this.retiredTurns.has(nativeId)) {
|
|
622
|
+
if (method === "turn/completed") this.retiredTurns.delete(nativeId);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (this.unidentifiedCancellation) return; // native traffic is still forwarded to its TUI; hub attribution is fenced
|
|
626
|
+
const unknownTurn = () => [...this.activeTurns].some(id => id.startsWith("unknown:"));
|
|
563
627
|
if (method === "thread/tokenUsage/updated") {
|
|
564
628
|
this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
|
|
565
629
|
const total = Number(params.tokenUsage?.total?.totalTokens);
|
|
@@ -571,22 +635,26 @@ export class CodexPeer extends BasePeer {
|
|
|
571
635
|
}
|
|
572
636
|
if (method === "turn/started") {
|
|
573
637
|
const nativeTurn = params.turn?.id ?? `unknown:${Date.now()}`;
|
|
638
|
+
this.unboundAccepted = false;
|
|
574
639
|
this.activeTurns.add(nativeTurn);
|
|
575
640
|
for (const id of [...this.unboundDeliveries]) this.addTurnDelivery(nativeTurn, id);
|
|
576
641
|
this.lastAnswer = "";
|
|
577
642
|
this.setState("busy");
|
|
578
643
|
if (params.turn?.id) this.opts.onTurn?.(params.turn.id);
|
|
579
644
|
} else if (method === "item/agentMessage/delta") {
|
|
645
|
+
if (!this.activeTurns.size || typeof params.turnId === "string" && !this.activeTurns.has(params.turnId) && !unknownTurn()) return;
|
|
580
646
|
const buf = this.deltas.get(params.itemId) ?? [];
|
|
581
647
|
buf.push(params.delta);
|
|
582
648
|
this.deltas.set(params.itemId, buf);
|
|
583
649
|
} else if (method === "item/completed" && ["fileChange", "commandExecution", "mcpToolCall", "userMessage"].includes(params.item?.type)) {
|
|
650
|
+
if (typeof params.turnId === "string" && !this.activeTurns.has(params.turnId) && !unknownTurn()) return;
|
|
584
651
|
try {
|
|
585
652
|
this.opts.onItem?.(params.item, typeof params.turnId === "string" && this.activeTurns.has(params.turnId) ? params.turnId : undefined);
|
|
586
653
|
} catch (error) {
|
|
587
654
|
this.opts.log?.(`[${this.id}] item handler failed: ${(error as Error).message}`); // the proxy keeps going
|
|
588
655
|
}
|
|
589
656
|
} else if (method === "item/completed" && params.item?.type === "agentMessage") {
|
|
657
|
+
if (!this.activeTurns.size || typeof params.turnId === "string" && !this.activeTurns.has(params.turnId) && !unknownTurn()) return;
|
|
590
658
|
const item = params.item;
|
|
591
659
|
const text: string =
|
|
592
660
|
item.text ?? item.content?.map((c: any) => c.text ?? "").join("") ?? this.deltas.get(item.id)?.join("") ?? "";
|
|
@@ -595,6 +663,8 @@ export class CodexPeer extends BasePeer {
|
|
|
595
663
|
if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
|
|
596
664
|
} else if (method === "turn/completed") {
|
|
597
665
|
const nativeTurn = params.turn?.id;
|
|
666
|
+
// A watchdog or a newer turn has invalidated this completion. Unknown-id legacy starts retain their fallback.
|
|
667
|
+
if (!this.activeTurns.size || typeof nativeTurn === "string" && !this.activeTurns.has(nativeTurn) && ![...this.activeTurns].some(id => id.startsWith("unknown:"))) return;
|
|
598
668
|
this.activeTurns.delete(nativeTurn);
|
|
599
669
|
for (const id of this.activeTurns) if (id.startsWith("unknown:")) this.activeTurns.delete(id);
|
|
600
670
|
if (params.turn?.status === "failed") {
|
|
@@ -1,12 +1,25 @@
|
|
|
1
|
+
import { kimiAcpCommand } from "./native-daemon-kimi.ts";
|
|
2
|
+
import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
|
|
3
|
+
import { piConfigSchema, localConfigSchema, piRoutingSchema, localRoutingSchema, PI_SETTINGS, PI_RESTART_REASONS, LOCAL_RESTART_REASONS, piEffectiveConfig } from "./native-config-policy.ts";
|
|
4
|
+
import { claudeCrashPlan, codexCrashPlan, kimiCrashPlan, piCrashPlan, localCrashPlan, jcodeCrashPlan, claudePluginInventory, codexPluginInventory, parsePiContinuity, codexToolSemantic } from "./native-policy-aux.ts";
|
|
5
|
+
import { piRecoveryContinuity } from "./native-recovery-continuity.ts";
|
|
6
|
+
import { piConductorStartArgs } from "./native-cli-pi.ts";
|
|
7
|
+
import { claudeNativeLaunch, CLAUDE_LAUNCH_POLICY } from "./native-cli-claude-launch.ts";
|
|
8
|
+
import { codexNativeLaunch, CODEX_LAUNCH_POLICY } from "./native-cli-codex-launch.ts";
|
|
9
|
+
import { createCodex, createKimi, createJcode, createPi, createLocal } from "./native-factories.ts";
|
|
10
|
+
import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
|
|
11
|
+
import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
12
|
+
import { claudeWindows, codexWindows } from "./native-quota.ts";
|
|
13
|
+
import { claudeContext, codexContext, acpContextValue } from "./native-context.ts";
|
|
1
14
|
import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm } from "../registry-types.ts";
|
|
2
15
|
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
3
|
-
import { shellQuote } from "../../cli/
|
|
16
|
+
import { shellQuote } from "../../cli/shell-quote.ts";
|
|
4
17
|
|
|
5
18
|
type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
|
|
6
19
|
|
|
7
20
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
21
|
+
* Preserve CLI-wrapper argv plans for definitions that still declare them and the frozen A1 compatibility export.
|
|
22
|
+
* Native startup and daemon factories are supplied separately by the runtime definitions.
|
|
10
23
|
*/
|
|
11
24
|
function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext): LaunchPlan {
|
|
12
25
|
const mode = kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
|
|
@@ -35,7 +48,6 @@ const switches = Object.freeze(["--unattended", "--safe", "--new"]);
|
|
|
35
48
|
const implementerRoles = Object.freeze(["implementer", "verifier"]);
|
|
36
49
|
const dualForms = Object.freeze(["tui", "headless"] as const);
|
|
37
50
|
const headlessForms = Object.freeze(["headless"] as const);
|
|
38
|
-
const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
|
|
39
51
|
const hubGrants = {
|
|
40
52
|
ask: "every write, edit and shell command asks",
|
|
41
53
|
"ask-when-needed": "write and edit inside the project run without asking; shell and git writes still ask",
|
|
@@ -44,6 +56,91 @@ const hubGrants = {
|
|
|
44
56
|
|
|
45
57
|
// Sources: peer-catalog.ts roles, roster.ts defaults, start-mode.ts forms, permission-mode.ts grants,
|
|
46
58
|
// daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
|
|
59
|
+
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
60
|
+
{
|
|
61
|
+
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
62
|
+
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
63
|
+
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", ownsTerminal: terminal, launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
|
|
64
|
+
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
65
|
+
identity: { mcp: "channel-plugin" }, attach: { recoveryLaunch: claudeRecoveryLaunch },
|
|
66
|
+
// Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
|
|
67
|
+
permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
|
|
68
|
+
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
69
|
+
"never-ask": "every tool runs without asking" } },
|
|
70
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
71
|
+
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
72
|
+
cli: { command: "claude", retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
76
|
+
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
77
|
+
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
|
|
78
|
+
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
|
|
79
|
+
identity: { mcp: "env" },
|
|
80
|
+
permission: { trackUnattended: true, modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
|
|
81
|
+
"ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
|
|
82
|
+
grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
83
|
+
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
84
|
+
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
85
|
+
cli: { command: "codex", retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
89
|
+
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
90
|
+
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
91
|
+
// ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
|
|
92
|
+
identity: { mcp: "acp-session" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
|
|
93
|
+
pullOnlyNotice: "pull-only: messages wait for hub_inbox; no push, steer, watchdog or automatic quota handoff",
|
|
94
|
+
admission: { reservedRefusal: "Kimi attaches pull-only in TUI mode; run ahub kimi", watchRefusal: "Kimi is in pull-only TUI mode; use its native terminal or start headless" } },
|
|
95
|
+
permission: { modes: { ask: { kind: "acp-mode", modeId: "default" }, "ask-when-needed": { kind: "acp-mode", modeId: "yolo" }, "never-ask": { kind: "acp-mode", modeId: "auto" } },
|
|
96
|
+
grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
|
|
97
|
+
unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
|
|
98
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
99
|
+
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
100
|
+
cli: { command: "kimi", stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
104
|
+
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
105
|
+
start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
|
|
106
|
+
identity: { mcp: "env" },
|
|
107
|
+
// Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
|
|
108
|
+
permission: { modes: {}, grants: { ...hubGrants } },
|
|
109
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
110
|
+
turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
111
|
+
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
112
|
+
cli: { command: "pi", previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
116
|
+
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
117
|
+
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
118
|
+
identity: { mcp: "env" },
|
|
119
|
+
// Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
|
|
120
|
+
permission: { modes: {}, grants: { ...hubGrants } },
|
|
121
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
122
|
+
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
123
|
+
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
124
|
+
cli: { command: "local", startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
128
|
+
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
129
|
+
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("jcode", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
130
|
+
identity: { mcp: "project-file" },
|
|
131
|
+
permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
|
|
132
|
+
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
133
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
134
|
+
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
135
|
+
cli: { command: "jcode", authority: "person", startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
136
|
+
},
|
|
137
|
+
] satisfies AdapterDefinition[]);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
|
|
141
|
+
* Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
|
|
142
|
+
*/
|
|
143
|
+
const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
|
|
47
144
|
export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
48
145
|
{
|
|
49
146
|
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
@@ -51,7 +148,6 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
|
|
|
51
148
|
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true,
|
|
52
149
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
53
150
|
identity: { mcp: "channel-plugin" },
|
|
54
|
-
// ponytail: A1 compatibility, existing daemon permission hooks remain authority until A2; absence is not unmanaged or a refusal.
|
|
55
151
|
permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
|
|
56
152
|
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
57
153
|
"never-ask": "every tool runs without asking" } },
|
|
@@ -90,7 +186,6 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
|
|
|
90
186
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
91
187
|
start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", hubSwitches: [], ownedFlags: [] },
|
|
92
188
|
identity: { mcp: "env" },
|
|
93
|
-
// ponytail: A1 compatibility, existing daemon permission hooks remain authority until A2; absence is not unmanaged or a refusal.
|
|
94
189
|
permission: { modes: {}, grants: { ...hubGrants } },
|
|
95
190
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
|
|
96
191
|
turnEnd: "adapter", budget: { account: "pi" },
|
|
@@ -101,7 +196,6 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
|
|
|
101
196
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
102
197
|
start: { forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
103
198
|
identity: { mcp: "env" },
|
|
104
|
-
// ponytail: A1 compatibility, existing daemon permission hooks remain authority until A2; absence is not unmanaged or a refusal.
|
|
105
199
|
permission: { modes: {}, grants: { ...hubGrants } },
|
|
106
200
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local" },
|
|
107
201
|
turnEnd: "adapter",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync, readFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import { CLAUDE_CHANNEL, sessionSettings, UNATTENDED_WARNING, type Launch } from "../../cli/launch.ts";
|
|
5
|
+
import type { LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
6
|
+
import { nativeLaunchArguments, nativeLaunchPlan } from "./native-cli-launch-common.ts";
|
|
7
|
+
|
|
8
|
+
export const CLAUDE_LAUNCH_POLICY = Object.freeze({ hubSwitches: Object.freeze(["--unattended", "--safe", "--new"]), ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]), unattended: true, passthrough: true });
|
|
9
|
+
|
|
10
|
+
function claudeChannel(userArgs: string[]): string {
|
|
11
|
+
const bundle = resolve(import.meta.dir, "../../../plugins/agent-hub/server.js");
|
|
12
|
+
for (let i = 0; i < userArgs.length; i++) {
|
|
13
|
+
const arg = userArgs[i]!;
|
|
14
|
+
const path = arg === "--mcp-config" ? userArgs[i + 1] : arg.startsWith("--mcp-config=") ? arg.slice("--mcp-config=".length) : undefined;
|
|
15
|
+
if (!path) continue;
|
|
16
|
+
try {
|
|
17
|
+
const server = JSON.parse(readFileSync(resolve(path), "utf8")).mcpServers?.["agent-hub"];
|
|
18
|
+
if (server?.command === "bun" && Array.isArray(server.args) && server.args.some((value: unknown) => typeof value === "string" && resolve(value) === bundle)) return "server:agent-hub";
|
|
19
|
+
} catch { /* The native CLI reports invalid user-supplied MCP configuration. */ }
|
|
20
|
+
}
|
|
21
|
+
return CLAUDE_CHANNEL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function claudeNativeLaunch(ctx: LaunchContext): LaunchPlan {
|
|
25
|
+
const { passthrough, unattended } = nativeLaunchArguments("claude", ctx, CLAUDE_LAUNCH_POLICY);
|
|
26
|
+
const build = (): Launch => {
|
|
27
|
+
// `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
|
|
28
|
+
const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
|
|
29
|
+
let settingsArgs = ctx.statusLine && !own ? ["--settings", sessionSettings(ctx.statusLine, ctx.facts)] : [];
|
|
30
|
+
let nativeArgs = passthrough;
|
|
31
|
+
let settingsFile: string | undefined;
|
|
32
|
+
if (own) {
|
|
33
|
+
if (passthrough.filter(a => a === "--settings" || a.startsWith("--settings=")).length > 1) throw new Error("pass --settings only once so the hub permission hook stays installed");
|
|
34
|
+
const index = passthrough.findIndex(a => a === "--settings" || a.startsWith("--settings="));
|
|
35
|
+
const arg = passthrough[index]!;
|
|
36
|
+
const value = arg === "--settings" ? passthrough[index + 1] : arg.slice("--settings=".length);
|
|
37
|
+
if (!value) throw new Error("--settings requires a JSON object or readable file so the hub permission hook can be installed");
|
|
38
|
+
let caller: Record<string, unknown>;
|
|
39
|
+
try {
|
|
40
|
+
caller = JSON.parse(value.trim().startsWith("{") ? value : readFileSync(resolve(value), "utf8"));
|
|
41
|
+
if (!caller || typeof caller !== "object" || Array.isArray(caller)) throw new Error("not an object");
|
|
42
|
+
} catch { throw new Error("cannot read --settings as a JSON object; the hub permission hook must be installed"); }
|
|
43
|
+
if (ctx.facts && caller.disableAllHooks === true) throw new Error("--settings disableAllHooks prevents the required hub permission hook; enable hooks to launch");
|
|
44
|
+
const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
|
|
45
|
+
const callerHooks = caller.hooks;
|
|
46
|
+
if (callerHooks !== undefined && (!callerHooks || typeof callerHooks !== "object" || Array.isArray(callerHooks))) throw new Error("--settings hooks must be an object");
|
|
47
|
+
const hooks = { ...(callerHooks as Record<string, unknown> | undefined) };
|
|
48
|
+
for (const [event, entries] of Object.entries(injected.hooks ?? {})) {
|
|
49
|
+
const existing = hooks[event];
|
|
50
|
+
if (existing !== undefined && !Array.isArray(existing)) throw new Error(`--settings ${event} hooks must be an array`);
|
|
51
|
+
hooks[event] = [...(existing as unknown[] | undefined ?? []), ...(entries as unknown[])];
|
|
52
|
+
}
|
|
53
|
+
settingsArgs = ["--settings", JSON.stringify({ ...caller, ...(ctx.facts ? { hooks } : {}) })];
|
|
54
|
+
nativeArgs = passthrough.filter((_, i) => i !== index && (arg !== "--settings" || i !== index + 1));
|
|
55
|
+
} else if (ctx.facts && !ctx.statusLine) {
|
|
56
|
+
const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
|
|
57
|
+
settingsArgs = ["--settings", JSON.stringify({ hooks: injected.hooks })];
|
|
58
|
+
}
|
|
59
|
+
if (settingsArgs.length && !ctx.preview) {
|
|
60
|
+
const stateDir = ctx.facts?.stateDir ?? ctx.statusLine?.stateDir;
|
|
61
|
+
if (!stateDir) throw new Error("Claude session settings require the hub state directory");
|
|
62
|
+
const file = join(stateDir, `claude-settings-${randomUUID()}.json`);
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
65
|
+
writeFileSync(`${file}.tmp`, settingsArgs[1]!, { mode: 0o600, flag: "wx" });
|
|
66
|
+
renameSync(`${file}.tmp`, file);
|
|
67
|
+
} catch {
|
|
68
|
+
try { unlinkSync(`${file}.tmp`); } catch { /* no temp file was written */ }
|
|
69
|
+
throw new Error("cannot write private Claude session settings in the hub state directory");
|
|
70
|
+
}
|
|
71
|
+
settingsArgs[1] = file;
|
|
72
|
+
settingsFile = file;
|
|
73
|
+
}
|
|
74
|
+
const notes = [
|
|
75
|
+
unattended ? UNATTENDED_WARNING : "",
|
|
76
|
+
ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
return {
|
|
79
|
+
cmd: "claude",
|
|
80
|
+
args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...settingsArgs, ...nativeArgs],
|
|
81
|
+
...(settingsFile ? { settingsFile } : {}),
|
|
82
|
+
permissionHook: !!ctx.facts,
|
|
83
|
+
...(ctx.facts ? { hookPurpose: ctx.facts.purpose ?? "facts" } : {}),
|
|
84
|
+
unattended,
|
|
85
|
+
...(notes.length ? { warning: notes.join("\n") } : {}),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
};
|
|
89
|
+
return nativeLaunchPlan(build(), ctx);
|
|
90
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { realPath } from "../../hub/project.ts";
|
|
4
|
+
import { VERSION } from "../../version.ts";
|
|
5
|
+
|
|
6
|
+
export const PLUGIN = "agent-hub@agent-hub";
|
|
7
|
+
const MARKETPLACE = "agent-hub";
|
|
8
|
+
|
|
9
|
+
export interface InstalledPlugin {
|
|
10
|
+
id: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
installPath?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Marketplace {
|
|
15
|
+
name: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `claude plugin list --json` / `claude plugin marketplace list --json`. Anything that does not parse is an empty list. */
|
|
20
|
+
export function parseList<T>(json: string): T[] {
|
|
21
|
+
try {
|
|
22
|
+
const d = JSON.parse(json);
|
|
23
|
+
return Array.isArray(d) ? d : [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const real = (p: string) => {
|
|
30
|
+
try {
|
|
31
|
+
return realPath(p);
|
|
32
|
+
} catch {
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const read = (p: string) => {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(p, "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type PluginState = { state: "missing" } | { state: "current"; version: string } | { state: "stale"; version: string; why: string };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Is the installed Claude plugin the one this hub ships? The version is compared, and so is the bundle itself: Claude
|
|
48
|
+
* Code runs a cached copy, and a copy taken from an earlier build of the same version speaks an older wire protocol.
|
|
49
|
+
*/
|
|
50
|
+
export function pluginState(plugins: InstalledPlugin[], packageRoot: string): PluginState {
|
|
51
|
+
const mine = plugins.find((p) => p.id === PLUGIN);
|
|
52
|
+
if (!mine) return { state: "missing" };
|
|
53
|
+
const version = mine.version ?? "unknown";
|
|
54
|
+
if (version !== VERSION) return { state: "stale", version, why: `version ${version}, this hub is ${VERSION}` };
|
|
55
|
+
const shipped = read(join(packageRoot, "plugins", "agent-hub", "server.js"));
|
|
56
|
+
const cached = mine.installPath ? read(join(mine.installPath, "server.js")) : undefined;
|
|
57
|
+
if (shipped !== undefined && cached !== undefined && shipped !== cached) return { state: "stale", version, why: "same version, but the cached bundle differs from the one this hub ships" };
|
|
58
|
+
return { state: "current", version };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SetupStep {
|
|
62
|
+
why: string;
|
|
63
|
+
argv: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The next thing `ahub setup` has to do, or nothing. One step at a time: the state is read again after every step,
|
|
68
|
+
* because what a step leaves behind is Claude Code's business (removing a marketplace may uninstall its plugins).
|
|
69
|
+
*/
|
|
70
|
+
export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
|
|
71
|
+
const root = real(packageRoot);
|
|
72
|
+
const market = marketplaces.find((m) => m.name === MARKETPLACE);
|
|
73
|
+
if (market && real(market.path ?? "") !== root) return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
|
|
74
|
+
if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
|
|
75
|
+
const state = pluginState(plugins, root);
|
|
76
|
+
if (state.state === "missing") return { why: "install the channel plugin", argv: ["claude", "plugin", "install", PLUGIN] };
|
|
77
|
+
// Uninstall rather than update: with an unchanged version number an update has nothing to do.
|
|
78
|
+
if (state.state === "stale") return { why: `replace the installed plugin (${state.why})`, argv: ["claude", "plugin", "uninstall", PLUGIN] };
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
2
|
+
import { nativeLaunchArguments, nativeLaunchPlan } from "./native-cli-launch-common.ts";
|
|
3
|
+
|
|
4
|
+
export const CODEX_LAUNCH_POLICY = Object.freeze({ hubSwitches: Object.freeze(["--unattended", "--safe", "--new"]), ownedFlags: Object.freeze(["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]), unattended: true, passthrough: true });
|
|
5
|
+
export function codexNativeLaunch(ctx: LaunchContext): LaunchPlan {
|
|
6
|
+
const { passthrough, unattended, warning } = nativeLaunchArguments("codex", ctx, CODEX_LAUNCH_POLICY);
|
|
7
|
+
if (!ctx.proxyUrl) throw new Error("codex proxy url is missing");
|
|
8
|
+
return nativeLaunchPlan({
|
|
9
|
+
cmd: ctx.codexBin ?? "codex",
|
|
10
|
+
args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
|
|
11
|
+
unattended,
|
|
12
|
+
...warning,
|
|
13
|
+
}, ctx);
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DoctorCheck } from "../../cli/output.ts";
|
|
2
|
+
|
|
3
|
+
// Fixed builtin doctor policy only. These projections neither execute probes nor extend AdapterDefinition.
|
|
4
|
+
const nativeTools = [
|
|
5
|
+
{ id: "claude", initial: true }, { id: "codex", initial: true }, { id: "kimi", initial: true },
|
|
6
|
+
{ id: "qwen", initial: false }, { id: "jcode", initial: false },
|
|
7
|
+
] as const;
|
|
8
|
+
export const DOCTOR_TOOL_PROBES: readonly string[] = Object.freeze(["bun", ...nativeTools.filter(tool => tool.initial).map(tool => tool.id)]);
|
|
9
|
+
export const DOCTOR_MEMORY_CAPTURE_PEERS = Object.freeze(["codex", "kimi"] as const);
|
|
10
|
+
export const DOCTOR_PLUGIN_PROBE = Object.freeze({ command: "claude", argv: Object.freeze(["plugin", "list", "--json"] as const), name: "claude plugin" });
|
|
11
|
+
|
|
12
|
+
/** Exact legacy section and severity classification, including non-native and unknown diagnostic names. */
|
|
13
|
+
export function nativeDoctorCheck(ok: boolean | undefined, name: string, detail: string): DoctorCheck {
|
|
14
|
+
const nativeTool = nativeTools.some(tool => tool.id === name);
|
|
15
|
+
const section: DoctorCheck["section"] = name === "bun" || nativeTool ? "Tools"
|
|
16
|
+
: name === "config" || name === "retired setting" ? "Config"
|
|
17
|
+
: name.startsWith("memory") ? "Memory"
|
|
18
|
+
: ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
|
|
19
|
+
const optional = nativeTool || ["ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
|
|
20
|
+
const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
|
|
21
|
+
return { section, level, name, detail };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Recovery IDs are checked only for the two legacy native conversation surfaces. */
|
|
25
|
+
export function nativeDoctorRecoveryIdentity(peer: { id: string; state: string; sessionId?: string; threadId?: string }): { ok: boolean; name: string; detail: string } | undefined {
|
|
26
|
+
const fields = { claude: "sessionId", codex: "threadId" } as const;
|
|
27
|
+
const field = Object.hasOwn(fields, peer.id) ? fields[peer.id as keyof typeof fields] : undefined;
|
|
28
|
+
if (!field || peer.state === "offline") return undefined;
|
|
29
|
+
const identity = peer[field];
|
|
30
|
+
return { ok: !!identity, name: `${peer.id} recovery ID`, detail: identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type NativeDoctorOptionalProbe =
|
|
34
|
+
| { kind: "source-identity"; name: string; command: string[]; detail: string }
|
|
35
|
+
| { kind: "pinned-version"; name: string; command: string[]; expected: string; confirmed: string; unconfirmed: string };
|
|
36
|
+
|
|
37
|
+
/** The host runs these existing checks in order; no process, filesystem or caller callback enters this planner. */
|
|
38
|
+
export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[] }): NativeDoctorOptionalProbe[] {
|
|
39
|
+
const probes: NativeDoctorOptionalProbe[] = [];
|
|
40
|
+
if (config.roster.jcode === true) probes.push({ kind: "source-identity", name: "jcode", command: config.jcode_cmd,
|
|
41
|
+
detail: "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged" });
|
|
42
|
+
if (config.roster.qwen === true) probes.push({ kind: "pinned-version", name: "qwen", command: config.qwen_cmd, expected: "0.25.0",
|
|
43
|
+
confirmed: "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check",
|
|
44
|
+
unconfirmed: "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown" });
|
|
45
|
+
return probes;
|
|
46
|
+
}
|