@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.
- package/CHANGELOG.md +19 -0
- package/README.md +12 -6
- package/docs/agent-notes/adapters.md +19 -8
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +6 -3
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +3 -3
- package/docs/operations.md +45 -17
- package/docs/quickstart.md +19 -5
- package/docs/security.md +7 -3
- package/docs/smoke.md +22 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- 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 +22 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1759 -437
- package/src/adapters/acp.ts +20 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/codex-review-isolation.ts +30 -16
- package/src/adapters/definitions/builtin-metadata.ts +8 -8
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -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-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-daemon-policy.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +4 -3
- package/src/adapters/jcode.ts +58 -1
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +29 -1
- package/src/adapters/registry.ts +16 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +83 -20
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +5 -4
- package/src/cli/output.ts +5 -0
- package/src/cli/preview.ts +2 -2
- package/src/hub/bus.ts +17 -6
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +182 -24
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/peers.ts +2 -2
- package/src/hub/reviewer-pool.ts +12 -6
- package/src/hub/routing.ts +3 -0
- package/src/hub/start-mode.ts +1 -1
- package/src/hub/tasks.ts +7 -0
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +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");
|
|
@@ -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
|
-
|
|
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,
|
|
73
|
-
const roots:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
100
|
-
const targets = new Set([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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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"]
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|