@staix/agent-hub 0.12.37 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/adapters.md +19 -8
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +6 -3
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +3 -3
  9. package/docs/operations.md +45 -17
  10. package/docs/quickstart.md +19 -5
  11. package/docs/security.md +7 -3
  12. package/docs/smoke.md +22 -2
  13. package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
  14. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  15. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  16. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  17. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  20. package/docs/verified.json +22 -14
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1759 -437
  24. package/src/adapters/acp.ts +20 -2
  25. package/src/adapters/agy/definition.ts +61 -0
  26. package/src/adapters/agy/entry.ts +76 -0
  27. package/src/adapters/agy/hooks.ts +81 -0
  28. package/src/adapters/claude-channel.ts +1 -0
  29. package/src/adapters/codex-review-isolation.ts +30 -16
  30. package/src/adapters/definitions/builtin-metadata.ts +8 -8
  31. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  32. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  33. package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
  34. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  35. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  37. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  38. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  39. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  41. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  42. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  43. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  44. package/src/adapters/definitions/qwen.ts +1 -1
  45. package/src/adapters/definitions/reviewer-variants.ts +4 -3
  46. package/src/adapters/jcode.ts +58 -1
  47. package/src/adapters/kimi-review.ts +161 -0
  48. package/src/adapters/registry-types.ts +29 -1
  49. package/src/adapters/registry.ts +16 -7
  50. package/src/adapters/reviewer-mcp.ts +85 -0
  51. package/src/adapters/reviewer-print.ts +83 -20
  52. package/src/cli/agy-mcp.ts +370 -0
  53. package/src/cli/console-state.ts +6 -0
  54. package/src/cli/console.ts +2 -0
  55. package/src/cli/facts-hook.ts +15 -2
  56. package/src/cli/init.ts +7 -0
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +5 -4
  59. package/src/cli/output.ts +5 -0
  60. package/src/cli/preview.ts +2 -2
  61. package/src/hub/bus.ts +17 -6
  62. package/src/hub/control-client.ts +7 -1
  63. package/src/hub/daemon.ts +182 -24
  64. package/src/hub/native-hook-launch.ts +37 -0
  65. package/src/hub/peer-catalog.ts +2 -3
  66. package/src/hub/peers.ts +2 -2
  67. package/src/hub/reviewer-pool.ts +12 -6
  68. package/src/hub/routing.ts +3 -0
  69. package/src/hub/start-mode.ts +1 -1
  70. package/src/hub/tasks.ts +7 -0
  71. package/src/ui/index.html +6 -1
  72. package/templates/AGENTS.block.md +1 -1
