@staix/agent-hub 0.12.39 → 0.12.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +15 -10
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -6
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +152 -28
- package/docs/quickstart.md +19 -5
- package/docs/security.md +19 -7
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verified.json +38 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +413 -147
- package/src/adapters/acp.ts +34 -1
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +2 -0
- package/src/adapters/definitions/native-cli-doctor.ts +8 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
- package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/jcode.ts +110 -4
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/cli/console-state.ts +1 -0
- package/src/cli/console.ts +11 -1
- package/src/cli/help.ts +13 -8
- package/src/cli/identity-audit.ts +33 -7
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +134 -24
- package/src/cli/output.ts +5 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +54 -4
- package/src/cli/upgrade-interactive.ts +5 -0
- package/src/cli/upgrade-runtime.ts +13 -1
- package/src/cli/upgrade.ts +67 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +8 -3
- package/src/hub/checks.ts +15 -5
- package/src/hub/conductor.ts +2 -1
- package/src/hub/config-apply.ts +20 -2
- package/src/hub/config-reload.ts +2 -8
- package/src/hub/control-client.ts +26 -3
- package/src/hub/daemon.ts +155 -68
- package/src/hub/delivery-superseded.ts +19 -2
- package/src/hub/events.ts +4 -1
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +4 -8
- package/src/hub/lifecycle.ts +3 -3
- package/src/hub/main-checkout.ts +36 -0
- package/src/hub/memory-profile-check.ts +6 -2
- package/src/hub/memory-profile.ts +18 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/task-repository.ts +136 -0
- package/src/hub/tasks.ts +101 -21
- package/src/hub/terminal-launch.ts +130 -0
- package/src/hub/usage.ts +44 -18
- package/src/models/route/config.ts +1 -1
- package/src/omniroute/client.ts +7 -19
- package/templates/AGENTS.block.md +1 -0
- package/templates/routing.toml +10 -44
- package/src/switchyard/config.ts +0 -45
- package/src/switchyard/sidecar.ts +0 -136
package/src/adapters/acp.ts
CHANGED
|
@@ -159,6 +159,9 @@ export class AcpPeer extends BasePeer {
|
|
|
159
159
|
private activeDeliveryId: string | undefined;
|
|
160
160
|
private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
|
|
161
161
|
private deliveryAccepted = false;
|
|
162
|
+
/** What the running turn answers: its delivery plus whatever the transport took into it mid-turn (#572). */
|
|
163
|
+
private turnEnvs: Envelope[] = [];
|
|
164
|
+
private steerIds: string[] = [];
|
|
162
165
|
private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout>; reported: boolean; released: Promise<void>; release: () => void } | undefined;
|
|
163
166
|
private promptActivity = false;
|
|
164
167
|
private activityOpen = false;
|
|
@@ -324,6 +327,7 @@ export class AcpPeer extends BasePeer {
|
|
|
324
327
|
if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
|
|
325
328
|
if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
|
|
326
329
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
|
|
330
|
+
this.settleSteers("needs_review", reason ?? "ACP session stopped before settlement");
|
|
327
331
|
this.activeDeliveryId = undefined;
|
|
328
332
|
const proc = this.proc;
|
|
329
333
|
if (!proc) { this.setState("offline"); return; }
|
|
@@ -341,6 +345,7 @@ export class AcpPeer extends BasePeer {
|
|
|
341
345
|
const turn = ++this.turn;
|
|
342
346
|
this.activeDeliveryId = deliveryId;
|
|
343
347
|
this.activeLegacy = deliveryId ? undefined : { turn, envs };
|
|
348
|
+
this.turnEnvs = envs; this.steerIds = [];
|
|
344
349
|
this.deliveryAccepted = false;
|
|
345
350
|
this.promptActivity = false;
|
|
346
351
|
this.chunks = [];
|
|
@@ -366,13 +371,16 @@ export class AcpPeer extends BasePeer {
|
|
|
366
371
|
this.observeUsage(result, "prompt_result");
|
|
367
372
|
this.primed = true;
|
|
368
373
|
const body = this.chunks.join("").trim();
|
|
369
|
-
|
|
374
|
+
// What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
|
|
375
|
+
const answering = this.turnEnvs.length ? this.turnEnvs : envs;
|
|
376
|
+
if (body) this.onMessage?.(body, { inReplyTo: replyParent(answering), to: replyAudience(answering) });
|
|
370
377
|
// Partial output cannot certify normal completion (#160).
|
|
371
378
|
if (result?.stopReason !== "end_turn") this.reportTurnFailure(envs, `ACP prompt ended without normal completion (${String(result?.stopReason ?? "unknown")})`);
|
|
372
379
|
if (deliveryId && this.activeDeliveryId === deliveryId) {
|
|
373
380
|
this.acceptDelivery();
|
|
374
381
|
this.delivery({ id: deliveryId, state: result?.stopReason === "end_turn" ? "completed" : "needs_review", ...(result?.stopReason === "end_turn" ? {} : { reason: "ACP prompt ended without normal completion" }) });
|
|
375
382
|
}
|
|
383
|
+
this.settleSteers(result?.stopReason === "end_turn" ? "completed" : "needs_review", "ACP prompt ended without normal completion");
|
|
376
384
|
})
|
|
377
385
|
.catch((e: Error) => {
|
|
378
386
|
if (turn !== this.turn) {
|
|
@@ -381,6 +389,7 @@ export class AcpPeer extends BasePeer {
|
|
|
381
389
|
}
|
|
382
390
|
this.endActivity("failed", true);
|
|
383
391
|
const reason = sanitize(e.message);
|
|
392
|
+
this.settleSteers("needs_review", reason);
|
|
384
393
|
this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
|
|
385
394
|
const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
|
|
386
395
|
if (quota) {
|
|
@@ -437,6 +446,7 @@ export class AcpPeer extends BasePeer {
|
|
|
437
446
|
this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
|
|
438
447
|
const durable = !!this.activeDeliveryId;
|
|
439
448
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
|
|
449
|
+
this.settleSteers("needs_review", "ACP turn watchdog timeout");
|
|
440
450
|
this.activeDeliveryId = undefined;
|
|
441
451
|
this.notify("session/cancel", { sessionId: this.sessionId });
|
|
442
452
|
this.turn++; // whatever the cancelled prompt still reports is stale
|
|
@@ -474,6 +484,28 @@ export class AcpPeer extends BasePeer {
|
|
|
474
484
|
if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
|
|
475
485
|
}
|
|
476
486
|
|
|
487
|
+
/** The running turn's generation, for a transport that must adopt into exactly this turn after an async step (#572). */
|
|
488
|
+
protected currentTurn(): number { return this.turn; }
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A transport that took envelopes into the running turn (#572) reports them here, once it has acknowledged them. The
|
|
492
|
+
* turn now answers their senders too, and their receipts settle with the turn: completed on a normal end, held for
|
|
493
|
+
* review on anything else. Only the busy turn the caller named (`currentTurn()` before sending) can adopt.
|
|
494
|
+
*/
|
|
495
|
+
protected adoptIntoTurn(turn: number, envs: Envelope[], deliveryId?: string): void {
|
|
496
|
+
// Fenced to the exact turn the caller saw before it sent anything: a later turn on the same session is never adopted into.
|
|
497
|
+
if (turn !== this.turn || this.state !== "busy" || this.cancelWait || this.stopping) throw new Error(`${this.id} is ${this.state}, or its turn changed`);
|
|
498
|
+
this.turnEnvs = [...this.turnEnvs, ...envs];
|
|
499
|
+
this.activityPrivate ||= envs.some(env => env.private === true);
|
|
500
|
+
this.primed = true;
|
|
501
|
+
if (deliveryId) { this.steerIds.push(deliveryId); this.delivery({ id: deliveryId, state: "accepted" }); }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private settleSteers(state: "completed" | "needs_review", reason?: string): void {
|
|
505
|
+
const ids = this.steerIds; this.steerIds = [];
|
|
506
|
+
for (const id of ids) this.delivery({ id, state, ...(state === "needs_review" ? { reason: reason ?? "steered turn did not complete normally" } : {}) });
|
|
507
|
+
}
|
|
508
|
+
|
|
477
509
|
private acceptDelivery(): void {
|
|
478
510
|
if (!this.activeDeliveryId || this.deliveryAccepted) return;
|
|
479
511
|
this.deliveryAccepted = true;
|
|
@@ -486,6 +518,7 @@ export class AcpPeer extends BasePeer {
|
|
|
486
518
|
for (const p of this.pending.values()) p.reject(new Error(reason));
|
|
487
519
|
this.pending.clear();
|
|
488
520
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason });
|
|
521
|
+
this.settleSteers("needs_review", reason);
|
|
489
522
|
this.activeDeliveryId = undefined;
|
|
490
523
|
this.setState("offline");
|
|
491
524
|
}
|
|
@@ -86,6 +86,7 @@ const TERMINAL_CLOSES: Record<number, string> = {
|
|
|
86
86
|
4404: "the hub belongs to a different project or instance; restart this session from the intended project",
|
|
87
87
|
4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
|
|
88
88
|
4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
|
|
89
|
+
4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
|
|
89
90
|
4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
|
|
90
91
|
};
|
|
91
92
|
/** Another session attached under this peer id. Unlike the other closes this one ends on its own, so the
|
|
@@ -97,7 +98,7 @@ const INBOX_CAP = 200;
|
|
|
97
98
|
/**
|
|
98
99
|
* Whether a live session holds this peer id, per the daemon's own status file. The hub gives the id to
|
|
99
100
|
* whoever said hello last, so reconnecting blind would evict the session that just took it and the two
|
|
100
|
-
* would trade the peer forever.
|
|
101
|
+
* would trade the peer forever. This file is advisory: a standby hello also checks live ownership at the daemon.
|
|
101
102
|
*/
|
|
102
103
|
function peerHeld(): boolean {
|
|
103
104
|
try {
|
|
@@ -144,7 +145,7 @@ const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });
|
|
|
144
145
|
|
|
145
146
|
const server = new Server(
|
|
146
147
|
{ name: "agent-hub", version: VERSION },
|
|
147
|
-
toolsMode ? { capabilities: { tools: {} }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: channel ? { experimental: { "claude/channel": {} }, tools: {} } : { tools: {} }, instructions: INSTRUCTIONS },
|
|
148
|
+
toolsMode ? { capabilities: { tools: { listChanged: true } }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: channel ? { experimental: { "claude/channel": {} }, tools: { listChanged: true } } : { tools: { listChanged: true } }, instructions: INSTRUCTIONS },
|
|
148
149
|
);
|
|
149
150
|
|
|
150
151
|
const inbox: string[] = []; // pushes that failed; drained by hub_inbox
|
|
@@ -243,6 +244,8 @@ async function connectLoop(): Promise<void> {
|
|
|
243
244
|
let closeReason: string | undefined;
|
|
244
245
|
try {
|
|
245
246
|
const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
|
|
247
|
+
...(!toolsMode && process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
|
|
248
|
+
...(!toolsMode && standingBy ? { standby: true } : {}),
|
|
246
249
|
...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
|
|
247
250
|
client.onPush = (msg) => {
|
|
248
251
|
if (msg.t === "queued") {
|
|
@@ -267,6 +270,9 @@ async function connectLoop(): Promise<void> {
|
|
|
267
270
|
standingBy = false;
|
|
268
271
|
detached = undefined;
|
|
269
272
|
log(`connected to hub as "${peerId}"`);
|
|
273
|
+
// The tool list depends on the connected hub (its lifecycle): when it differs from what the host last listed,
|
|
274
|
+
// ask the host to list it again (#490).
|
|
275
|
+
if (listedTools !== undefined) void reannounce(client).catch(() => {});
|
|
270
276
|
code = await new Promise<number>((r) => { client.onClose = (value, reason) => { closeReason = reason; r(value); }; });
|
|
271
277
|
hub = undefined;
|
|
272
278
|
log("hub connection lost");
|
|
@@ -290,8 +296,27 @@ async function connectLoop(): Promise<void> {
|
|
|
290
296
|
}
|
|
291
297
|
}
|
|
292
298
|
|
|
293
|
-
|
|
294
|
-
|
|
299
|
+
/** The tool definitions the host was last given; a reconnect that changes any of them asks the host to list again (#490).
|
|
300
|
+
* Whole definitions, not names: a lifecycle change alters schemas (stage enums, required fields) under the same names. */
|
|
301
|
+
let listedTools: string | undefined;
|
|
302
|
+
const toolKey = (list: { tools: unknown[] }) => JSON.stringify(list.tools);
|
|
303
|
+
|
|
304
|
+
/** After a reconnect, re-announce the tools when they differ from what the host listed. The status probe can time out
|
|
305
|
+
* right after a restart; retry it, and when it stays unavailable announce anyway: a needless re-list costs less than a
|
|
306
|
+
* session that keeps a short list until the next disconnect (#490). */
|
|
307
|
+
async function reannounce(client: ControlClient) {
|
|
308
|
+
for (let tries = 0; ; tries++) {
|
|
309
|
+
if (hub !== client) return;
|
|
310
|
+
const lifecycle = await activeLifecycle(stateDir, peerId, projectRoot, client);
|
|
311
|
+
if (lifecycle === undefined && tries < 2) { await new Promise(r => setTimeout(r, 1000)); continue; }
|
|
312
|
+
if (lifecycle === undefined || toolKey(toolListFor(lifecycle)) !== listedTools) await server.sendToolListChanged();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function buildToolList() { return toolListFor(await activeLifecycle(stateDir, peerId, projectRoot, hub)); }
|
|
318
|
+
|
|
319
|
+
function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
|
|
295
320
|
return {
|
|
296
321
|
tools: [
|
|
297
322
|
{
|
|
@@ -333,6 +358,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
333
358
|
...CONDUCTOR_TOOLS,
|
|
334
359
|
],
|
|
335
360
|
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
364
|
+
const list = await buildToolList();
|
|
365
|
+
listedTools = toolKey(list);
|
|
366
|
+
return list;
|
|
336
367
|
});
|
|
337
368
|
|
|
338
369
|
async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
|
|
@@ -49,6 +49,7 @@ export interface CodexOptions {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
interface Link {
|
|
52
|
+
launchId?: string;
|
|
52
53
|
tui: ServerWebSocket<Link>;
|
|
53
54
|
up: WebSocket;
|
|
54
55
|
backlog: string[]; // TUI frames that arrived before the upstream socket opened
|
|
@@ -128,6 +129,13 @@ export class CodexPeer extends BasePeer {
|
|
|
128
129
|
return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
terminalOwner(launchId: string): { generation: string } | undefined {
|
|
133
|
+
const link = this.link;
|
|
134
|
+
return this.threadId && link && link === this.claimedTui && link.launchId === launchId &&
|
|
135
|
+
link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN
|
|
136
|
+
? { generation: String(this.sessionGeneration) } : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
131
139
|
async start(): Promise<void> {
|
|
132
140
|
const upstream = this.opts.upstreamUrl ?? (await this.spawnAppServer());
|
|
133
141
|
try {
|
|
@@ -137,7 +145,8 @@ export class CodexPeer extends BasePeer {
|
|
|
137
145
|
fetch: (req, server) => {
|
|
138
146
|
// Browsers always send Origin on WebSocket upgrades; the TUI never does.
|
|
139
147
|
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
140
|
-
|
|
148
|
+
const launchId = new URL(req.url).searchParams.get("launchId");
|
|
149
|
+
if (server.upgrade(req, { data: { ...(launchId && launchId.length <= 128 ? { launchId } : {}) } as Link })) return undefined;
|
|
141
150
|
return new Response("agent-hub codex proxy");
|
|
142
151
|
},
|
|
143
152
|
websocket: {
|
|
@@ -418,7 +427,7 @@ export class CodexPeer extends BasePeer {
|
|
|
418
427
|
|
|
419
428
|
private attach(tui: ServerWebSocket<Link>, upstream: string): void {
|
|
420
429
|
const up = new WebSocket(upstream);
|
|
421
|
-
const link: Link = { tui, up, backlog: [], tracked: new Map() };
|
|
430
|
+
const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
|
|
422
431
|
this.claimedTui = link;
|
|
423
432
|
tui.data = link;
|
|
424
433
|
up.onopen = () => {
|
|
@@ -556,7 +565,7 @@ export class CodexPeer extends BasePeer {
|
|
|
556
565
|
}
|
|
557
566
|
|
|
558
567
|
private adopt(link: Link, threadId: unknown, fresh = false): void {
|
|
559
|
-
if (typeof threadId !== "string" || !threadId) return;
|
|
568
|
+
if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
|
|
560
569
|
this.abandonSteers("thread changed");
|
|
561
570
|
for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
|
|
562
571
|
pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
|
|
@@ -158,6 +158,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
158
158
|
/**
|
|
159
159
|
* ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
|
|
160
160
|
* Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
|
|
161
|
+
* The frozen jcode entry stays byte-identical: its headless `launch` emits `jcode` without `--mode`, which the CLI now
|
|
162
|
+
* resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
|
|
161
163
|
*/
|
|
162
164
|
const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
|
|
163
165
|
export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
@@ -13,11 +13,11 @@ export const DOCTOR_PLUGIN_PROBE = Object.freeze({ command: "claude", argv: Obje
|
|
|
13
13
|
export function nativeDoctorCheck(ok: boolean | undefined, name: string, detail: string): DoctorCheck {
|
|
14
14
|
const nativeTool = nativeTools.some(tool => tool.id === name);
|
|
15
15
|
const section: DoctorCheck["section"] = name === "bun" || nativeTool ? "Tools"
|
|
16
|
-
: name === "config" || name === "retired setting" ? "Config"
|
|
16
|
+
: name === "config" || name === "retired setting" || name === "routing" ? "Config"
|
|
17
17
|
: name.startsWith("memory") ? "Memory"
|
|
18
|
-
: ["omniroute", "omniroute key", "
|
|
19
|
-
const optional = nativeTool || ["ahub daemon", "
|
|
20
|
-
const level: DoctorCheck["level"] = ok === undefined ? (
|
|
18
|
+
: ["omniroute", "omniroute key", "routing model", "routing models", "pi mlx"].includes(name) ? "Models" : "Hub";
|
|
19
|
+
const optional = nativeTool || ["ahub daemon", "pi mlx"].includes(name) || section === "Memory";
|
|
20
|
+
const level: DoctorCheck["level"] = ok === undefined ? (name === "omniroute" ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
|
|
21
21
|
return { section, level, name, detail };
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -32,13 +32,16 @@ export function nativeDoctorRecoveryIdentity(peer: { id: string; state: string;
|
|
|
32
32
|
|
|
33
33
|
export type NativeDoctorOptionalProbe =
|
|
34
34
|
| { kind: "source-identity"; name: string; command: string[]; detail: string }
|
|
35
|
+
| { kind: "jcode-mcp"; name: string; home: string }
|
|
35
36
|
| { kind: "pinned-version"; name: string; command: string[]; expected: string; confirmed: string; unconfirmed: string };
|
|
36
37
|
|
|
37
38
|
/** 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
|
+
export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[]; jcode_home?: string }): NativeDoctorOptionalProbe[] {
|
|
39
40
|
const probes: NativeDoctorOptionalProbe[] = [];
|
|
40
41
|
if (config.roster.jcode === true) probes.push({ kind: "source-identity", name: "jcode", command: config.jcode_cmd,
|
|
41
42
|
detail: "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged" });
|
|
43
|
+
// #572 AC4: jcode cannot be told which MCP servers to load, so the host lists the ones its config files name.
|
|
44
|
+
if (config.roster.jcode === true && config.jcode_home) probes.push({ kind: "jcode-mcp", name: "jcode MCP servers", home: config.jcode_home });
|
|
42
45
|
if (config.roster.qwen === true) probes.push({ kind: "pinned-version", name: "qwen", command: config.qwen_cmd, expected: "0.25.0",
|
|
43
46
|
confirmed: "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check",
|
|
44
47
|
unconfirmed: "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown" });
|
|
@@ -22,7 +22,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
22
22
|
} finally { hub.close(); }
|
|
23
23
|
return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
|
|
24
24
|
}
|
|
25
|
-
const { launcherPreview, readControl,
|
|
25
|
+
const { launcherPreview, readControl, prepareTerminalLaunch, registerTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
|
|
26
26
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
27
27
|
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
28
28
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
@@ -30,8 +30,13 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
30
30
|
const control = readControl(stateDir);
|
|
31
31
|
if (control?.instanceId) {
|
|
32
32
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
33
|
-
const terminal = await
|
|
33
|
+
const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
|
|
34
34
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
35
|
+
else {
|
|
36
|
+
const hub = await ctx.connect();
|
|
37
|
+
try { await registerTerminalLaunch(hub, terminal, process.env.AGENTHUB_RECOVERY_OPERATION); }
|
|
38
|
+
finally { hub.close(); }
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
37
42
|
// project local, then project, then user.
|
|
@@ -4,35 +4,34 @@ import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
|
4
4
|
export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
5
5
|
const peer = ctx.instance ?? "codex";
|
|
6
6
|
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
|
|
7
|
-
const { launcherPreview, buildLaunch,
|
|
7
|
+
const { launcherPreview, buildLaunch, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
|
|
8
8
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
9
9
|
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
10
10
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
11
11
|
}
|
|
12
12
|
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
13
|
-
// #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
|
|
14
|
-
// a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
|
|
15
|
-
// refused start must not replace the record of a Codex already running here (one row per peer and instance).
|
|
16
|
-
const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
|
|
17
|
-
const before = readControl(stateDir);
|
|
18
|
-
// ponytail: recovering extras have no authenticated execution-root binding before start replies, so their row
|
|
19
|
-
// is recorded afterward. The coordinator retains its pending creation/wait evidence; add pre-start recording
|
|
20
|
-
// only when a future recovery receipt supplies independently verified exact-instance execution-root evidence.
|
|
21
|
-
if (recovering && !ctx.instance && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
22
13
|
const hub = await connect();
|
|
23
14
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
24
15
|
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
25
16
|
const res = await hub.request({ t: "start", peer, args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
26
|
-
hub.close();
|
|
27
|
-
if (!res.ok) fail(res.error);
|
|
17
|
+
if (!res.ok) { hub.close(); fail(res.error); }
|
|
28
18
|
if (ctx.instance) {
|
|
29
19
|
if (typeof res.executionCwd !== "string" || !res.executionCwd.startsWith("/")) fail("instance execution directory was not verified");
|
|
30
20
|
ctx.executionCwd = res.executionCwd;
|
|
31
21
|
}
|
|
32
22
|
const control = readControl(stateDir);
|
|
33
|
-
|
|
23
|
+
let proxyUrl = res.proxyUrl;
|
|
24
|
+
try {
|
|
25
|
+
if (control?.instanceId) {
|
|
26
|
+
const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
|
|
27
|
+
if (terminal) {
|
|
28
|
+
await registerTerminalLaunch(hub, terminal, process.env.AGENTHUB_RECOVERY_OPERATION);
|
|
29
|
+
const url = new URL(proxyUrl); url.searchParams.set("launchId", terminal.launchId); proxyUrl = url.toString();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} finally { hub.close(); }
|
|
34
33
|
const runningCodexBin = res.retainedLaunchConfig?.codex_bin;
|
|
35
|
-
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl
|
|
34
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
|
|
36
35
|
if (launch0.warning) console.error(launch0.warning);
|
|
37
36
|
exec(launch.cmd, launch.args, "codex");
|
|
38
37
|
}
|
|
@@ -7,6 +7,11 @@ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
|
7
7
|
|
|
8
8
|
const WAITING = "jcode's TUI opens after its first hub turn; send it work, or Ctrl-C";
|
|
9
9
|
|
|
10
|
+
/** The daemon names the one session option that differs from the attached saved session (#559 N2, #574). */
|
|
11
|
+
function refuseNamedOption(reply: { ok?: unknown; option?: unknown }, fail: NativeCliEntryContext["fail"]): void {
|
|
12
|
+
if (!reply.ok && typeof reply.option === "string" && ["--session-id", "--model", "--provider-profile"].includes(reply.option)) fail(`Jcode is already running with a different ${reply.option}; run ahub jcode without it, or stop jcode first`);
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryContext["fail"], signal: AbortSignal,
|
|
11
16
|
options: Record<string, string | undefined>, first?: JcodeTuiState): Promise<Extract<JcodeTuiState, { state: "ready" }> | undefined> {
|
|
12
17
|
let binding: JcodeTuiBinding | undefined = first?.binding, announced = false, current = first;
|
|
@@ -14,6 +19,7 @@ export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryCo
|
|
|
14
19
|
if (!current) {
|
|
15
20
|
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
|
|
16
21
|
if (signal.aborted) return;
|
|
22
|
+
refuseNamedOption(reply, fail);
|
|
17
23
|
if (!reply.ok || !reply.binding || !["waiting", "ready"].includes(reply.state)) fail("Jcode launch changed or its native saved session is unavailable; retry from a terminal");
|
|
18
24
|
current = reply as JcodeTuiState;
|
|
19
25
|
}
|
|
@@ -68,6 +74,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
68
74
|
if (print) {
|
|
69
75
|
if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
|
|
70
76
|
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
|
|
77
|
+
refuseNamedOption(reply, fail);
|
|
71
78
|
if (!reply.ok || reply.state !== "ready") fail("Jcode's selected native session is not saved and ready; run ahub jcode to wait for its first hub turn");
|
|
72
79
|
return console.log(JSON.stringify(reply.launch, null, 2));
|
|
73
80
|
}
|
|
@@ -88,12 +95,15 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
88
95
|
if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
|
|
89
96
|
const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
|
|
90
97
|
if (interruptedWait() || !ready) return;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
await ctx.helpers.recordTerminalLaunch("jcode", cwd, stateDir, instanceId);
|
|
98
|
+
// No managed terminal record: Jcode's controlled recovery is refused at plan time, so, like Kimi's TUI, it has
|
|
99
|
+
// no terminal recovery adapter (#564).
|
|
94
100
|
const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
|
|
95
101
|
if (interruptedWait()) return;
|
|
96
102
|
if (!confirmed.ok || confirmed.state !== "ready") fail("Jcode launch changed before its terminal opened");
|
|
103
|
+
// ponytail: jcode 0.98.1's top-level TUI spawns a shared server (no --temporary-server or --owner-pid) when nothing
|
|
104
|
+
// answers on the dialed socket at startup, and on its reload-handoff reconnect. A hub stop or an owned-server crash
|
|
105
|
+
// between the confirm reply above and that check can leave an unowned jcode server on the private socket under the
|
|
106
|
+
// person's JCODE_HOME. No native flag prevents it; revisit if upstream adds an owner flag (#559 N2).
|
|
97
107
|
ctx.execWithEnv(confirmed.launch.cmd, confirmed.launch.args, { ...ctx.helpers.childEnv(), ...confirmed.launch.env });
|
|
98
108
|
} finally { process.removeListener("SIGINT", interrupt); hub.onClose = oldClose; hub.close(); }
|
|
99
109
|
}
|
|
@@ -5,7 +5,7 @@ import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
|
5
5
|
export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
6
6
|
const peer = ctx.instance ?? "pi";
|
|
7
7
|
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, execWithEnv } = ctx;
|
|
8
|
-
const { launcherPreview, startModeOf,
|
|
8
|
+
const { launcherPreview, startModeOf, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
|
|
9
9
|
// --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
|
|
10
10
|
if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
11
11
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
@@ -34,11 +34,6 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
34
34
|
return console.log(reply.already ? "pi is already attached" : reply.opened ? `pi: opened a terminal running ${reply.command}; it attaches when its TUI is ready` : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
35
35
|
} finally { hub.close(); }
|
|
36
36
|
}
|
|
37
|
-
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
38
|
-
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
39
|
-
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
40
|
-
const before = readControl(stateDir);
|
|
41
|
-
if (recovering && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
42
37
|
const hub = await connect();
|
|
43
38
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
44
39
|
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
@@ -52,7 +47,14 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
52
47
|
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
53
48
|
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
54
49
|
const control = readControl(stateDir);
|
|
55
|
-
if (
|
|
50
|
+
if (control?.instanceId) {
|
|
51
|
+
const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId);
|
|
52
|
+
if (terminal) {
|
|
53
|
+
const registration = await connect();
|
|
54
|
+
try { await registerTerminalLaunch(registration, terminal, process.env.AGENTHUB_RECOVERY_OPERATION); }
|
|
55
|
+
finally { registration.close(); }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
56
58
|
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
57
59
|
}
|
|
58
60
|
console.log(res.already ? `${peer} is already attached` : `${peer} attached (headless). Talk to it with: ahub say @${peer} "..."`);
|
|
@@ -19,6 +19,8 @@ export interface NativeCliEntryContext {
|
|
|
19
19
|
launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
|
|
20
20
|
readControl: typeof import("../../hub/control-client.ts").readControl;
|
|
21
21
|
recordTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").recordTerminalLaunch;
|
|
22
|
+
prepareTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").prepareTerminalLaunch;
|
|
23
|
+
registerTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").registerTerminalLaunch;
|
|
22
24
|
recoveryLock: typeof import("../../hub/recovery-store.ts").recoveryLock;
|
|
23
25
|
processSignature: typeof import("../../pi/process-signature.ts").processSignature;
|
|
24
26
|
buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { hubGitSync } from "../../hub/git.ts";
|
|
@@ -56,3 +56,57 @@ export function configureJcodeMcp(cwd: string, stateDir: string, bundle: string,
|
|
|
56
56
|
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
57
57
|
return { changed: true, tracked };
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* #572 AC4: jcode 0.99.0 has no flag or environment variable that chooses which MCP servers its `serve` and `acp`
|
|
62
|
+
* load (`--mcp-tools` and `JCODE_MCP_TOOLS` only change how their tools are exposed, and `JCODE_DISABLE_CLAUDE_MCP`
|
|
63
|
+
* does not stop a project `.mcp.json`). The hub-owned jcode therefore starts every server these files name, besides
|
|
64
|
+
* the hub's own. This lists their names for `ahub doctor`. It reads only `<JCODE_HOME>/mcp.json` and the project's
|
|
65
|
+
* `.jcode/mcp.json`, `.mcp.json` and `.claude/mcp.json`: no other path, no home search.
|
|
66
|
+
* Links are refused below the trusted root (ceiling: per-component `lstat` plus `O_NOFOLLOW` does not pin a directory against a concurrent same-user swap; that would need fd-relative traversal): each controlled directory component (`.jcode`, `.claude`) and the
|
|
67
|
+
* file itself must be a real directory or a real regular file, and the file is opened without following a link and
|
|
68
|
+
* checked again after the open (review I3). The project root and the home are the hub's own configured paths, so a
|
|
69
|
+
* system alias at or above them (macOS `/var`) stays legitimate. Names only: a command, url, header or environment
|
|
70
|
+
* value never leaves the file.
|
|
71
|
+
*/
|
|
72
|
+
function readBoundedJson(root: string, parts: string[]): unknown | undefined {
|
|
73
|
+
let dir = root;
|
|
74
|
+
for (const part of parts.slice(0, -1)) {
|
|
75
|
+
dir = join(dir, part);
|
|
76
|
+
const info = lstatSync(dir, { throwIfNoEntry: false });
|
|
77
|
+
if (!info) return undefined; // absent: nothing to list
|
|
78
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsafe directory");
|
|
79
|
+
}
|
|
80
|
+
const file = join(dir, parts[parts.length - 1]!), info = lstatSync(file, { throwIfNoEntry: false });
|
|
81
|
+
if (!info) return undefined;
|
|
82
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 256 * 1024) throw new Error("unsafe file");
|
|
83
|
+
let fd: number;
|
|
84
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
85
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
86
|
+
try {
|
|
87
|
+
const open = fstatSync(fd);
|
|
88
|
+
if (!open.isFile() || open.nlink !== 1 || open.size > 256 * 1024 || open.dev !== info.dev || open.ino !== info.ino) throw new Error("unsafe file");
|
|
89
|
+
const bytes = Buffer.alloc(open.size);
|
|
90
|
+
let offset = 0;
|
|
91
|
+
while (offset < bytes.length) { const n = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!n) break; offset += n; }
|
|
92
|
+
return JSON.parse(bytes.subarray(0, offset).toString("utf8"));
|
|
93
|
+
} finally { closeSync(fd); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function jcodeExtraMcpServers(cwd: string, home: string): { servers: string[]; unreadable: string[] } {
|
|
97
|
+
const sources: [label: string, root: string, parts: string[]][] = [
|
|
98
|
+
["mcp.json (JCODE_HOME)", home, ["mcp.json"]], [JCODE_MCP_FILE, cwd, [".jcode", "mcp.json"]],
|
|
99
|
+
[".mcp.json", cwd, [".mcp.json"]], [".claude/mcp.json", cwd, [".claude", "mcp.json"]],
|
|
100
|
+
];
|
|
101
|
+
const names = new Set<string>(), unreadable: string[] = [];
|
|
102
|
+
for (const [label, root, parts] of sources) {
|
|
103
|
+
try {
|
|
104
|
+
const doc = readBoundedJson(root, parts);
|
|
105
|
+
if (doc === undefined) continue;
|
|
106
|
+
const servers = object(doc) ? (doc.mcpServers ?? doc.servers) : undefined;
|
|
107
|
+
if (!object(doc) || (servers !== undefined && !object(servers))) { unreadable.push(label); continue; }
|
|
108
|
+
for (const name of Object.keys(servers ?? {})) if (name !== JCODE_MCP_NAME) names.add(name);
|
|
109
|
+
} catch { unreadable.push(label); }
|
|
110
|
+
}
|
|
111
|
+
return { servers: [...names].sort(), unreadable };
|
|
112
|
+
}
|