@staix/agent-hub 0.12.32 → 0.12.34
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 +16 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -5
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +48 -14
- package/docs/quickstart.md +5 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +20 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +33 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/reset.ts +13 -1
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-presentation.ts +11 -1
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +107 -146
- package/src/cli/upgrade.ts +10 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/bus.ts +133 -14
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +244 -241
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/restart.ts +12 -1
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +14 -11
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
3
|
+
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
4
|
+
|
|
5
|
+
export function previewPi(ctx: NativePreviewContext) {
|
|
6
|
+
const { args, stateDir, cliDir, config } = ctx;
|
|
7
|
+
const { relayModelIds, buildPiLaunch, startModeOf } = ctx.helpers;
|
|
8
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
9
|
+
let envNames = state.envNames;
|
|
10
|
+
let launch: Launch;
|
|
11
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
|
|
12
|
+
const backend = value("--backend") ?? config.pi.backend;
|
|
13
|
+
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
|
|
14
|
+
if (args.includes("--replace-session") && (!value("--session-file") || value("--session-id"))) throw new Error("--replace-session requires --session-file, without --session-id");
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
if (args[i] === "--replace-session") continue;
|
|
17
|
+
if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!) || !value(args[i]!)) throw new Error("unknown Pi option");
|
|
18
|
+
i++;
|
|
19
|
+
}
|
|
20
|
+
if (args.includes("--replace-session")) unresolved.push({ field: "session replacement", reason: "requires a person, a validated managed file and an offline settled owner at runtime; preview changes nothing" });
|
|
21
|
+
const models = relayModelIds({ enableHubAuto: true, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast } });
|
|
22
|
+
const pi = buildPiLaunch({
|
|
23
|
+
stateDir, cmd: nativeCommand(config.pi.cmd, ["pi"]), mode: mode as "headless" | "tui", backend: backend as "auto" | "dgx" | "mlx",
|
|
24
|
+
...(value("--model") ? { model: REDACTED } : {}),
|
|
25
|
+
...(value("--session-id") ? { sessionId: REDACTED } : {}),
|
|
26
|
+
...(value("--session-file") ? { sessionFile: REDACTED } : {}),
|
|
27
|
+
relay: { url: UNRESOLVED, token: UNRESOLVED, models: models.map(id => ({ id })) },
|
|
28
|
+
tools: [], preamble: REDACTED, maxSteps: config.pi.max_steps,
|
|
29
|
+
}, {}, resolve(join(cliDir, "../pi/extension.ts")), UNRESOLVED, UNRESOLVED);
|
|
30
|
+
launch = pi;
|
|
31
|
+
envNames = Object.keys(pi.env).concat(["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"].filter(key => process.env[key] !== undefined));
|
|
32
|
+
if (mode === "tui") envNames.push("AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR");
|
|
33
|
+
unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
|
|
34
|
+
if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
|
|
35
|
+
return finishPreview(ctx, launch, envNames, unresolved, undefined);
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
3
|
+
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
4
|
+
|
|
5
|
+
export function previewQwen(ctx: NativePreviewContext) {
|
|
6
|
+
const { tool, args, cwd, stateDir, cliDir, config } = ctx;
|
|
7
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
8
|
+
let envNames = state.envNames;
|
|
9
|
+
let launch: Launch;
|
|
10
|
+
const mode = value("--mode") ?? "headless", model = value("--model");
|
|
11
|
+
const adapter = ctx.definition;
|
|
12
|
+
if (!(adapter.start.forms as readonly string[]).includes(mode)) throw new Error("Qwen TUI is not offered before verification");
|
|
13
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
|
|
14
|
+
const sessionId = value("--session-id");
|
|
15
|
+
if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
|
|
16
|
+
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
17
|
+
launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
|
|
18
|
+
userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
|
|
19
|
+
unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
|
|
20
|
+
|
|
21
|
+
return finishPreview(ctx, launch, envNames, unresolved, undefined);
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
|
+
import type { NativePreviewBuilder } from "./native-cli-preview-common.ts";
|
|
3
|
+
import { previewClaude } from "./native-cli-preview-claude.ts";
|
|
4
|
+
import { previewCodex } from "./native-cli-preview-codex.ts";
|
|
5
|
+
import { previewKimi } from "./native-cli-preview-kimi.ts";
|
|
6
|
+
import { previewPi } from "./native-cli-preview-pi.ts";
|
|
7
|
+
import { previewQwen } from "./native-cli-preview-qwen.ts";
|
|
8
|
+
import { previewJcode } from "./native-cli-preview-jcode.ts";
|
|
9
|
+
|
|
10
|
+
// Private implementations of the fixed builtin set; neither config nor an external definition can add callbacks.
|
|
11
|
+
const previews: Readonly<Record<string, NativePreviewBuilder>> = Object.freeze({
|
|
12
|
+
claude: previewClaude, codex: previewCodex, kimi: previewKimi, pi: previewPi, qwen: previewQwen, jcode: previewJcode,
|
|
13
|
+
});
|
|
14
|
+
export function builtinPreview(definition: AdapterDefinition): NativePreviewBuilder | undefined {
|
|
15
|
+
return Object.hasOwn(previews, definition.kind) ? previews[definition.kind] : undefined;
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { HubConfig } from "../../hub/daemon.ts";
|
|
3
|
+
import type { Routing } from "../../hub/routing.ts";
|
|
4
|
+
|
|
5
|
+
/** Preserve the legacy config block order and shallow native merges. */
|
|
6
|
+
export const NATIVE_CONFIG_BLOCKS = ["local", "pi"] as const;
|
|
7
|
+
export function nativeConfigurationDefaults(ctx: { home: string; env: Readonly<Record<string, string | undefined>>; networkAllow: string[] }) {
|
|
8
|
+
return {
|
|
9
|
+
commands: { kimi_cmd: ["kimi", "acp"], qwen_cmd: ["qwen", "--acp"], jcode_cmd: ["jcode"], jcode_home: ctx.env.JCODE_HOME ?? join(ctx.home, ".jcode"), jcode_provider_profile: "" },
|
|
10
|
+
proxy: { codex_bin: "codex" },
|
|
11
|
+
rpc: { pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto" as const, dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 } },
|
|
12
|
+
sandbox: { local: { deny: [] as string[], bash_network: false, network_allow: ctx.networkAllow, max_steps: 30, read_allow: [] as string[] } },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function mergeNativeConfiguration(file: Record<string, any>, defaults: HubConfig): Pick<HubConfig, "local" | "pi"> {
|
|
16
|
+
return { local: { ...defaults.local, ...file.local }, pi: { ...defaults.pi, ...file.pi } };
|
|
17
|
+
}
|
|
18
|
+
/** The published RPC relay stays bound while other routing leaves are reloaded. */
|
|
19
|
+
export function retainNativeRouting(running: Routing): Pick<Routing, "pi"> { return { pi: running.pi }; }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NativeConfigCheck, NativeConfigValidators } from "../registry-types.ts";
|
|
2
|
+
import type { SettingDef } from "../../hub/settings.ts";
|
|
3
|
+
|
|
4
|
+
/** Native config uses the host's checked schema vocabulary, retaining its shape/path introspection. */
|
|
5
|
+
export function piConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
|
|
6
|
+
return { pi: v.block({ enabled: v.bool, auto_start: v.bool, cmd: v.argv, backend: v.words(["auto", "dgx", "mlx"]), dgx_coding: v.text, dgx_fast: v.text, max_steps: v.number(1, 1_000_000, true) }) };
|
|
7
|
+
}
|
|
8
|
+
export function localConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
|
|
9
|
+
return { local: v.block({ deny: v.strings, bash_network: (value, key) => { if (value !== "direct") v.bool(value, key); }, network_allow: v.strings, max_steps: v.number(1, 1_000_000, true), read_allow: v.strings }) };
|
|
10
|
+
}
|
|
11
|
+
export function piRoutingSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
|
|
12
|
+
return { pi: v.block({ dgx_max_context_tokens: v.number(), mlx_max_context_tokens: v.number(), efficient_wait_ms: v.number(0, 119999) }) };
|
|
13
|
+
}
|
|
14
|
+
export function localRoutingSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
|
|
15
|
+
return { local: v.block({ route: v.text, fixed_model: v.text }) };
|
|
16
|
+
}
|
|
17
|
+
export const PI_SETTINGS: readonly SettingDef[] = [
|
|
18
|
+
{ key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "next use", risk: "raises", floor: false, path: ["pi", "auto_start"] },
|
|
19
|
+
];
|
|
20
|
+
export const PI_RESTART_REASONS: Readonly<Record<string, string>> = {
|
|
21
|
+
"pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
|
|
22
|
+
"pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
|
|
23
|
+
"routing.pi": "Pi relay context and model publication are bound to the running relay",
|
|
24
|
+
};
|
|
25
|
+
export const LOCAL_RESTART_REASONS: Readonly<Record<string, string>> = {
|
|
26
|
+
"local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
|
|
27
|
+
"local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
|
|
28
|
+
};
|
|
29
|
+
export function piEffectiveConfig(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void {
|
|
30
|
+
const object = (value: unknown, key: string): Record<string, unknown> => {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail(key, "must be an object");
|
|
32
|
+
return value as Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContextReading } from "../../hub/context-window.ts";
|
|
2
|
+
|
|
3
|
+
export const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
4
|
+
export const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
|
|
5
|
+
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
6
|
+
const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
|
|
7
|
+
export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
8
|
+
const c = object(value);
|
|
9
|
+
const current = object(c.current_usage);
|
|
10
|
+
const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
|
|
11
|
+
const window = positive(c.context_window_size) ? c.context_window_size : null;
|
|
12
|
+
const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
|
|
13
|
+
const tokens = nonnegative(sum) ? sum : null;
|
|
14
|
+
const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
|
|
15
|
+
return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
|
|
16
|
+
}
|
|
17
|
+
export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
18
|
+
const c = object(value), last = object(c.last);
|
|
19
|
+
const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
|
|
20
|
+
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
21
|
+
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
22
|
+
}
|
|
23
|
+
/** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
|
|
24
|
+
export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
|
|
25
|
+
const window = positive(usage.size) ? usage.size : null;
|
|
26
|
+
const tokens = nonnegative(usage.used) ? usage.used : null;
|
|
27
|
+
// If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
|
|
28
|
+
return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The coordinator supplies validated occupancy; malformed public input remains unknown. */
|
|
32
|
+
export function acpContextValue(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
33
|
+
const raw = object(value);
|
|
34
|
+
return acpContext({ used: raw.used as number, size: raw.size as number }, sessionId, measuredAt);
|
|
35
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Kimi puts its optional model switch before the configured ACP subcommand. */
|
|
2
|
+
export function kimiAcpCommand(command: readonly string[], model?: string): string[] {
|
|
3
|
+
const [cmd, ...args] = command;
|
|
4
|
+
return [cmd!, ...(model ? ["--model", model, ...args] : args)];
|
|
5
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StartForm, StartRequest, VersionContext } from "../registry-types.ts";
|
|
2
|
+
|
|
3
|
+
/** Native serialization and person-facing guidance only; the daemon retains every ownership decision. */
|
|
4
|
+
export const claudeRecoveryLaunch = (instance: string): Record<string, unknown> => ({ kind: "claude-channel", peer: instance });
|
|
5
|
+
export const kimiRecoveryLaunch = (instance: string): Record<string, unknown> => ({ kind: "kimi-tui", peer: instance, mode: "tui" });
|
|
6
|
+
export const claudeRetainedTerminalNotice = (instance: string): string =>
|
|
7
|
+
`Stop ${instance}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`;
|
|
8
|
+
export const piRetainedTerminalNotice = (instance: string, form: StartForm | undefined): string | undefined =>
|
|
9
|
+
form === "tui" ? claudeRetainedTerminalNotice(instance) : undefined;
|
|
10
|
+
export const codexRetainedTerminalNotice = (_instance: string, _form: StartForm | undefined, resumeCommand: string): string =>
|
|
11
|
+
`Check codex's existing terminal and stop any remaining work. Its app-server and proxy stay available, but Codex 0.146.0 exits its TUI on disconnect and does not reconnect automatically; after ahub roster add codex, resume the previous session with ${resumeCommand}.`;
|
|
12
|
+
export const codexRosterReturnNotice = (resumeCommand: string): string =>
|
|
13
|
+
`Codex 0.146.0 needs a manual return: resume the previous native session with ${resumeCommand}.`;
|
|
14
|
+
export const jcodeStartPreflight = (request: StartRequest): string | undefined =>
|
|
15
|
+
request.form !== undefined && request.form !== "headless" ? "Jcode native TUI driving is unverified; use headless" : undefined;
|
|
16
|
+
export const claudeObservedVersion = (context: VersionContext): string | undefined => context.observedVersion;
|
|
17
|
+
export const codexObservedVersion = (context: VersionContext): string | undefined =>
|
|
18
|
+
(context.peer as { version?: string } | undefined)?.version ?? context.observedVersion;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Existing native identity swatches; hub and unknown-peer styling stays in the UI. */
|
|
2
|
+
export const CLAUDE_DISPLAY = { palette: { fg: "#6495ed", bg: "transparent", tone: "peerClaude", ansi: "\x1b[94m" } } as const;
|
|
3
|
+
export const CODEX_DISPLAY = { palette: { fg: "#22b8cf", bg: "transparent", tone: "peerCodex", ansi: "\x1b[96m" } } as const;
|
|
4
|
+
export const KIMI_DISPLAY = { palette: { fg: "#bc73ca", bg: "transparent", tone: "taskKeyword", ansi: "\x1b[35m" } } as const;
|
|
5
|
+
export const PI_DISPLAY = { palette: { fg: "#c79936", bg: "transparent", tone: "attention", ansi: "\x1b[33m" } } as const;
|
|
6
|
+
export const LOCAL_DISPLAY = { palette: { fg: "#4caa77", bg: "transparent", tone: "success", ansi: "\x1b[32m" } } as const;
|
|
7
|
+
// These two kinds deliberately retain the existing 'other' fallback, not new public swatches.
|
|
8
|
+
export const QWEN_DISPLAY = { palette: { fg: "#8a8f99", bg: "transparent", tone: "muted", ansi: "\x1b[90m" } } as const;
|
|
9
|
+
export const JCODE_DISPLAY = { palette: { fg: "#8a8f99", bg: "transparent", tone: "muted", ansi: "\x1b[90m" } } as const;
|
|
10
|
+
|
|
11
|
+
/** Kinds with a public named swatch; keep their exact literal types in compatibility projections. */
|
|
12
|
+
export const NATIVE_DISPLAYS = { claude: CLAUDE_DISPLAY, codex: CODEX_DISPLAY, kimi: KIMI_DISPLAY, pi: PI_DISPLAY, local: LOCAL_DISPLAY } as const;
|
|
13
|
+
|
|
14
|
+
const STREAM_TONES = { claude: "peerClaude", codex: "peerCodex" } as const;
|
|
15
|
+
/** Stream headers historically color only these speakers, independently of their board palette. */
|
|
16
|
+
export function nativeStreamTone(peer: string): (typeof STREAM_TONES)[keyof typeof STREAM_TONES] | undefined {
|
|
17
|
+
return Object.hasOwn(STREAM_TONES, peer) ? STREAM_TONES[peer as keyof typeof STREAM_TONES] : undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CreateContext, DaemonEngine, DaemonPorts } from "../registry-types.ts";
|
|
2
|
+
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
3
|
+
|
|
4
|
+
// Load only fixed builtin modules after the pre-effect guards. A top-level LocalPeer import would
|
|
5
|
+
// cycle through hub-tools and the registry while its definitions are still being initialized.
|
|
6
|
+
export function createCodex(ctx: CreateContext): PeerAdapter {
|
|
7
|
+
if (!("codex" in ctx.daemon)) throw new Error("Codex requires its Codex transport options");
|
|
8
|
+
if (ctx.permission === "read-only") throw new Error("Codex has no verified read-only permission mapping");
|
|
9
|
+
const { CodexPeer } = require("../codex-appserver.ts") as typeof import("../codex-appserver.ts");
|
|
10
|
+
return new CodexPeer(ctx.instance, ctx.daemon.codex);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createKimi(ctx: CreateContext): PeerAdapter {
|
|
14
|
+
if (!("acp" in ctx.daemon)) throw new Error("Kimi requires its ACP transport options");
|
|
15
|
+
if (ctx.permission === "read-only") throw new Error("Kimi has no verified read-only permission mapping");
|
|
16
|
+
const { AcpPeer } = require("../acp.ts") as typeof import("../acp.ts");
|
|
17
|
+
return new AcpPeer(ctx.instance, ctx.daemon.acp);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createJcode(ctx: CreateContext): PeerAdapter {
|
|
21
|
+
if (!("jcode" in ctx.daemon)) throw new Error("Jcode requires its Jcode transport options");
|
|
22
|
+
if (ctx.permission === "read-only") throw new Error("Jcode has no verified read-only permission mapping");
|
|
23
|
+
const { JcodePeer } = require("../jcode.ts") as typeof import("../jcode.ts");
|
|
24
|
+
return new JcodePeer(ctx.daemon.jcode);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPi(ctx: CreateContext): PeerAdapter {
|
|
28
|
+
if (!("pi" in ctx.daemon)) throw new Error("Pi requires its Pi transport options");
|
|
29
|
+
if (ctx.permission === "read-only") throw new Error("Pi has no verified read-only permission mapping");
|
|
30
|
+
const { PiPeer } = require("../pi.ts") as typeof import("../pi.ts");
|
|
31
|
+
return new PiPeer(ctx.instance, ctx.daemon.pi);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createLocal(ctx: CreateContext): PeerAdapter {
|
|
35
|
+
if (!("local" in ctx.daemon)) throw new Error("Local worker requires its local transport options");
|
|
36
|
+
if (ctx.permission === "read-only") throw new Error("Local worker has no verified read-only permission mapping");
|
|
37
|
+
const { LocalPeer } = require("../local-worker.ts") as typeof import("../local-worker.ts");
|
|
38
|
+
return new LocalPeer(ctx.instance, ctx.daemon.local);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Preserve existing transport option types and object identity; selection stays host-owned. */
|
|
42
|
+
export const DAEMON_PORTS = {
|
|
43
|
+
"codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
|
|
44
|
+
acp: (options: Extract<DaemonPorts, { acp: unknown }>["acp"]) => ({ acp: options }),
|
|
45
|
+
"owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
|
|
46
|
+
"pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
|
|
47
|
+
"local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
|
|
48
|
+
} satisfies Record<DaemonEngine, unknown>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
3
|
+
import type { Addon, AddonName } from "../../hub/tooling.ts";
|
|
4
|
+
|
|
5
|
+
/** Native crash recovery keeps each adapter's exact restart and guidance contract. */
|
|
6
|
+
interface NativeCrashPlan { resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }
|
|
7
|
+
const str = (value: unknown): string | undefined => typeof value === "string" && value ? value : undefined;
|
|
8
|
+
|
|
9
|
+
export function claudeCrashPlan(_meta: Record<string, unknown>): NativeCrashPlan {
|
|
10
|
+
return { how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function codexCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|
|
14
|
+
return { how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function jcodeCrashPlan(_meta: Record<string, unknown>): NativeCrashPlan {
|
|
18
|
+
return { how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function kimiCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|
|
22
|
+
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
23
|
+
const model = str(launch.model);
|
|
24
|
+
const sessionId = str(meta.sessionId);
|
|
25
|
+
if (!sessionId) return { how: "kimi: no session id was recorded; start it again with ahub kimi" };
|
|
26
|
+
return { resume: { sessionId, ...(model ? { model } : {}) }, how: `kimi: session ${sessionId} can be loaded again (ACP session/load)` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function piCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|
|
30
|
+
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
31
|
+
const model = str(launch.model);
|
|
32
|
+
// `fresh`: what a new headless session keeps of the recorded one (pi.auto_start's fallback, #66).
|
|
33
|
+
const fresh: Record<string, string> = {};
|
|
34
|
+
for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
|
|
35
|
+
const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
|
|
36
|
+
// #269: a terminal Pi stays marked as one without a session file too, so nothing headless is started in its place.
|
|
37
|
+
if (!sessionFile) return { fresh, ...(str(launch.mode) === "tui" ? { tui: true } : {}), how: "pi: no session file was recorded; start it again with ahub pi" };
|
|
38
|
+
// A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
|
|
39
|
+
if (str(launch.mode) === "tui") return { fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
|
|
40
|
+
const args: Record<string, string> = { sessionFile };
|
|
41
|
+
for (const k of ["mode", "backend", "model"] as const) if (str(launch[k])) args[k] = str(launch[k])!;
|
|
42
|
+
return { fresh, resume: args, how: `pi: its session file can be resumed (${sessionFile})` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function localCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|
|
46
|
+
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
47
|
+
const model = str(launch.model);
|
|
48
|
+
// `model` is also recorded as the route's fallback, and a model given at start pins it: pass one or the other.
|
|
49
|
+
const route = str(launch.route);
|
|
50
|
+
const args: Record<string, string> = route ? { route } : model ? { model } : {};
|
|
51
|
+
return { resume: args, how: "local: starts again without its history (the worker keeps none across a hub stop)" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Plugin names (before the `@marketplace`) that stand for an add-on. */
|
|
55
|
+
// Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
|
|
56
|
+
const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
|
|
57
|
+
/** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
|
|
58
|
+
const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
|
|
59
|
+
/** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
|
|
60
|
+
const text = (file: string): string | undefined => {
|
|
61
|
+
let fd: number | undefined;
|
|
62
|
+
try {
|
|
63
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
64
|
+
const st = fstatSync(fd);
|
|
65
|
+
if (!st.isFile() || st.size > 1024 * 1024) return undefined;
|
|
66
|
+
return readFileSync(fd, "utf8");
|
|
67
|
+
} catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
|
|
68
|
+
};
|
|
69
|
+
const json = (file: string): Record<string, unknown> | undefined => {
|
|
70
|
+
try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
71
|
+
};
|
|
72
|
+
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
73
|
+
|
|
74
|
+
function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
75
|
+
const enabled = new Map<string, boolean>();
|
|
76
|
+
for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
|
|
77
|
+
const map = json(file)?.enabledPlugins;
|
|
78
|
+
if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
|
|
79
|
+
}
|
|
80
|
+
const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
|
|
81
|
+
const out: Addon[] = [];
|
|
82
|
+
for (const [key, on] of enabled) {
|
|
83
|
+
if (!on) continue;
|
|
84
|
+
const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
|
|
85
|
+
const entries = Array.isArray(installed[key]) ? installed[key]! : [];
|
|
86
|
+
// An install for another project is not this one's.
|
|
87
|
+
const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
|
|
88
|
+
if (name && entry) out.push({ name, version: version(entry.version) });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function codexPlugins(root: string): Addon[] {
|
|
94
|
+
const config = text(join(root, "config.toml"));
|
|
95
|
+
if (config === undefined) return [];
|
|
96
|
+
const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
|
|
97
|
+
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
98
|
+
// unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
|
|
99
|
+
for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
|
|
100
|
+
for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
|
|
101
|
+
const out: Addon[] = [];
|
|
102
|
+
for (const [plugin, market] of found) {
|
|
103
|
+
const name = PLUGINS.get(plugin);
|
|
104
|
+
if (!name) continue;
|
|
105
|
+
let versions: string[] = [];
|
|
106
|
+
try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
|
|
107
|
+
out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PluginInventoryContext { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }
|
|
113
|
+
export const claudePluginInventory = (ctx: PluginInventoryContext): Addon[] => claudePlugins(ctx.env.CLAUDE_CONFIG_DIR?.trim() || join(ctx.home, ".claude"), ctx.cwd);
|
|
114
|
+
export const codexPluginInventory = (ctx: PluginInventoryContext): Addon[] => codexPlugins(ctx.env.CODEX_HOME?.trim() || join(ctx.home, ".codex"));
|
|
115
|
+
|
|
116
|
+
/** A persisted native identity decoder; the coordinator retains envelope, path and operation checks. */
|
|
117
|
+
export function parsePiContinuity(value: unknown): { sessionId: string; sessionFile?: string; backend?: "auto" | "dgx" | "mlx"; model?: string } | undefined {
|
|
118
|
+
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
119
|
+
const text = (v: unknown, max: number): v is string => typeof v === "string" && v.length > 0 && v.length <= max && !/[\x00-\x1f\x7f]/.test(v);
|
|
120
|
+
const id = (v: unknown): v is string => text(v, 256) && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(v);
|
|
121
|
+
if (!record(value) || Object.keys(value).some(key => !["sessionId", "sessionFile", "backend", "model"].includes(key)) || !id(value.sessionId)
|
|
122
|
+
|| value.sessionFile !== undefined && !absoluteContinuityPath(value.sessionFile) || value.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(value.backend))
|
|
123
|
+
|| value.model !== undefined && !text(value.model, 4096)) return undefined;
|
|
124
|
+
return { sessionId: value.sessionId, ...(value.sessionFile === undefined ? {} : { sessionFile: value.sessionFile as string }), ...(value.backend === undefined ? {} : { backend: value.backend as "auto" | "dgx" | "mlx" }), ...(value.model === undefined ? {} : { model: value.model as string }) };
|
|
125
|
+
}
|
|
126
|
+
function absoluteContinuityPath(value: unknown): value is string {
|
|
127
|
+
return typeof value === "string" && value.length > 0 && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value) && isAbsolute(value) && normalize(value) === value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const codexToolSemantic = (name: string, _command?: string): "edit" | undefined => name.toLowerCase() === "filechange" ? "edit" : undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { UsageWindow } from "../../hub/budget.ts";
|
|
2
|
+
|
|
3
|
+
/** Codex `rateLimits` snapshot (account/rateLimits/read, account/rateLimits/updated) -> windows. */
|
|
4
|
+
export function codexWindows(rateLimits: any): UsageWindow[] {
|
|
5
|
+
const out: UsageWindow[] = [];
|
|
6
|
+
// The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
|
|
7
|
+
// readings are keyed by window id, so two windows must never both come out as "5h".
|
|
8
|
+
for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
|
|
9
|
+
if (!w || typeof w.usedPercent !== "number") continue;
|
|
10
|
+
const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
|
|
11
|
+
const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
|
|
12
|
+
if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
|
|
13
|
+
out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
|
|
14
|
+
}
|
|
15
|
+
if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Claude Code status line `rate_limits` -> windows. */
|
|
20
|
+
export function claudeWindows(rateLimits: any): UsageWindow[] {
|
|
21
|
+
const out: UsageWindow[] = [];
|
|
22
|
+
for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
|
|
23
|
+
const w = rateLimits?.[key];
|
|
24
|
+
if (!w || typeof w.used_percentage !== "number") continue;
|
|
25
|
+
const resets = Number(w.resets_at);
|
|
26
|
+
out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RecoveryPeer } from "../../cli/upgrade.ts";
|
|
2
|
+
import type { TerminalBinding } from "../../cli/terminal-recovery.ts";
|
|
3
|
+
import type { RecoveryContinuityBookmark } from "../../hub/recovery-continuity.ts";
|
|
4
|
+
|
|
5
|
+
/** Native continuity payload only; archival evidence and persistence remain with the recovery coordinator. */
|
|
6
|
+
export function piRecoveryContinuity(peer: RecoveryPeer, binding?: TerminalBinding): Pick<RecoveryContinuityBookmark, "pi"> {
|
|
7
|
+
const sessionId = binding?.sessionId ?? peer.sessionId;
|
|
8
|
+
if (!sessionId) throw new Error("Pi recorded session unavailable; archive continuity bookmark was not written");
|
|
9
|
+
const backend = binding?.backend ?? peer.args?.backend;
|
|
10
|
+
return { pi: { sessionId, sessionFile: binding?.sessionFile ?? peer.sessionFile ?? peer.args?.sessionFile,
|
|
11
|
+
...(backend === "auto" || backend === "dgx" || backend === "mlx" ? { backend } : {}), model: binding?.model ?? peer.args?.model } };
|
|
12
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { readEvents } from "../../hub/events.ts";
|
|
4
|
+
import { checkedSessionPath, recordedFileState } from "../../cli/recovery-session-file.ts";
|
|
5
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
6
|
+
import type { TerminalBinding } from "../../cli/terminal-recovery.ts";
|
|
7
|
+
import type { RecoveryPeer } from "../../cli/upgrade.ts";
|
|
8
|
+
|
|
9
|
+
type SessionPolicy = Partial<AdapterDefinition["session"]>;
|
|
10
|
+
const rolloutState = (binding: TerminalBinding): boolean | "unknown" => {
|
|
11
|
+
try { return readdirSync(checkedSessionPath(binding, "rollout"), { recursive: true }).some(name => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)); }
|
|
12
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? false : "unknown"; }
|
|
13
|
+
};
|
|
14
|
+
const transcriptState = (binding: TerminalBinding): boolean | "unknown" => {
|
|
15
|
+
const state = recordedFileState(checkedSessionPath(binding, "transcript"));
|
|
16
|
+
return state === "unknown" ? "unknown" : state === "found";
|
|
17
|
+
};
|
|
18
|
+
/** Preserve the driver's explicit evidence-kind dispatch, including historical typed receipt reads. */
|
|
19
|
+
export function nativeRecordedSessionState(binding: TerminalBinding, kind: "rollout" | "transcript"): "found" | "missing" | "unknown" {
|
|
20
|
+
const state = kind === "rollout" ? rolloutState(binding) : transcriptState(binding);
|
|
21
|
+
return state === "unknown" ? "unknown" : state ? "found" : "missing";
|
|
22
|
+
}
|
|
23
|
+
const startupArgs = (peer: RecoveryPeer): Record<string, string> => Object.fromEntries(["mode", "backend", "model", "route"]
|
|
24
|
+
.flatMap(key => typeof peer.args?.[key] === "string" ? [[key, peer.args[key]!]] : []));
|
|
25
|
+
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
26
|
+
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
27
|
+
|
|
28
|
+
export const CODEX_RECOVERY_SESSION: SessionPolicy = {
|
|
29
|
+
resumeArgv: (binding, fresh) => ["codex", ...(fresh ? [] : ["resume", binding.sessionId])],
|
|
30
|
+
transcriptPath: binding => checkedSessionPath(binding, "rollout"), transcriptExists: rolloutState,
|
|
31
|
+
personFresh: true, missingEvidence: "rollout",
|
|
32
|
+
zeroTurn: ({ stateDir, binding, phase }) => {
|
|
33
|
+
if (phase !== "plan") return false;
|
|
34
|
+
let current: string | undefined, started = false, turned = false;
|
|
35
|
+
for (const e of readEvents(join(stateDir, "events.jsonl"))) {
|
|
36
|
+
if (e.type === "native_thread" && e.peer === "codex") {
|
|
37
|
+
current = e.thread;
|
|
38
|
+
if (e.thread === binding.sessionId && e.fresh) started = true;
|
|
39
|
+
} else if (e.type === "turn_start" && e.peer === "codex" && current === binding.sessionId) turned = true;
|
|
40
|
+
}
|
|
41
|
+
return started && !turned;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export const CLAUDE_RECOVERY_SESSION: SessionPolicy = {
|
|
45
|
+
resumeArgv: (binding, fresh) => ["claude", ...(fresh ? [] : ["--resume", binding.sessionId])],
|
|
46
|
+
transcriptPath: binding => checkedSessionPath(binding, "transcript"), transcriptExists: transcriptState,
|
|
47
|
+
zeroTurn: ({ binding, phase }) => phase !== "plan" && transcriptState(binding) !== true,
|
|
48
|
+
personFresh: true, missingEvidence: "transcript",
|
|
49
|
+
};
|
|
50
|
+
export const PI_RECOVERY_SESSION: SessionPolicy = {
|
|
51
|
+
resumeArgv: binding => ["pi", "--mode", "tui", ...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
|
|
52
|
+
...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])],
|
|
53
|
+
personFresh: false,
|
|
54
|
+
restoreArgs: peer => {
|
|
55
|
+
const args = startupArgs(peer);
|
|
56
|
+
if (typeof peer.args?.mode !== "string") args.mode = "headless";
|
|
57
|
+
const file = peer.sessionFile ?? peer.args?.sessionFile;
|
|
58
|
+
if (typeof file === "string") args["session-file"] = file;
|
|
59
|
+
else if (peer.sessionId) args["session-id"] = peer.sessionId;
|
|
60
|
+
return args;
|
|
61
|
+
},
|
|
62
|
+
crashIdentity: row => {
|
|
63
|
+
const value = object(row), args = object(value.args), session = object(value.session);
|
|
64
|
+
const sessionId = [value.sessionId, value.agentSessionId, value.resumeSessionId, session.id, session.sessionId].find(text);
|
|
65
|
+
const sessionFile = [value.sessionFile, value.sessionPath, args.sessionFile, session.file, session.path].find(text);
|
|
66
|
+
return { ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
export const KIMI_RECOVERY_SESSION: SessionPolicy = {
|
|
70
|
+
legacyHeadlessArgv: { file: "src/hub/start-mode.ts", exportName: "START_MODE_PEERS", argv: ["--mode", "headless"] },
|
|
71
|
+
restoreArgs: peer => { const args = startupArgs(peer); delete args.mode; return args; },
|
|
72
|
+
recoveryRefusal: (peer, phase = "plan") => peer.args?.mode === "tui"
|
|
73
|
+
? phase === "restore" ? "Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan"
|
|
74
|
+
: "kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)" : undefined,
|
|
75
|
+
};
|
|
76
|
+
export const LOCAL_RECOVERY_SESSION: SessionPolicy = { restoreArgs: startupArgs };
|
|
77
|
+
export const JCODE_RECOVERY_SESSION: SessionPolicy = {
|
|
78
|
+
legacyTargetSupport: { file: "src/hub/peer-catalog.ts", exportName: "OPTIONAL_PEERS", refusal: "target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged" },
|
|
79
|
+
recoveryRefusal: (_peer, phase = "plan") => phase === "restore" ? undefined : "jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects",
|
|
80
|
+
restoreArgs: peer => ({ ...startupArgs(peer), ...(peer.sessionId ? { "session-id": peer.sessionId } : {}), ...(peer.args?.providerProfile ? { "provider-profile": peer.args.providerProfile } : {}) }),
|
|
81
|
+
};
|
|
82
|
+
export const QWEN_RECOVERY_SESSION: SessionPolicy = {
|
|
83
|
+
legacyTargetSupport: { file: "src/hub/peer-catalog.ts", exportName: "OPTIONAL_PEERS", refusal: "target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged" },
|
|
84
|
+
restoreArgs: peer => ({ ...startupArgs(peer), ...(peer.sessionId ? { "session-id": peer.sessionId } : {}) }),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Fixed legacy native probes: callers retain their existing source-text capability checks. */
|
|
88
|
+
export const SHARED_CHANNEL_VERSION_ARGV = ["claude", "--version"] as const;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { QWEN_DISPLAY } from "./native-display.ts";
|
|
2
|
+
import { QWEN_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
1
3
|
import { AcpPeer } from "../acp.ts";
|
|
2
4
|
import type { AdapterDefinition } from "../registry-types.ts";
|
|
3
5
|
import { buildQwenLaunch, qwenNativeIdentity, qwenSessionId, QWEN_MODE_IDS } from "./qwen-native.ts";
|
|
4
|
-
import { shellQuote } from "../../cli/
|
|
6
|
+
import { shellQuote } from "../../cli/shell-quote.ts";
|
|
5
7
|
|
|
6
8
|
/** Production Qwen policy. Legacy AcpPeer callers opt in separately through qwen025. */
|
|
7
9
|
export const QWEN_DEFINITION: AdapterDefinition = {
|
|
@@ -15,6 +17,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
15
17
|
defaultOnRoster: false,
|
|
16
18
|
trust: { onPrem: false, hubSandboxed: false },
|
|
17
19
|
start: {
|
|
20
|
+
daemonPort: "acp",
|
|
18
21
|
forms: ["headless"],
|
|
19
22
|
defaultForm: "headless",
|
|
20
23
|
ownsTerminal: () => false,
|
|
@@ -34,7 +37,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
34
37
|
if (context.permission === "read-only") throw new Error("Qwen has no verified read-only permission mapping");
|
|
35
38
|
return new AcpPeer(context.instance, { ...context.daemon.acp, qwen025: true, permissionMode: context.permission, mcpServers: context.mcpServers });
|
|
36
39
|
},
|
|
37
|
-
conductorStartArgs: args => args.length ? new Error("
|
|
40
|
+
conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub qwen") : {},
|
|
38
41
|
},
|
|
39
42
|
identity: { mcp: "acp-session" },
|
|
40
43
|
permission: {
|
|
@@ -55,8 +58,9 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
55
58
|
const sessionId = peer.recoveryMetadata?.().sessionId;
|
|
56
59
|
return typeof sessionId === "string" ? sessionId : undefined;
|
|
57
60
|
},
|
|
61
|
+
...QWEN_RECOVERY_SESSION,
|
|
58
62
|
launchKind: "acp",
|
|
59
|
-
|
|
63
|
+
resumeById: true,
|
|
60
64
|
crashIdentity: row => {
|
|
61
65
|
const sessionId = row && typeof row === "object" ? (row as { sessionId?: unknown }).sessionId : undefined;
|
|
62
66
|
return qwenSessionId(sessionId) ? { sessionId } : {};
|
|
@@ -66,6 +70,6 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
66
70
|
budget: { account: "qwen" },
|
|
67
71
|
capabilities: { watch: true },
|
|
68
72
|
cli: { command: "qwen", startWords: () => ["qwen"] },
|
|
69
|
-
display:
|
|
73
|
+
display: QWEN_DISPLAY,
|
|
70
74
|
hooks: { agentIdentity: init => { qwenNativeIdentity("qwen", true, init); }, recallPlatform: "qwen" },
|
|
71
75
|
};
|