@@ -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");
@@ -0,0 +1,61 @@
1
+ import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
2
+ import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
3
+ import { AGY_FACTS_HOOK } from "./hooks.ts";
4
+
5
+ const DISPLAY = { palette: { fg: "#6558a6", bg: "#f1effa" } };
6
+ const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
7
+
8
+ function agyLaunch(ctx: LaunchContext): LaunchPlan {
9
+ const argv = ["agy", ...ctx.userArgs], command = argv.map(shellQuote).join(" ");
10
+ const metadata = { packageEntrypoint: ctx.entrypoint, command, argv, env: {}, kind: "agy", peer: ctx.instance } as LaunchPlan["metadata"] & { kind: "agy"; peer: string };
11
+ return { cmd: "agy", args: ctx.userArgs, metadata };
12
+ }
13
+
14
+ /** Antigravity is an unmanaged, pull-only peer attached from its native project plugin. */
15
+ export const AGY_DEFINITION: AdapterDefinition = {
16
+ api: 1,
17
+ kind: "agy",
18
+ displayName: "Antigravity",
19
+ family: "agy",
20
+ transport: "ws-pull",
21
+ roles: ["implementer", "verifier"],
22
+ optional: true,
23
+ defaultOnRoster: false,
24
+ trust: { onPrem: false, hubSandboxed: false },
25
+ start: {
26
+ forms: ["tui"],
27
+ defaultForm: "tui",
28
+ ownsTerminal: form => form === "tui",
29
+ launch: agyLaunch,
30
+ conductorStartable: false,
31
+ hubSwitches: [],
32
+ ownedFlags: ["--new", "--continue", "--conversation", "--resume"],
33
+ conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub agy") : {},
34
+ },
35
+ identity: { mcp: "channel-plugin" },
36
+ attach: {
37
+ pullOnlyForms: ["tui"],
38
+ recoveryLaunch: instance => ({ kind: "agy", peer: instance }),
39
+ pullOnlyNotice: "pull-only: messages wait for hub_inbox; permission modes and session recovery are not managed",
40
+ },
41
+ permission: {
42
+ modes: {},
43
+ unmanaged: "Antigravity selects its own permission mode in its TUI; no hub permission mode",
44
+ unmanagedForms: ["tui"],
45
+ unmanagedNote: "Antigravity approves tools in its own TUI; no hub permission mode",
46
+ unmanagedStatusNote: "unmanaged native permission policy",
47
+ },
48
+ session: {
49
+ idField: "sessionId",
50
+ currentId: () => undefined,
51
+ freshOnRestore: true,
52
+ recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
53
+ },
54
+ turnEnd: "native-hook",
55
+ cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
56
+ display: DISPLAY,
57
+ routing: { allowedRoles: ["implementer", "verifier"] },
58
+ reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
59
+ reviewerVariant: AGY_REVIEWER_VARIANT,
60
+ hooks: { factsHook: AGY_FACTS_HOOK },
61
+ };
@@ -0,0 +1,76 @@
1
+ import { publicArg } from "../definitions/native-cli-preview-common.ts";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
5
+ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
6
+ import { ensureAgyMcp, cleanupAgyMcp, type AgyMcpOptions } from "../../cli/agy-mcp.ts";
7
+ import { peerChildEnv } from "../../hub/child-process.ts";
8
+
9
+ export function agyTuiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv) {
10
+ return { cwd, env, stdio: "inherit" as const };
11
+ }
12
+
13
+ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<number> {
14
+ const child = spawn("agy", argv, agyTuiSpawnOptions(cwd, env));
15
+ return new Promise((resolveExit, reject) => {
16
+ const forward = (signal: NodeJS.Signals) => {
17
+ if (child.exitCode !== null || child.signalCode !== null) return;
18
+ try { child.kill(signal); } catch { /* The native child may exit as the signal arrives. */ }
19
+ };
20
+ const onInterrupt = () => forward("SIGINT");
21
+ const onTerminate = () => forward("SIGTERM");
22
+ const onHangup = () => forward("SIGHUP");
23
+ const cleanup = () => {
24
+ process.off("SIGINT", onInterrupt);
25
+ process.off("SIGTERM", onTerminate);
26
+ process.off("SIGHUP", onHangup);
27
+ };
28
+ process.on("SIGINT", onInterrupt);
29
+ process.on("SIGTERM", onTerminate);
30
+ process.on("SIGHUP", onHangup);
31
+ child.once("error", error => { cleanup(); reject(error); });
32
+ child.once("close", code => { cleanup(); resolveExit(code ?? 1); });
33
+ });
34
+ }
35
+
36
+ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
37
+ const { args, cwd, stateDir, cliDir, connect, fail } = ctx;
38
+ if (ctx.instance) fail("extra Antigravity TUI instances are not verified in P1");
39
+ const preview = args.includes("--print-command") || args.includes("--dry-run");
40
+ const unattended = ctx.unattendedEnv || args.includes("--unattended");
41
+ const nativeArgs = args.filter(arg => !["--print-command", "--dry-run", "--unattended"].includes(arg));
42
+ // Headless, resume and native approval overrides are not silently passed through an unverified hub mapping.
43
+ if (nativeArgs.some(arg => ["--print", "-p", "--prompt", "--input-format", "--output-format", "--conversation", "--continue", "-c", "--new", "--mode", "--dangerously-skip-permissions"].some(flag => arg === flag || arg.startsWith(`${flag}=`)))) fail("agy P1 supports a fresh TUI only; headless, resume and permission flags need a verified mapping");
44
+ if (unattended) fail("agy P1 native permissions are unmanaged; unattended mode has no verified prompt mapping");
45
+ const argv = nativeArgs;
46
+ if (preview) { console.log(JSON.stringify({ command: "agy", args: argv.map(publicArg), mode: "tui", permission: "unmanaged", mcp: ".agents/plugins/agent-hub/", writes: false })); return; }
47
+ const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
48
+ if (version.status !== 0 || version.stdout.trim() !== "1.4.2") fail("agy P1 requires the verified Antigravity CLI 1.4.2");
49
+ const hub = await connect();
50
+ let options: AgyMcpOptions | undefined;
51
+ let pluginWritten = false;
52
+ try {
53
+ if (!hub.features.includes("native_hook_launch")) fail("running hub lacks the verified native hook launcher; upgrade or restart it before launching agy");
54
+ const status = (await hub.request({ t: "status" })).status;
55
+ if (rosterInStatus(status, "agy") !== true) fail("agy is off roster; add it with ahub roster add agy");
56
+ const admitted = await hub.request({ t: "native_hook_launch", op: "begin", peer: "agy" });
57
+ if (!admitted.ok || typeof admitted.launchId !== "string" || typeof admitted.instanceId !== "string") fail(admitted.error ?? "native hook launch was not admitted");
58
+ options = { projectRoot: cwd, stateDir, serverJs: bundleInStatus(status) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"),
59
+ hookScript: resolve(cliDir, "facts-hook.ts"), peer: "agy", launchId: admitted.launchId, instanceId: admitted.instanceId };
60
+ const written = ensureAgyMcp(options);
61
+ pluginWritten = written.changed;
62
+ if (written.tracked) console.error("The managed agy plugin is tracked; review its local machine paths before committing");
63
+ console.error("Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox");
64
+ const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
65
+ delete env.AGENTHUB_CHANNEL;
66
+ const code = await runAgyTui(argv, cwd, env);
67
+ if (code !== 0) process.exitCode = code;
68
+ } finally {
69
+ if (options) {
70
+ // Revoke launcher authority before cleanup; stale cleanup never removes a newer managed launch.
71
+ await hub.request({ t: "native_hook_launch", op: "end", peer: "agy", launchId: options.launchId }).catch(() => {});
72
+ if (pluginWritten) try { cleanupAgyMcp(options); } catch { console.error("Managed agy plugin cleanup refused: files changed or ownership could not be verified"); }
73
+ }
74
+ hub.close();
75
+ }
76
+ }
@@ -0,0 +1,81 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import type { NativeFactsInput, NativeFactsPort, NativeFactsTranscript } from "../registry-types.ts";
6
+
7
+ const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
10
+
11
+ /** The pinned 1.4.2 envelope has no event name: the managed command supplies it separately. */
12
+ export function decodeAgyHook(event: string, value: unknown): NativeFactsInput | undefined {
13
+ if (!(EVENTS as readonly string[]).includes(event) || !record(value) || typeof value.conversationId !== "string" || !UUID.test(value.conversationId)
14
+ || typeof value.transcriptPath !== "string" || !isAbsolute(value.transcriptPath) || value.transcriptPath.length > 4096
15
+ || !Array.isArray(value.workspacePaths) || !value.workspacePaths.length || value.workspacePaths.length > 32
16
+ || !value.workspacePaths.every(path => typeof path === "string" && path.length <= 4096 && isAbsolute(path))) return;
17
+ if (event === "Stop" && typeof value.fullyIdle !== "boolean") return;
18
+ const call = record(value.toolCall) ? value.toolCall : undefined;
19
+ if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record(call.args))) return;
20
+ const native = call?.args as Record<string, unknown> | undefined;
21
+ // Only the observed native read is classified. Unobserved edits never receive a scoped grant or effect attribution.
22
+ const observedRead = call?.name === "view_file" && typeof native?.AbsolutePath === "string";
23
+ const phase = ({ SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" } as const)[event as typeof EVENTS[number]];
24
+ return { event: event as NativeFactsInput["event"], phase, sessionId: value.conversationId, transcriptPath: value.transcriptPath,
25
+ workspacePaths: value.workspacePaths as string[], tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path: native!.AbsolutePath } : native ?? {},
26
+ ...(Number.isSafeInteger(value.stepIdx) && (value.stepIdx as number) >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {}),
27
+ ...(Number.isSafeInteger(value.invocationNum) && (value.invocationNum as number) >= 0 ? { invocation: value.invocationNum as number } : {}),
28
+ ...(event === "Stop" ? { fullyIdle: value.fullyIdle as boolean, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}) };
29
+ }
30
+
31
+ export function encodeAgyHook(event: string, text?: string): Record<string, unknown> | undefined {
32
+ // `allow` does not override native approval. Until a prompt mapping is proven, leave all native permissions alone.
33
+ if (text && (event === "SessionStart" || event === "PreInvocation")) return { injectSteps: [{ ephemeralMessage: text }] };
34
+ return undefined;
35
+ }
36
+
37
+ /** Only native-generated rows under the exact session cache path count as readback/completion evidence. */
38
+ export function readAgyTranscript(session: string, transcript: string): NativeFactsTranscript {
39
+ const empty: NativeFactsTranscript = { factIds: [] };
40
+ if (!UUID.test(session)) return empty;
41
+ const home = process.env.HOME ?? homedir();
42
+ const candidates = ["antigravity-cli", "antigravity", "antigravity-ide"].map(surface => join(home, ".gemini", surface, "brain", session, ".system_generated/logs/transcript_full.jsonl"));
43
+ if (!candidates.includes(transcript)) return empty;
44
+ let fd: number | undefined;
45
+ try {
46
+ let cursor = home;
47
+ for (const name of relative(home, transcript).split("/")) { cursor = join(cursor, name); if (lstatSync(cursor).isSymbolicLink()) return empty; }
48
+ fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ const stat = fstatSync(fd);
50
+ if (!stat.isFile() || stat.nlink !== 1) return empty;
51
+ // ponytail: completion lookback is capped at 4 MiB (as for Claude). A larger final row is unverified;
52
+ // upgrade to a native completion cursor if that ceiling becomes insufficient.
53
+ const cap = 4 * 1024 * 1024;
54
+ for (let window = 64 * 1024; ; window = Math.min(cap, window * 2)) {
55
+ const start = Math.max(0, stat.size - window), from = start > 0 ? start - 1 : 0;
56
+ const buffer = Buffer.alloc(stat.size - from);
57
+ let bytes = 0;
58
+ while (bytes < buffer.length) {
59
+ const count = readSync(fd, buffer, bytes, buffer.length - bytes, from + bytes);
60
+ if (!count) break;
61
+ bytes += count;
62
+ }
63
+ const lines = buffer.subarray(0, bytes).toString("utf8").split("\n");
64
+ // The preceding byte distinguishes a cut inside a row from a row beginning exactly at the window.
65
+ if (start) lines.shift();
66
+ const out: NativeFactsTranscript = { factIds: [] };
67
+ for (const line of lines) {
68
+ let row: unknown; try { row = JSON.parse(line); } catch { continue; }
69
+ if (!record(row) || row.status !== "DONE") continue;
70
+ if (row.source === "SYSTEM_SDK" && row.type === "EPHEMERAL_MESSAGE" && typeof row.content === "string") {
71
+ for (const match of row.content.matchAll(/agent-hub facts \[([a-zA-Z0-9-]{1,128})\]/g)) out.factIds.push(match[1]!);
72
+ }
73
+ if (row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index)
74
+ && typeof row.content === "string" && row.content.trim().length > 0 && (!Array.isArray(row.tool_calls) || row.tool_calls.length === 0)) out.completion = { id: createHash("sha256").update(`${session}:${row.step_index}:${line}`).digest("hex"), index: row.step_index as number };
75
+ }
76
+ if (out.completion || start === 0 || window === cap) return out;
77
+ }
78
+ } catch { return empty; } finally { if (fd !== undefined) closeSync(fd); }
79
+ }
80
+
81
+ export const AGY_FACTS_HOOK: NativeFactsPort = { decode: decodeAgyHook, encode: encodeAgyHook, read: readAgyTranscript };
@@ -368,6 +368,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
368
368
  const res = await hub.request({ t: "send", body, to, reply_to });
