@staix/agent-hub 0.12.38 → 0.12.39
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 +8 -0
- package/README.md +8 -4
- package/docs/agent-notes/adapters.md +3 -1
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/tests.md +1 -1
- package/docs/operations.md +24 -11
- package/docs/quickstart.md +9 -3
- package/docs/security.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +11 -4
- package/docs/verification/2026-10-20-0.12.39.md +55 -0
- package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
- package/docs/verified.json +12 -12
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +273 -53
- package/src/adapters/acp.ts +20 -2
- package/src/adapters/definitions/builtin-metadata.ts +4 -4
- package/src/adapters/definitions/native-cli-entry-jcode.ts +80 -11
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- 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 +58 -1
- package/src/adapters/registry.ts +1 -1
- package/src/cli/main.ts +4 -4
- package/src/cli/preview.ts +2 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +13 -1
- package/src/hub/peers.ts +2 -2
- package/src/hub/start-mode.ts +1 -1
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,6 +151,8 @@ 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;
|
|
@@ -339,6 +344,7 @@ export class AcpPeer extends BasePeer {
|
|
|
339
344
|
this.deliveryAccepted = false;
|
|
340
345
|
this.promptActivity = false;
|
|
341
346
|
this.chunks = [];
|
|
347
|
+
this.openTools.clear();
|
|
342
348
|
this.activityOpen = true;
|
|
343
349
|
this.activityPrivate = envs.some(env => env.private === true);
|
|
344
350
|
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
@@ -415,6 +421,16 @@ export class AcpPeer extends BasePeer {
|
|
|
415
421
|
protected override onWatchdog(): void {
|
|
416
422
|
if (this.state !== "busy") return;
|
|
417
423
|
if (this.cancelWait) { this.clearWatchdog(); return; }
|
|
424
|
+
// #542 D4: a tool call that started and has not finished is running, not silent, up to its own ceiling.
|
|
425
|
+
if (this.openTools.size) {
|
|
426
|
+
const ceiling = Math.max(this.watchdogMs, this.opts.toolCeilingMs ?? TOOL_CEILING_MS);
|
|
427
|
+
const remaining = ceiling - (Date.now() - this.lastSignAt);
|
|
428
|
+
if (remaining > 0) { this.touch(Math.min(this.watchdogMs, remaining)); return; }
|
|
429
|
+
// Privacy latches for the turn and fails closed, as in emitActivity; the title is agent-written, so one quoted line.
|
|
430
|
+
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
431
|
+
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(", ");
|
|
432
|
+
this.opts.log?.(`[${this.id}] open tool call (${tools}) silent past the ${Math.round(ceiling / 60_000)} min tool ceiling; cancelling the turn`);
|
|
433
|
+
}
|
|
418
434
|
this.endActivity("watchdog cancelled", true);
|
|
419
435
|
const cancelledTurn = this.turn;
|
|
420
436
|
this.cancelPermissions();
|
|
@@ -482,7 +498,7 @@ export class AcpPeer extends BasePeer {
|
|
|
482
498
|
} catch {
|
|
483
499
|
return; // not protocol output
|
|
484
500
|
}
|
|
485
|
-
if (this.state === "busy" && !this.cancelWait) this.touch();
|
|
501
|
+
if (this.state === "busy" && !this.cancelWait) { this.lastSignAt = Date.now(); this.touch(); }
|
|
486
502
|
|
|
487
503
|
if (msg.method === "session/update") {
|
|
488
504
|
if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
|
|
@@ -517,7 +533,9 @@ export class AcpPeer extends BasePeer {
|
|
|
517
533
|
if (u.sessionUpdate === "tool_call" && typeof u.title === "string") this.toolTitles.set(u.toolCallId, u.title);
|
|
518
534
|
const text = Array.isArray(u.content) ? u.content.map((c: any) => (c?.type === "content" && c.content?.type === "text" ? String(c.content.text) : "")).join("") : "";
|
|
519
535
|
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));
|
|
536
|
+
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));
|
|
537
|
+
else if (u.sessionUpdate === "tool_call" || this.openTools.has(u.toolCallId)) this.openTools.set(u.toolCallId, this.toolTitles.get(u.toolCallId) ?? "tool");
|
|
538
|
+
while (this.openTools.size > TOOL_INPUT_CAP) this.openTools.delete(this.openTools.keys().next().value as string);
|
|
521
539
|
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
540
|
}
|
|
523
541
|
else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
|
|
@@ -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,13 +145,13 @@ 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
|
|
|
@@ -1,30 +1,99 @@
|
|
|
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
|
+
export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryContext["fail"], signal: AbortSignal,
|
|
11
|
+
options: Record<string, string | undefined>, first?: JcodeTuiState): Promise<Extract<JcodeTuiState, { state: "ready" }> | undefined> {
|
|
12
|
+
let binding: JcodeTuiBinding | undefined = first?.binding, announced = false, current = first;
|
|
13
|
+
while (!signal.aborted) {
|
|
14
|
+
if (!current) {
|
|
15
|
+
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
|
|
16
|
+
if (signal.aborted) return;
|
|
17
|
+
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
|
+
current = reply as JcodeTuiState;
|
|
19
|
+
}
|
|
20
|
+
if (binding && (binding.launchId !== current.binding.launchId || binding.sessionId !== current.binding.sessionId)) fail("Jcode launch changed while waiting");
|
|
21
|
+
binding = current.binding;
|
|
22
|
+
if (current.state === "ready") return current;
|
|
23
|
+
if (!announced) { console.log(WAITING); announced = true; }
|
|
24
|
+
await new Promise<void>(resolve => {
|
|
25
|
+
const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
|
|
26
|
+
const timer = setTimeout(finish, 200); signal.addEventListener("abort", finish, { once: true });
|
|
27
|
+
if (signal.aborted) finish();
|
|
28
|
+
});
|
|
29
|
+
current = undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
34
|
const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
8
|
-
const { launcherPreview, qwenSessionId, configureJcodeMcp } = ctx.helpers;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
35
|
+
const { launcherPreview, qwenSessionId, configureJcodeMcp, readControl, startModeOf } = ctx.helpers;
|
|
36
|
+
const print = args.includes("--print-command");
|
|
37
|
+
if (args.includes("--dry-run") || print) {
|
|
38
|
+
// Pure previews never create a native store or start a session. Only a person may resolve an existing private tuple.
|
|
39
|
+
if (!print || args.includes("--dry-run") || !ctx.person) {
|
|
40
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
41
|
+
catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
42
|
+
}
|
|
12
43
|
}
|
|
13
|
-
const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
14
|
-
if (rest.length || (one["--mode"] !== undefined && one["--mode"]
|
|
44
|
+
const { one, rest } = takeFlags(args.filter(arg => arg !== "--print-command"), ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
45
|
+
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
46
|
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
|
|
16
|
-
const
|
|
47
|
+
const control = readControl(stateDir);
|
|
48
|
+
const mode = one["--mode"] ?? (ctx.instance ? "headless" : control?.startModes?.jcode ?? startModeOf(ctx.projectConfig().peers, "jcode"));
|
|
49
|
+
if (print && (mode === "headless" || !control?.features?.includes("native_tui_launch"))) {
|
|
50
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
51
|
+
catch { return fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
52
|
+
}
|
|
53
|
+
if (mode === "tui" && !ctx.person) fail("Jcode's private TUI is person-only; run ahub jcode in your terminal");
|
|
54
|
+
const hub = await connect(), abort = new AbortController();
|
|
55
|
+
let interrupted = false;
|
|
56
|
+
const interruptedWait = () => { if (abort.signal.aborted && !interrupted) fail("hub connection closed while waiting for Jcode; retry from a terminal"); return abort.signal.aborted; };
|
|
57
|
+
const interrupt = () => { interrupted = true; abort.abort(); hub.close(); process.exitCode = 130; };
|
|
58
|
+
process.once("SIGINT", interrupt);
|
|
59
|
+
const oldClose = hub.onClose;
|
|
60
|
+
hub.onClose = (code, reason) => { if (!abort.signal.aborted) abort.abort(); oldClose?.(code, reason); };
|
|
17
61
|
try {
|
|
18
62
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
63
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
20
64
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
21
65
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
22
66
|
const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
|
|
23
|
-
|
|
67
|
+
const options = { model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] };
|
|
68
|
+
if (print) {
|
|
69
|
+
if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
|
|
70
|
+
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
|
|
71
|
+
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
|
+
return console.log(JSON.stringify(reply.launch, null, 2));
|
|
73
|
+
}
|
|
74
|
+
if (mode === "tui") {
|
|
75
|
+
if (!hub.features.includes("native_tui_launch")) fail("the running hub does not support Jcode TUI launch; upgrade it before retrying");
|
|
76
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
77
|
+
if (one["--mode"] || Object.values(options).some(value => value !== undefined)) fail("run ahub jcode with these options in a terminal");
|
|
78
|
+
const reply = await hub.request({ t: "peer_start", peer: "jcode" });
|
|
79
|
+
if (!reply.ok) fail(reply.error);
|
|
80
|
+
console.log(reply.command ?? "Jcode terminal requested"); return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
24
83
|
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
25
84
|
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
|
|
85
|
+
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode, ...options }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
86
|
+
if (interruptedWait()) return;
|
|
27
87
|
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
|
-
|
|
88
|
+
if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
|
|
89
|
+
const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
|
|
90
|
+
if (interruptedWait() || !ready) return;
|
|
91
|
+
const instanceId = typeof snapshot?.instanceId === "string" ? snapshot.instanceId : undefined;
|
|
92
|
+
if (!instanceId) return fail("cannot bind Jcode's terminal to the running hub");
|
|
93
|
+
await ctx.helpers.recordTerminalLaunch("jcode", cwd, stateDir, instanceId);
|
|
94
|
+
const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
|
|
95
|
+
if (interruptedWait()) return;
|
|
96
|
+
if (!confirmed.ok || confirmed.state !== "ready") fail("Jcode launch changed before its terminal opened");
|
|
97
|
+
ctx.execWithEnv(confirmed.launch.cmd, confirmed.launch.args, { ...ctx.helpers.childEnv(), ...confirmed.launch.env });
|
|
98
|
+
} finally { process.removeListener("SIGINT", interrupt); hub.onClose = oldClose; hub.close(); }
|
|
30
99
|
}
|
|
@@ -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>;
|
|
@@ -14,6 +14,7 @@ export interface NativePreviewHelpers {
|
|
|
14
14
|
childEnv: typeof import("../../hub/child-process.ts").childEnv;
|
|
15
15
|
startModeOf: typeof import("../../hub/start-mode.ts").startModeOf;
|
|
16
16
|
kimiMcpPlan: typeof import("../../cli/kimi-mcp.ts").kimiMcpPlan;
|
|
17
|
+
jcodeTuiCommand: typeof import("../jcode.ts").jcodeTuiCommand;
|
|
17
18
|
jcodeCommand: typeof import("../jcode.ts").jcodeCommand;
|
|
18
19
|
qwenSessionId: typeof import("../../hub/crash.ts").qwenSessionId;
|
|
19
20
|
relayModelIds: typeof import("../../models/relay.ts").relayModelIds;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { Launch } from "../../cli/launch.ts";
|
|
2
|
-
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
2
|
+
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext, type NativePreview } from "./native-cli-preview-common.ts";
|
|
3
3
|
|
|
4
|
-
export function previewJcode(ctx: NativePreviewContext) {
|
|
4
|
+
export function previewJcode(ctx: NativePreviewContext): NativePreview {
|
|
5
5
|
const { args, config } = ctx;
|
|
6
|
-
const { jcodeCommand, qwenSessionId } = ctx.helpers;
|
|
6
|
+
const { jcodeCommand, jcodeTuiCommand, qwenSessionId, startModeOf } = ctx.helpers;
|
|
7
7
|
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
8
8
|
let envNames = state.envNames;
|
|
9
9
|
let launch: Launch;
|
|
10
|
-
const mode = value("--mode") ?? "
|
|
11
|
-
if (mode !== "headless") throw new Error("Jcode
|
|
10
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "jcode"), model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
|
|
11
|
+
if (mode !== "headless" && mode !== "tui") throw new Error("invalid Jcode start mode");
|
|
12
12
|
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
|
|
13
13
|
if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
|
|
14
|
-
const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
14
|
+
const cmd = mode === "tui" ? jcodeTuiCommand(nativeCommand(config.jcode_cmd, ["jcode"]), UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined) : jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
15
15
|
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
16
16
|
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
17
17
|
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
18
18
|
|
|
19
|
-
return finishPreview(ctx, launch, envNames, unresolved,
|
|
19
|
+
return { ...finishPreview(ctx, launch, envNames, unresolved), mode };
|
|
20
20
|
}
|
|
@@ -12,7 +12,7 @@ export const codexRetainedTerminalNotice = (_instance: string, _form: StartForm
|
|
|
12
12
|
export const codexRosterReturnNotice = (resumeCommand: string): string =>
|
|
13
13
|
`Codex 0.146.0 needs a manual return: resume the previous native session with ${resumeCommand}.`;
|
|
14
14
|
export const jcodeStartPreflight = (request: StartRequest): string | undefined =>
|
|
15
|
-
request.form !== undefined && request.form !== "headless"
|
|
15
|
+
request.form !== undefined && request.form !== "headless" && request.form !== "tui" ? "invalid Jcode start mode" : undefined;
|
|
16
16
|
export const claudeObservedVersion = (context: VersionContext): string | undefined => context.observedVersion;
|
|
17
17
|
export const codexObservedVersion = (context: VersionContext): string | undefined =>
|
|
18
18
|
(context.peer as { version?: string } | undefined)?.version ?? context.observedVersion;
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { isAbsolute, join } from "node:path";
|
|
5
5
|
import type { Readable } from "node:stream";
|
|
@@ -17,6 +17,45 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
|
|
|
17
17
|
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
18
18
|
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
|
|
19
19
|
}
|
|
20
|
+
export interface JcodeTuiBinding { launchId: string; sessionId: string }
|
|
21
|
+
export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
|
|
22
|
+
{ state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
|
|
23
|
+
|
|
24
|
+
export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string): string[] {
|
|
25
|
+
if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
|
|
26
|
+
return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev",
|
|
27
|
+
...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : [])];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read native persistence only. Never synthesize a native session or send a save-inducing prompt. */
|
|
31
|
+
function savedSession(home: string, id: string): boolean {
|
|
32
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error("invalid native session identity");
|
|
33
|
+
const directory = join(home, "sessions"), info = lstatSync(directory, { throwIfNoEntry: false });
|
|
34
|
+
if (!info) return false;
|
|
35
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsafe native session directory");
|
|
36
|
+
const file = join(directory, `${id}.json`), entry = lstatSync(file, { throwIfNoEntry: false });
|
|
37
|
+
if (!entry) return false;
|
|
38
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("unsafe native session proof");
|
|
39
|
+
let fd: number;
|
|
40
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
41
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
42
|
+
try {
|
|
43
|
+
const before = fstatSync(fd);
|
|
44
|
+
// ponytail: bound native persistence proof to 32 MiB; larger conversations need a native saved-session API.
|
|
45
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 32 * 1024 * 1024) throw new Error("unsafe native session proof");
|
|
46
|
+
if (!before.size) return false;
|
|
47
|
+
const bytes = Buffer.alloc(before.size);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < bytes.length) { const n = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!n) return false; offset += n; }
|
|
50
|
+
const after = fstatSync(fd);
|
|
51
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) return false;
|
|
52
|
+
let data: unknown;
|
|
53
|
+
try { data = JSON.parse(bytes.toString("utf8")); } catch { return false; }
|
|
54
|
+
if (!data || typeof data !== "object" || (data as { id?: unknown }).id !== id || !Array.isArray((data as { messages?: unknown }).messages)) throw new Error("native saved session identity is unproven");
|
|
55
|
+
return true;
|
|
56
|
+
} finally { closeSync(fd); }
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/** Set on every owned jcode process: no Claude MCP import, and no native telemetry (jcode-telemetry-core is_enabled, #529). */
|
|
21
60
|
const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
|
|
22
61
|
/** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
|
|
@@ -45,6 +84,8 @@ export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
|
|
|
45
84
|
|
|
46
85
|
/** Both processes are ours. The user's global daemon and its sockets are never stopped. */
|
|
47
86
|
export class JcodePeer extends AcpPeer {
|
|
87
|
+
private readonly launchId = crypto.randomUUID();
|
|
88
|
+
private socketIdentity: { dev: number; ino: number } | undefined;
|
|
48
89
|
private readonly root: string;
|
|
49
90
|
private readonly socket: string;
|
|
50
91
|
private server: ChildProcess | undefined;
|
|
@@ -79,6 +120,8 @@ export class JcodePeer extends AcpPeer {
|
|
|
79
120
|
ready.once("data", () => { clearTimeout(timer); server.off("exit", fail); if (lstatSync(this.socket, { throwIfNoEntry: false })?.isSocket()) resolve(); else reject(new Error("Jcode readiness has no private socket")); });
|
|
80
121
|
});
|
|
81
122
|
server.once("exit", () => { if (!this.ending && this.server === server) void this.stop("Jcode owned daemon disconnected").catch(() => {}); });
|
|
123
|
+
const identity = lstatSync(this.socket);
|
|
124
|
+
this.socketIdentity = { dev: identity.dev, ino: identity.ino };
|
|
82
125
|
await super.start();
|
|
83
126
|
} catch (error) { await this.stop().catch(() => {}); throw error; }
|
|
84
127
|
}
|
|
@@ -119,6 +162,20 @@ export class JcodePeer extends AcpPeer {
|
|
|
119
162
|
}
|
|
120
163
|
if (failure) throw failure;
|
|
121
164
|
}
|
|
165
|
+
tuiLaunch(expected?: JcodeTuiBinding, options: { model?: string; providerProfile?: string; sessionId?: string } = {}): JcodeTuiState {
|
|
166
|
+
const sessionId = super.recoveryMetadata().sessionId;
|
|
167
|
+
const socket = lstatSync(this.socket, { throwIfNoEntry: false });
|
|
168
|
+
if (this.ending || !this.server || this.server.exitCode !== null || this.server.signalCode !== null || this.state === "offline" ||
|
|
169
|
+
!socket?.isSocket() || socket.dev !== this.socketIdentity?.dev || socket.ino !== this.socketIdentity.ino || typeof sessionId !== "string") throw new Error("owned Jcode launch is unavailable");
|
|
170
|
+
const binding = { launchId: this.launchId, sessionId };
|
|
171
|
+
if (expected && (expected.launchId !== binding.launchId || expected.sessionId !== binding.sessionId)) throw new Error("Jcode launch changed");
|
|
172
|
+
if ((options.sessionId !== undefined && options.sessionId !== sessionId) ||
|
|
173
|
+
(options.model !== undefined && options.model !== this.jcode.launchModel) ||
|
|
174
|
+
(options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile)) throw new Error("Jcode launch options differ from its running session");
|
|
175
|
+
if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
|
|
176
|
+
const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
|
|
177
|
+
return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, ...JCODE_OWNED_ENV } } };
|
|
178
|
+
}
|
|
122
179
|
override recoveryMetadata(): Record<string, unknown> {
|
|
123
180
|
return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
|
|
124
181
|
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const ADAPTER_API = 1;
|
|
|
16
16
|
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
17
17
|
export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
|
|
18
18
|
export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
|
|
19
|
-
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
19
|
+
export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
|
|
20
20
|
export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
|
|
21
21
|
|
|
22
22
|
|
package/src/cli/main.ts
CHANGED
|
@@ -255,8 +255,8 @@ function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi", e
|
|
|
255
255
|
process.exit(res.status ?? 1);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, executionCwd = cwd): never {
|
|
259
|
-
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(
|
|
258
|
+
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, peer: string, executionCwd = cwd): never {
|
|
259
|
+
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(peer, extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
260
260
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
261
261
|
delete env.AGENTHUB_UNATTENDED;
|
|
262
262
|
const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env });
|
|
@@ -854,11 +854,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
854
854
|
if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
|
|
855
855
|
const hub = await nativeConnect(); hub.close();
|
|
856
856
|
}
|
|
857
|
-
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
|
|
857
|
+
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, person: caller.role === "console", definition: entry.definition,
|
|
858
858
|
...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
|
|
859
859
|
projectConfig, connect: nativeConnect, fail,
|
|
860
860
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
861
|
-
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
|
|
861
|
+
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
862
862
|
helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
|
|
863
863
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
864
864
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
package/src/cli/preview.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
4
4
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
5
5
|
import { instanceLaunchArguments, buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
|
|
6
6
|
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
7
|
-
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
7
|
+
import { jcodeCommand, jcodeTuiCommand } from "../adapters/jcode.ts";
|
|
8
8
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
9
9
|
import { relayModelIds } from "../models/relay.ts";
|
|
10
10
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
@@ -24,7 +24,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
24
24
|
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
|
|
25
25
|
baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
|
|
26
26
|
helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
27
|
-
startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
27
|
+
startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
28
28
|
});
|
|
29
29
|
if (!selected.instance) return result;
|
|
30
30
|
return { ...result, instance: selected.instance,
|
|
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
-
export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch"] as const;
|
|
12
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
13
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
14
14
|
|
package/src/hub/daemon.ts
CHANGED
|
@@ -3322,7 +3322,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3322
3322
|
if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
|
|
3323
3323
|
return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
3324
3324
|
}
|
|
3325
|
-
if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
|
|
3325
|
+
if (owner && owner.state !== "offline" && definitionOf(peer)?.start.daemonPort !== "owned-acp") return { ok: true, already: true, mode };
|
|
3326
3326
|
const words = [...startWords(peer, mode)];
|
|
3327
3327
|
if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
|
|
3328
3328
|
const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
|
|
@@ -5579,6 +5579,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5579
5579
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
5580
5580
|
return void reply({ ok: true });
|
|
5581
5581
|
}
|
|
5582
|
+
case "native_tui_launch": {
|
|
5583
|
+
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
5584
|
+
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
5585
|
+
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
|
|
5586
|
+
peer !== definitionOf(peer)?.kind || !(owner instanceof JcodePeer) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
|
|
5587
|
+
try {
|
|
5588
|
+
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
5589
|
+
const options = msg.args ?? {};
|
|
5590
|
+
for (const key of ["model", "providerProfile", "sessionId"]) if (options[key] !== undefined && typeof options[key] !== "string") throw new Error("invalid option");
|
|
5591
|
+
return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
|
|
5592
|
+
} catch { return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" }); }
|
|
5593
|
+
}
|
|
5582
5594
|
case "peer_start":
|
|
5583
5595
|
// #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
|
|
5584
5596
|
if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
|
package/src/hub/peers.ts
CHANGED
|
@@ -73,11 +73,11 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Any sign of life from a busy peer re-arms the watchdog (it measures inactivity, not duration). */
|
|
76
|
-
protected touch(): void {
|
|
76
|
+
protected touch(ms = this.watchdogMs): void {
|
|
77
77
|
this.clearWatchdog();
|
|
78
78
|
this.timer = setTimeout(() => {
|
|
79
79
|
if (this._state === "busy") this.onWatchdog();
|
|
80
|
-
},
|
|
80
|
+
}, ms);
|
|
81
81
|
this.timer.unref?.();
|
|
82
82
|
}
|
|
83
83
|
|
package/src/hub/start-mode.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type StartMode = typeof START_MODES[number];
|
|
|
11
11
|
// ponytail: released coordinators source-probe this exact literal before restoring headless Kimi.
|
|
12
12
|
// Keep it until those readers negotiate the registry capability.
|
|
13
13
|
/** The peers with both forms. Kimi's TUI is pull-only; Claude has only a TUI and local only headless. */
|
|
14
|
-
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
14
|
+
export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
|
|
15
15
|
/** Every peer the hub can start itself. */
|
|
16
16
|
export const STARTABLE_PEERS = SUPPORTED_PEERS;
|
|
17
17
|
export type StartablePeer = typeof STARTABLE_PEERS[number];
|