@staix/agent-hub 0.12.38 → 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 +34 -0
- package/README.md +19 -10
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -5
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +167 -30
- package/docs/quickstart.md +25 -5
- package/docs/security.md +20 -8
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +167 -72
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.39.md +55 -0
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -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 +679 -193
- package/src/adapters/acp.ts +54 -3
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +6 -4
- 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 +90 -11
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
- package/src/adapters/definitions/native-daemon-policy.ts +1 -1
- package/src/adapters/jcode.ts +165 -2
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry.ts +1 -1
- 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 +138 -28
- package/src/cli/output.ts +5 -2
- package/src/cli/preview.ts +2 -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 +166 -67
- 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 +4 -2
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/start-mode.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
|
@@ -79,6 +79,8 @@ export interface AcpOptions {
|
|
|
79
79
|
/** Optional launch environment; recovery authority is always removed before spawn. */
|
|
80
80
|
env?: NodeJS.ProcessEnv;
|
|
81
81
|
watchdogMs?: number;
|
|
82
|
+
/** #542 D4: how long an open tool call keeps a silent turn alive; defaults to 30 minutes, never below the watchdog. */
|
|
83
|
+
toolCeilingMs?: number;
|
|
82
84
|
/** Bound for a journal-less watchdog cancellation to report its prompt; defaults to the 30 s ACP handshake cap. */
|
|
83
85
|
cancelWaitMs?: number;
|
|
84
86
|
/** ACP stdio MCP servers for the session (the hub's task tools). */
|
|
@@ -108,6 +110,7 @@ export interface AcpOptions {
|
|
|
108
110
|
const HANDSHAKE_MS = 30_000;
|
|
109
111
|
/** Tool calls whose arguments are remembered until their permission request arrives. */
|
|
110
112
|
const TOOL_INPUT_CAP = 64;
|
|
113
|
+
const TOOL_CEILING_MS = 30 * 60_000;
|
|
111
114
|
|
|
112
115
|
/**
|
|
113
116
|
* The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
|
|
@@ -148,12 +151,17 @@ export class AcpPeer extends BasePeer {
|
|
|
148
151
|
private readonly toolInputs = new Map<string, unknown>();
|
|
149
152
|
private readonly toolText = new Map<string, string>(); // streamed argument text, per call, until it finishes
|
|
150
153
|
private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
|
|
154
|
+
private readonly openTools = new Map<string, string>(); // this turn's started, unfinished tool calls (#542 D4)
|
|
155
|
+
private lastSignAt = 0;
|
|
151
156
|
private primed = false;
|
|
152
157
|
private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
|
|
153
158
|
private stopping = false;
|
|
154
159
|
private activeDeliveryId: string | undefined;
|
|
155
160
|
private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
|
|
156
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[] = [];
|
|
157
165
|
private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout>; reported: boolean; released: Promise<void>; release: () => void } | undefined;
|
|
158
166
|
private promptActivity = false;
|
|
159
167
|
private activityOpen = false;
|
|
@@ -319,6 +327,7 @@ export class AcpPeer extends BasePeer {
|
|
|
319
327
|
if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
|
|
320
328
|
if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
|
|
321
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");
|
|
322
331
|
this.activeDeliveryId = undefined;
|
|
323
332
|
const proc = this.proc;
|
|
324
333
|
if (!proc) { this.setState("offline"); return; }
|
|
@@ -336,9 +345,11 @@ export class AcpPeer extends BasePeer {
|
|
|
336
345
|
const turn = ++this.turn;
|
|
337
346
|
this.activeDeliveryId = deliveryId;
|
|
338
347
|
this.activeLegacy = deliveryId ? undefined : { turn, envs };
|
|
348
|
+
this.turnEnvs = envs; this.steerIds = [];
|
|
339
349
|
this.deliveryAccepted = false;
|
|
340
350
|
this.promptActivity = false;
|
|
341
351
|
this.chunks = [];
|
|
352
|
+
this.openTools.clear();
|
|
342
353
|
this.activityOpen = true;
|
|
343
354
|
this.activityPrivate = envs.some(env => env.private === true);
|
|
344
355
|
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
@@ -360,13 +371,16 @@ export class AcpPeer extends BasePeer {
|
|
|
360
371
|
this.observeUsage(result, "prompt_result");
|
|
361
372
|
this.primed = true;
|
|
362
373
|
const body = this.chunks.join("").trim();
|
|
363
|
-
|
|
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) });
|
|
364
377
|
// Partial output cannot certify normal completion (#160).
|
|
365
378
|
if (result?.stopReason !== "end_turn") this.reportTurnFailure(envs, `ACP prompt ended without normal completion (${String(result?.stopReason ?? "unknown")})`);
|
|
366
379
|
if (deliveryId && this.activeDeliveryId === deliveryId) {
|
|
367
380
|
this.acceptDelivery();
|
|
368
381
|
this.delivery({ id: deliveryId, state: result?.stopReason === "end_turn" ? "completed" : "needs_review", ...(result?.stopReason === "end_turn" ? {} : { reason: "ACP prompt ended without normal completion" }) });
|
|
369
382
|
}
|
|
383
|
+
this.settleSteers(result?.stopReason === "end_turn" ? "completed" : "needs_review", "ACP prompt ended without normal completion");
|
|
370
384
|
})
|
|
371
385
|
.catch((e: Error) => {
|
|
372
386
|
if (turn !== this.turn) {
|
|
@@ -375,6 +389,7 @@ export class AcpPeer extends BasePeer {
|
|
|
375
389
|
}
|
|
376
390
|
this.endActivity("failed", true);
|
|
377
391
|
const reason = sanitize(e.message);
|
|
392
|
+
this.settleSteers("needs_review", reason);
|
|
378
393
|
this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
|
|
379
394
|
const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
|
|
380
395
|
if (quota) {
|
|
@@ -415,12 +430,23 @@ export class AcpPeer extends BasePeer {
|
|
|
415
430
|
protected override onWatchdog(): void {
|
|
416
431
|
if (this.state !== "busy") return;
|
|
417
432
|
if (this.cancelWait) { this.clearWatchdog(); return; }
|
|
433
|
+
// #542 D4: a tool call that started and has not finished is running, not silent, up to its own ceiling.
|
|
434
|
+
if (this.openTools.size) {
|
|
435
|
+
const ceiling = Math.max(this.watchdogMs, this.opts.toolCeilingMs ?? TOOL_CEILING_MS);
|
|
436
|
+
const remaining = ceiling - (Date.now() - this.lastSignAt);
|
|
437
|
+
if (remaining > 0) { this.touch(Math.min(this.watchdogMs, remaining)); return; }
|
|
438
|
+
// Privacy latches for the turn and fails closed, as in emitActivity; the title is agent-written, so one quoted line.
|
|
439
|
+
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
440
|
+
const tools = this.activityPrivate ? "a tool call" : [...new Set(this.openTools.values())].map(title => JSON.stringify(activityText(title).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 80))).join(", ");
|
|
441
|
+
this.opts.log?.(`[${this.id}] open tool call (${tools}) silent past the ${Math.round(ceiling / 60_000)} min tool ceiling; cancelling the turn`);
|
|
442
|
+
}
|
|
418
443
|
this.endActivity("watchdog cancelled", true);
|
|
419
444
|
const cancelledTurn = this.turn;
|
|
420
445
|
this.cancelPermissions();
|
|
421
446
|
this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
|
|
422
447
|
const durable = !!this.activeDeliveryId;
|
|
423
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");
|
|
424
450
|
this.activeDeliveryId = undefined;
|
|
425
451
|
this.notify("session/cancel", { sessionId: this.sessionId });
|
|
426
452
|
this.turn++; // whatever the cancelled prompt still reports is stale
|
|
@@ -458,6 +484,28 @@ export class AcpPeer extends BasePeer {
|
|
|
458
484
|
if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
|
|
459
485
|
}
|
|
460
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
|
+
|
|
461
509
|
private acceptDelivery(): void {
|
|
462
510
|
if (!this.activeDeliveryId || this.deliveryAccepted) return;
|
|
463
511
|
this.deliveryAccepted = true;
|
|
@@ -470,6 +518,7 @@ export class AcpPeer extends BasePeer {
|
|
|
470
518
|
for (const p of this.pending.values()) p.reject(new Error(reason));
|
|
471
519
|
this.pending.clear();
|
|
472
520
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason });
|
|
521
|
+
this.settleSteers("needs_review", reason);
|
|
473
522
|
this.activeDeliveryId = undefined;
|
|
474
523
|
this.setState("offline");
|
|
475
524
|
}
|
|
@@ -482,7 +531,7 @@ export class AcpPeer extends BasePeer {
|
|
|
482
531
|
} catch {
|
|
483
532
|
return; // not protocol output
|
|
484
533
|
}
|
|
485
|
-
if (this.state === "busy" && !this.cancelWait) this.touch();
|
|
534
|
+
if (this.state === "busy" && !this.cancelWait) { this.lastSignAt = Date.now(); this.touch(); }
|
|
486
535
|
|
|
487
536
|
if (msg.method === "session/update") {
|
|
488
537
|
if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
|
|
@@ -517,7 +566,9 @@ export class AcpPeer extends BasePeer {
|
|
|
517
566
|
if (u.sessionUpdate === "tool_call" && typeof u.title === "string") this.toolTitles.set(u.toolCallId, u.title);
|
|
518
567
|
const text = Array.isArray(u.content) ? u.content.map((c: any) => (c?.type === "content" && c.content?.type === "text" ? String(c.content.text) : "")).join("") : "";
|
|
519
568
|
if (text) this.toolText.set(u.toolCallId, text);
|
|
520
|
-
if (u.status === "completed" || u.status === "failed") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId), this.toolTitles.delete(u.toolCallId));
|
|
569
|
+
if (u.status === "completed" || u.status === "failed") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId), this.toolTitles.delete(u.toolCallId), this.openTools.delete(u.toolCallId));
|
|
570
|
+
else if (u.sessionUpdate === "tool_call" || this.openTools.has(u.toolCallId)) this.openTools.set(u.toolCallId, this.toolTitles.get(u.toolCallId) ?? "tool");
|
|
571
|
+
while (this.openTools.size > TOOL_INPUT_CAP) this.openTools.delete(this.openTools.keys().next().value as string);
|
|
521
572
|
for (const map of [this.toolInputs, this.toolText, this.toolTitles]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
|
|
522
573
|
}
|
|
523
574
|
else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
|
|
@@ -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");
|
|
@@ -22,8 +22,8 @@ type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
|
|
|
22
22
|
* Preserve CLI-wrapper argv plans for definitions that still declare them and the frozen A1 compatibility export.
|
|
23
23
|
* Native startup and daemon factories are supplied separately by the runtime definitions.
|
|
24
24
|
*/
|
|
25
|
-
function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext): LaunchPlan {
|
|
26
|
-
const mode = kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
|
|
25
|
+
function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
|
|
26
|
+
const mode = explicitMode || kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
|
|
27
27
|
const unattended = ctx.unattended && (kind === "claude" || kind === "codex") ? ["--unattended"] : [];
|
|
28
28
|
const args = [ctx.entrypoint, "--project", ctx.projectRoot, kind, ...mode, ...unattended,
|
|
29
29
|
...(ctx.model && !ctx.userArgs.includes("--model") ? ["--model", ctx.model] : []), ...ctx.userArgs];
|
|
@@ -145,19 +145,21 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
145
145
|
{
|
|
146
146
|
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
147
147
|
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
148
|
-
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms:
|
|
148
|
+
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, explicitHeadlessMode: true, launch: ctx => compatibilityLaunch("jcode", ctx, true), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
149
149
|
identity: { mcp: "project-file" },
|
|
150
150
|
permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
|
|
151
151
|
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" } },
|
|
152
152
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
153
153
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
154
|
-
cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords:
|
|
154
|
+
cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
155
155
|
},
|
|
156
156
|
] satisfies AdapterDefinition[]);
|
|
157
157
|
|
|
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
|
}
|
|
@@ -1,30 +1,109 @@
|
|
|
1
1
|
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
4
|
+
import type { ControlClient } from "../../hub/control-client.ts";
|
|
5
|
+
import type { JcodeTuiBinding, JcodeTuiState } from "../jcode.ts";
|
|
4
6
|
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
5
7
|
|
|
8
|
+
const WAITING = "jcode's TUI opens after its first hub turn; send it work, or Ctrl-C";
|
|
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
|
+
|
|
15
|
+
export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryContext["fail"], signal: AbortSignal,
|
|
16
|
+
options: Record<string, string | undefined>, first?: JcodeTuiState): Promise<Extract<JcodeTuiState, { state: "ready" }> | undefined> {
|
|
17
|
+
let binding: JcodeTuiBinding | undefined = first?.binding, announced = false, current = first;
|
|
18
|
+
while (!signal.aborted) {
|
|
19
|
+
if (!current) {
|
|
20
|
+
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
|
|
21
|
+
if (signal.aborted) return;
|
|
22
|
+
refuseNamedOption(reply, fail);
|
|
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");
|
|
24
|
+
current = reply as JcodeTuiState;
|
|
25
|
+
}
|
|
26
|
+
if (binding && (binding.launchId !== current.binding.launchId || binding.sessionId !== current.binding.sessionId)) fail("Jcode launch changed while waiting");
|
|
27
|
+
binding = current.binding;
|
|
28
|
+
if (current.state === "ready") return current;
|
|
29
|
+
if (!announced) { console.log(WAITING); announced = true; }
|
|
30
|
+
await new Promise<void>(resolve => {
|
|
31
|
+
const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
|
|
32
|
+
const timer = setTimeout(finish, 200); signal.addEventListener("abort", finish, { once: true });
|
|
33
|
+
if (signal.aborted) finish();
|
|
34
|
+
});
|
|
35
|
+
current = undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
6
39
|
export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
40
|
const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
8
|
-
const { launcherPreview, qwenSessionId, configureJcodeMcp } = ctx.helpers;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
41
|
+
const { launcherPreview, qwenSessionId, configureJcodeMcp, readControl, startModeOf } = ctx.helpers;
|
|
42
|
+
const print = args.includes("--print-command");
|
|
43
|
+
if (args.includes("--dry-run") || print) {
|
|
44
|
+
// Pure previews never create a native store or start a session. Only a person may resolve an existing private tuple.
|
|
45
|
+
if (!print || args.includes("--dry-run") || !ctx.person) {
|
|
46
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
47
|
+
catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
48
|
+
}
|
|
12
49
|
}
|
|
13
|
-
const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
14
|
-
if (rest.length || (one["--mode"] !== undefined && one["--mode"]
|
|
50
|
+
const { one, rest } = takeFlags(args.filter(arg => arg !== "--print-command"), ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
51
|
+
if (rest.length || (one["--mode"] !== undefined && !["headless", "tui"].includes(one["--mode"]))) fail("usage: ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
|
|
15
52
|
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
|
|
16
|
-
const
|
|
53
|
+
const control = readControl(stateDir);
|
|
54
|
+
const mode = one["--mode"] ?? (ctx.instance ? "headless" : control?.startModes?.jcode ?? startModeOf(ctx.projectConfig().peers, "jcode"));
|
|
55
|
+
if (print && (mode === "headless" || !control?.features?.includes("native_tui_launch"))) {
|
|
56
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
57
|
+
catch { return fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
58
|
+
}
|
|
59
|
+
if (mode === "tui" && !ctx.person) fail("Jcode's private TUI is person-only; run ahub jcode in your terminal");
|
|
60
|
+
const hub = await connect(), abort = new AbortController();
|
|
61
|
+
let interrupted = false;
|
|
62
|
+
const interruptedWait = () => { if (abort.signal.aborted && !interrupted) fail("hub connection closed while waiting for Jcode; retry from a terminal"); return abort.signal.aborted; };
|
|
63
|
+
const interrupt = () => { interrupted = true; abort.abort(); hub.close(); process.exitCode = 130; };
|
|
64
|
+
process.once("SIGINT", interrupt);
|
|
65
|
+
const oldClose = hub.onClose;
|
|
66
|
+
hub.onClose = (code, reason) => { if (!abort.signal.aborted) abort.abort(); oldClose?.(code, reason); };
|
|
17
67
|
try {
|
|
18
68
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
69
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
20
70
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
21
71
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
22
72
|
const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
|
|
23
|
-
|
|
73
|
+
const options = { model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] };
|
|
74
|
+
if (print) {
|
|
75
|
+
if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
|
|
76
|
+
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
|
|
77
|
+
refuseNamedOption(reply, fail);
|
|
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");
|
|
79
|
+
return console.log(JSON.stringify(reply.launch, null, 2));
|
|
80
|
+
}
|
|
81
|
+
if (mode === "tui") {
|
|
82
|
+
if (!hub.features.includes("native_tui_launch")) fail("the running hub does not support Jcode TUI launch; upgrade it before retrying");
|
|
83
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
84
|
+
if (one["--mode"] || Object.values(options).some(value => value !== undefined)) fail("run ahub jcode with these options in a terminal");
|
|
85
|
+
const reply = await hub.request({ t: "peer_start", peer: "jcode" });
|
|
86
|
+
if (!reply.ok) fail(reply.error);
|
|
87
|
+
console.log(reply.command ?? "Jcode terminal requested"); return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
24
90
|
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
25
91
|
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
26
|
-
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode
|
|
92
|
+
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode, ...options }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
93
|
+
if (interruptedWait()) return;
|
|
27
94
|
if (!reply.ok) fail(reply.error);
|
|
28
|
-
console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
|
|
29
|
-
|
|
95
|
+
if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
|
|
96
|
+
const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
|
|
97
|
+
if (interruptedWait() || !ready) return;
|
|
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).
|
|
100
|
+
const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
|
|
101
|
+
if (interruptedWait()) return;
|
|
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).
|
|
107
|
+
ctx.execWithEnv(confirmed.launch.cmd, confirmed.launch.args, { ...ctx.helpers.childEnv(), ...confirmed.launch.env });
|
|
108
|
+
} finally { process.removeListener("SIGINT", interrupt); hub.onClose = oldClose; hub.close(); }
|
|
30
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} "..."`);
|
|
@@ -7,6 +7,7 @@ export interface NativeCliEntryContext {
|
|
|
7
7
|
instance?: string; executionCwd?: string;
|
|
8
8
|
/** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
|
|
9
9
|
hubStarted?: true;
|
|
10
|
+
person?: boolean;
|
|
10
11
|
args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
|
|
11
12
|
projectConfig(): HubConfig;
|
|
12
13
|
connect(): Promise<ControlClient>;
|
|
@@ -18,6 +19,8 @@ export interface NativeCliEntryContext {
|
|
|
18
19
|
launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
|
|
19
20
|
readControl: typeof import("../../hub/control-client.ts").readControl;
|
|
20
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;
|
|
21
24
|
recoveryLock: typeof import("../../hub/recovery-store.ts").recoveryLock;
|
|
22
25
|
processSignature: typeof import("../../pi/process-signature.ts").processSignature;
|
|
23
26
|
buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;
|