369
369
  if (!res.ok) return text(`not sent: ${res.error}`);
370
370
  if (res.recorded) return text("recorded only ([FYI]): it is on the hub console and log, and no peer spent a turn on it");
371
+ if (res.dropped === "hop") return text(res.notice);
371
372
  const sent = `sent to: ${res.targets.join(", ") || "(no other peers attached)"}`;
372
373
  return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
373
374
  }
@@ -67,18 +67,28 @@ function configFiles(cwd: string, env: NodeJS.ProcessEnv): string[] {
67
67
  // Bounded host credential roots, not a universal private-home classifier. Never inspect their file contents.
68
68
  const HOST_CREDENTIAL_PATHS = [".config", ".ssh", ".aws", ".azure", ".kube", ".docker", ".gnupg", ".codex", ".claude", ".kimi", ".kimi-code", ".gemini", ".git-credentials", ".vault-token", "Library/Keychains", ".local/share/keyrings", ".password-store"] as const;
69
69
  const HOST_CREDENTIAL_ENV = ["XDG_CONFIG_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "KIMI_CODE_HOME", "KIMI_HOME", "GEMINI_CLI_HOME", "GNUPGHOME", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DOCKER_CONFIG", "GOOGLE_APPLICATION_CREDENTIALS"] as const;
70
- function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): string[] {
70
+ /** A credential root that cannot be resolved. `label` is a path class (a standard name or an env var name), never a path or contents. */
71
+ class CredentialRootError extends Error {
72
+ constructor(readonly label: string, readonly kind: "dangling" | "unreadable" | "unsupported") { super("credential root refused"); }
73
+ }
74
+ interface CredentialRoot { path: string; label: string }
75
+ function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): CredentialRoot[] {
71
76
  const home = resolve(cwd, env.HOME || homedir());
72
- const homes = [...new Set([home, credentialCanonicalPath(home)])];
73
- const roots: string[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => join(base, entry)));
74
- const configured = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [env[key]!] : []);
75
- if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean));
77
+ const homes = [...new Set([home, canonicalLabelled(home, "HOME")])];
78
+ const roots: CredentialRoot[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => ({ path: join(base, entry), label: `~/${entry}` })));
79
+ const configured: CredentialRoot[] = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [{ path: env[key]!, label: `$${key}` }] : []);
80
+ if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean).map(path => ({ path, label: "$KUBECONFIG" })));
76
81
  if (configured.length > 64) throw new Error("credential root bound exceeded");
77
- for (const entry of configured) {
78
- if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid credential root");
79
- roots.push(entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry));
82
+ for (const { path: entry, label } of configured) {
83
+ if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new CredentialRootError(label, "unsupported");
84
+ roots.push({ path: entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry), label });
80
85
  }
81
- return [...new Set(roots)];
86
+ const seen = new Set<string>();
87
+ return roots.filter(root => !seen.has(root.path) && seen.add(root.path));
88
+ }
89
+ function canonicalLabelled(lexical: string, label: string): string {
90
+ try { return credentialCanonicalPath(lexical); }
91
+ catch (error) { throw new CredentialRootError(label, (error as NodeJS.ErrnoException).code === "ENOENT" ? "dangling" : "unreadable"); }
82
92
  }
83
93
  /** Resolve absent descendants through the deepest existing ancestor; dangling links refuse admission. */
84
94
  function credentialCanonicalPath(lexical: string): string {
@@ -93,14 +103,13 @@ function credentialCanonicalPath(lexical: string): string {
93
103
  }
94
104
  return join(realPath(ancestor), relative(ancestor, lexical));
95
105
  }
96
- function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly string[], cwd: string): void {
97
- for (const lexical of roots) {
106
+ function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly CredentialRoot[], cwd: string): void {
107
+ for (const { path: lexical, label } of roots) {
98
108
  // Native profile keys with glob metacharacters cannot represent these literal host locations safely.
99
- if (/[?*\[\]{}]/.test(lexical)) throw new Error("unverified credential root syntax");
100
- const targets = new Set([lexical, credentialCanonicalPath(lexical)]);
109
+ if (/[?*\[\]{}]/.test(lexical)) throw new CredentialRootError(label, "unsupported");
110
+ const targets = new Set([lexical, canonicalLabelled(lexical, label)]);
101
111
  for (const target of targets) {
102
- if (/[?*\[\]{}]/.test(target)) throw new Error("unverified credential root syntax");
103
- if (inside(cwd, target)) throw new Error("credential root overlaps checkout");
112
+ if (/[?*\[\]{}]/.test(target) || inside(cwd, target)) throw new CredentialRootError(label, "unsupported");
104
113
  filesystem[target] = "deny"; filesystem[`${target.replace(/\/$/, "")}/**`] = "deny";
105
114
  }
106
115
  }
@@ -261,8 +270,13 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
261
270
  cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
262
271
  isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
263
272
  evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
264
- } catch {
273
+ } catch (error) {
265
274
  if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
275
+ // Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
276
+ if (error instanceof CredentialRootError) {
277
+ const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
278
+ return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
279
+ }
266
280
  return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
267
281
  }
268
282
  }
@@ -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];
@@ -100,7 +100,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
100
100
  grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
101
101
  session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
102
102
  turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
103
- cli: { command: "codex", retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
103
+ cli: { command: "codex", launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
104
104
  },
105
105
  {
106
106
  reviewerVariant: KIMI_REVIEWER_VARIANT,
@@ -116,7 +116,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
116
116
  unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
117
117
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
118
118
  turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
119
- cli: { command: "kimi", stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
119
+ cli: { command: "kimi", launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
120
120
  },
121
121
  {
122
122
  api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
@@ -128,7 +128,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
128
128
  session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
129
129
  turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
130
130
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
131
- cli: { command: "pi", previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
131
+ cli: { command: "pi", launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
132
132
  },
133
133
  {
134
134
  api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
@@ -140,18 +140,18 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
140
140
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
141
141
  turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
142
142
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
143
- cli: { command: "local", startWords: () => ["local"] }, display: LOCAL_DISPLAY,
143
+ cli: { command: "local", launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
144
144
  },
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: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("jcode", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
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", startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
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,3 +1,4 @@
1
+ import { retainedLaunchWarning } from "./native-cli-retained.ts";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
@@ -14,6 +15,8 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
14
15
  if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
15
16
  const hub = await ctx.connect();
16
17
  try {
18
+ const snapshot = (await hub.request({ t: "status" })).status;
19
+ const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
17
20
  const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
18
21
  if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
19
22
  } finally { hub.close(); }
@@ -1,3 +1,4 @@
1
+ import { retainedLaunchWarning } from "./native-cli-retained.ts";
1
2
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
2
3
 
3
4
  export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
@@ -19,6 +20,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
19
20
  // only when a future recovery receipt supplies independently verified exact-instance execution-root evidence.
20
21
  if (recovering && !ctx.instance && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
21
22
  const hub = await connect();
23
+ const snapshot = (await hub.request({ t: "status" })).status;
24
+ const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
22
25
  const res = await hub.request({ t: "start", peer, args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
23
26
  hub.close();
24
27
  if (!res.ok) fail(res.error);
@@ -28,7 +31,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
28
31
  }
29
32
  const control = readControl(stateDir);
30
33
  if ((!recovering || ctx.instance) && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
31
- const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
34
+ const runningCodexBin = res.retainedLaunchConfig?.codex_bin;
35
+ const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
32
36
  if (launch0.warning) console.error(launch0.warning);
33
37
  exec(launch.cmd, launch.args, "codex");
34
38
  }
@@ -1,28 +1,99 @@
1
+ import { retainedLaunchWarning } from "./native-cli-retained.ts";
1
2
  import { resolve } from "node:path";
2
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";
3
6
  import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
4
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
+
5
33
  export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
6
34
  const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
7
- const { launcherPreview, qwenSessionId, configureJcodeMcp } = ctx.helpers;
8
- if (args.includes("--print-command") || args.includes("--dry-run")) {
9
- try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
10
- catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
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
+ }
11
43
  }
12
- const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
13
- if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
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>]");
14
46
  if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
15
- const hub = await connect();
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); };
16
61
  try {
17
62
  const snapshot = (await hub.request({ t: "status" })).status;
18
63
  const rostered = rosterInStatus(snapshot, "jcode");
19
64
  if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
20
65
  if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
21
- // The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
66
+ const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
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
+ }
22
83
  const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
23
84
  if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
24
- const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
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;
25
87
  if (!reply.ok) fail(reply.error);
26
- console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
27
- } finally { hub.close(); }
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(); }
28
99
  }