@staix/agent-hub 0.20.0 → 0.22.0
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 +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/adapters/acp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { activityText, updateActivity, type Activity, type ActivityKind } from "../hub/activity.ts";
|
|
2
2
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId, type PeerState } from "../hub/envelope.ts";
|
|
5
6
|
import { acpQuotaRefusal, type ACPQuotaRefusal } from "./acp-quota.ts";
|
|
6
7
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
@@ -49,6 +50,23 @@ export interface ACPUsageDiagnostic {
|
|
|
49
50
|
total?: number;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One prompt's own token counters from its result's `usage` (Jcode 0.99.1: `inputTokens`, `outputTokens`, optional `cachedReadTokens` and `cachedWriteTokens`; a result with several provider
|
|
55
|
+
* calls carries their sum). Per turn, not a session total, and the provider's `totalTokens` is deliberately not read: Jcode adds the cached read on top of an input that already contains it for
|
|
56
|
+
* OpenAI-style providers, so that total double counts. Input and output must both be non-negative safe integers, else the whole record is unknown; an optional bucket that is present but
|
|
57
|
+
* malformed is dropped alone (unknown, never zero). Counters only: nothing else of the payload escapes.
|
|
58
|
+
*/
|
|
59
|
+
export interface ACPTurnUsage { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }
|
|
60
|
+
export function acpTurnUsage(result: unknown): ACPTurnUsage | undefined {
|
|
61
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
|
|
62
|
+
const usage = (result as Record<string, unknown>).usage;
|
|
63
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
|
|
64
|
+
const u = usage as Record<string, unknown>;
|
|
65
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
66
|
+
if (!count(u.inputTokens) || !count(u.outputTokens)) return undefined;
|
|
67
|
+
return { inputTokens: u.inputTokens, outputTokens: u.outputTokens, ...(count(u.cachedReadTokens) ? { cacheReadTokens: u.cachedReadTokens } : {}), ...(count(u.cachedWriteTokens) ? { cacheWriteTokens: u.cachedWriteTokens } : {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/** Counter-only projection: no source payload, text, tools or metadata escapes. */
|
|
53
71
|
export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["source"] = "usage_update"): ACPUsageDiagnostic {
|
|
54
72
|
const unknown: ACPUsageDiagnostic = { source, availability: "unsupported", shape: "none" };
|
|
@@ -66,7 +84,8 @@ export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["so
|
|
|
66
84
|
const a = f[input!], b = f[output!];
|
|
67
85
|
return valid(a) && valid(b) && Number.isFinite(a + b) ? { source, availability: "known", shape: "input-output", total: a + b } : { source, availability: "invalid", shape: "input-output" };
|
|
68
86
|
}
|
|
69
|
-
|
|
87
|
+
// Either half of the pair names an occupancy update: one with `size` but no `used` (or the reverse) is a malformed reading, which must invalidate the previous value, not vanish as unrelated.
|
|
88
|
+
if (f.used !== undefined || f.size !== undefined) {
|
|
70
89
|
if (!valid(f.used) || !valid(f.size) || !Number.isSafeInteger(f.used) || !Number.isSafeInteger(f.size) || f.size <= 0) return { source, availability: "invalid", shape: "context-used" };
|
|
71
90
|
// Qwen 0.24.7 derives this from collectContextData: occupancy is not cumulative consumption.
|
|
72
91
|
return { source, availability: "unsupported", shape: "context-used", contextUsed: f.used, contextCapacity: f.size };
|
|
@@ -110,6 +129,8 @@ export interface AcpOptions {
|
|
|
110
129
|
preamble?: string;
|
|
111
130
|
/** The session's running token total from `usage_update` (checked totalTokens or input/output pair; `used` context occupancy is diagnostic only). Cumulative, not a delta. */
|
|
112
131
|
onTokens?: (sessionTotal: number, sessionId: string) => void;
|
|
132
|
+
/** One finished prompt's own counters (see `acpTurnUsage`), once per prompt that reported them; never a session total, so it never feeds `onTokens`. The sessionId is the adapter's own. */
|
|
133
|
+
onTurnUsage?: (usage: ACPTurnUsage, sessionId: string) => void;
|
|
113
134
|
/** The prompt rejected or ended without normal completion, even when it streamed partial text.
|
|
114
135
|
* Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
|
|
115
136
|
onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
|
|
@@ -138,6 +159,45 @@ const HANDSHAKE_MS = 30_000;
|
|
|
138
159
|
const TOOL_INPUT_CAP = 64;
|
|
139
160
|
const TOOL_CEILING_MS = 30 * 60_000;
|
|
140
161
|
|
|
162
|
+
export interface ManagedAcpLaunch {
|
|
163
|
+
readonly generation: string;
|
|
164
|
+
readonly cwd: string;
|
|
165
|
+
readonly sessionId: string;
|
|
166
|
+
}
|
|
167
|
+
interface ManagedAcpLaunchRecord {
|
|
168
|
+
readonly id: PeerId;
|
|
169
|
+
readonly options: AcpOptions;
|
|
170
|
+
readonly proc: ChildProcessWithoutNullStreams;
|
|
171
|
+
readonly proof: AcpOwnedGroup;
|
|
172
|
+
readonly receipt: Readonly<ManagedAcpLaunch>;
|
|
173
|
+
}
|
|
174
|
+
// Host evidence is deliberately outside runtime properties: copied metadata, private-field names
|
|
175
|
+
// and an overridden start/recovery getter cannot manufacture a verified managed spawn.
|
|
176
|
+
const managedLaunches = new WeakMap<object, ManagedAcpLaunchRecord>();
|
|
177
|
+
function currentManagedLaunch(peer: unknown, expectedOptions: AcpOptions): ManagedAcpLaunchRecord | undefined {
|
|
178
|
+
if (!peer || (typeof peer !== "object" && typeof peer !== "function")) return undefined;
|
|
179
|
+
const record = managedLaunches.get(peer);
|
|
180
|
+
if (!record) return undefined;
|
|
181
|
+
const id = Object.getOwnPropertyDescriptor(peer, "id");
|
|
182
|
+
if (id?.value !== record.id || record.options !== expectedOptions || record.proc.exitCode !== null || record.proc.signalCode !== null) return undefined;
|
|
183
|
+
return record;
|
|
184
|
+
}
|
|
185
|
+
/** Exact host options identity is required; a factory that copies/replaces those options has no host binding. */
|
|
186
|
+
export function managedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Readonly<ManagedAcpLaunch> | undefined {
|
|
187
|
+
return currentManagedLaunch(peer, expectedOptions)?.receipt;
|
|
188
|
+
}
|
|
189
|
+
/** Recheck the actual captured owned group after an await, then require the same current launch. */
|
|
190
|
+
export async function verifyManagedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Promise<boolean> {
|
|
191
|
+
const record = currentManagedLaunch(peer, expectedOptions);
|
|
192
|
+
if (!record) return false;
|
|
193
|
+
try { await record.proof.verify(); }
|
|
194
|
+
catch {
|
|
195
|
+
if (peer && typeof peer === "object" && managedLaunches.get(peer) === record) managedLaunches.delete(peer);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return currentManagedLaunch(peer, expectedOptions) === record;
|
|
199
|
+
}
|
|
200
|
+
|
|
141
201
|
/**
|
|
142
202
|
* The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
|
|
143
203
|
* `mcp__<server>__<tool>` only when <server> is one this session was configured with. A title that
|
|
@@ -239,7 +299,7 @@ export class AcpPeer extends BasePeer {
|
|
|
239
299
|
private readonly permissionWaits = new Set<AbortController>();
|
|
240
300
|
|
|
241
301
|
protected override setState(next: PeerState): void {
|
|
242
|
-
if (next === "offline") { this.clearActivity(); this.cancelPermissions(); }
|
|
302
|
+
if (next === "offline") { managedLaunches.delete(this); this.clearActivity(); this.cancelPermissions(); }
|
|
243
303
|
super.setState(next);
|
|
244
304
|
}
|
|
245
305
|
|
|
@@ -266,11 +326,14 @@ export class AcpPeer extends BasePeer {
|
|
|
266
326
|
}
|
|
267
327
|
|
|
268
328
|
async start(): Promise<void> {
|
|
329
|
+
managedLaunches.delete(this);
|
|
269
330
|
if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
|
|
270
331
|
this.stopping = false;
|
|
332
|
+
const launchedId = this.id;
|
|
333
|
+
const launchedCwd = this.opts.cwd;
|
|
271
334
|
const [bin, ...args] = this.opts.cmd;
|
|
272
335
|
// Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
|
|
273
|
-
const proc = spawn(bin!, args, { cwd:
|
|
336
|
+
const proc = spawn(bin!, args, { cwd: launchedCwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
274
337
|
this.proc = proc;
|
|
275
338
|
trackGroup(proc);
|
|
276
339
|
proc.on("error", (e) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`spawn failed: ${e.message}`); });
|
|
@@ -310,11 +373,13 @@ export class AcpPeer extends BasePeer {
|
|
|
310
373
|
setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
|
|
311
374
|
});
|
|
312
375
|
const session = await Promise.race([handshake(), timeout]);
|
|
376
|
+
const nativeSession = session?.sessionId;
|
|
313
377
|
if (this.nativePolicy !== undefined && (typeof session?.sessionId !== "string" || !session.sessionId.trim()
|
|
314
378
|
|| Buffer.byteLength(session.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(session.sessionId))) {
|
|
315
379
|
throw new Error("Configured ACP did not provide a valid native session identity");
|
|
316
380
|
}
|
|
317
381
|
this.sessionId = session.sessionId;
|
|
382
|
+
this.onDoing?.("reset");
|
|
318
383
|
const modes: unknown = session.modes?.availableModes;
|
|
319
384
|
this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
|
|
320
385
|
// A recovered session can retain a previous runtime opt-in. An advertised non-default mode
|
|
@@ -328,8 +393,13 @@ export class AcpPeer extends BasePeer {
|
|
|
328
393
|
if (!owned || owned.proc !== proc || this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
|
|
329
394
|
await owned.proof.verify();
|
|
330
395
|
if (this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
|
|
396
|
+
if (typeof nativeSession !== "string" || !nativeSession.trim() || Buffer.byteLength(nativeSession) > 4096 || /[\p{Cc}\p{Cf}]/u.test(nativeSession)) throw new Error("Configured ACP did not provide a valid native session identity");
|
|
397
|
+
if (this.id !== launchedId || this.opts.cwd !== launchedCwd || this.sessionId !== nativeSession) throw new Error("Configured ACP peer identity changed during startup");
|
|
398
|
+
managedLaunches.set(this, { id: launchedId, options: this.opts, proc, proof: owned.proof,
|
|
399
|
+
receipt: Object.freeze({ generation: randomUUID(), cwd: launchedCwd, sessionId: nativeSession }) });
|
|
331
400
|
}
|
|
332
401
|
} catch (e) {
|
|
402
|
+
managedLaunches.delete(this);
|
|
333
403
|
this.setState("offline");
|
|
334
404
|
if (this.proc === proc) {
|
|
335
405
|
if (this.opts.requireOwnedGroup) {
|
|
@@ -390,6 +460,7 @@ export class AcpPeer extends BasePeer {
|
|
|
390
460
|
}
|
|
391
461
|
|
|
392
462
|
async stop(reason?: string): Promise<void> {
|
|
463
|
+
managedLaunches.delete(this);
|
|
393
464
|
if (this.activityOpen) this.endActivity("stopped", true);
|
|
394
465
|
this.stopping = true;
|
|
395
466
|
this.cancelPermissions();
|
|
@@ -453,6 +524,8 @@ export class AcpPeer extends BasePeer {
|
|
|
453
524
|
if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
|
|
454
525
|
this.endActivity(typeof result?.stopReason === "string" ? result.stopReason : "unknown", result?.stopReason === "cancelled");
|
|
455
526
|
this.observeUsage(result, "prompt_result");
|
|
527
|
+
const turnUsage = acpTurnUsage(result);
|
|
528
|
+
if (turnUsage) try { this.opts.onTurnUsage?.(turnUsage, this.sessionId); } catch { /* a counter sink never costs a turn */ }
|
|
456
529
|
this.primed = true;
|
|
457
530
|
const body = this.chunks.join("").trim();
|
|
458
531
|
// What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
|
|
@@ -597,6 +670,7 @@ export class AcpPeer extends BasePeer {
|
|
|
597
670
|
}
|
|
598
671
|
|
|
599
672
|
private down(reason: string): void {
|
|
673
|
+
managedLaunches.delete(this);
|
|
600
674
|
this.clearCancelWait();
|
|
601
675
|
if (this.nativePolicy !== undefined || this.opts.requireOwnedGroup) reason = "Configured ACP transport stopped";
|
|
602
676
|
this.opts.log?.(`[${this.id}] ${reason}`);
|
|
@@ -657,6 +731,8 @@ export class AcpPeer extends BasePeer {
|
|
|
657
731
|
for (const map of [this.toolInputs, this.toolText, this.toolTitles]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
|
|
658
732
|
}
|
|
659
733
|
else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
|
|
734
|
+
// #878: the plan's in-progress entry, for the hub's DOING column only.
|
|
735
|
+
else if (u?.sessionUpdate === "plan") this.onDoing?.("todo", Array.isArray(u.entries) ? u.entries.find((e: any) => e?.status === "in_progress")?.content : undefined);
|
|
660
736
|
} else if (msg.method === "session/request_permission") {
|
|
661
737
|
if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
|
|
662
738
|
if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
|
|
@@ -48,6 +48,10 @@ export class WsPeer extends BasePeer {
|
|
|
48
48
|
this.claimed = sock;
|
|
49
49
|
this.claimedPullOnly = pullOnly;
|
|
50
50
|
}
|
|
51
|
+
/** Host transport observation, distinct from the peer's activity state. */
|
|
52
|
+
get attached(): boolean { return !!this.sock && this.sock.readyState === WebSocket.OPEN && !this.claiming; }
|
|
53
|
+
get managedLaunchId(): string | undefined { return this.attached ? this.sock?.data.launchId : undefined; }
|
|
54
|
+
|
|
51
55
|
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
52
56
|
get channelHeld(): boolean {
|
|
53
57
|
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
@@ -17,6 +17,7 @@ import { VERSION } from "../version.ts";
|
|
|
17
17
|
import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
18
18
|
import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
|
|
19
19
|
import { renderHeldHint } from "../hub/delivery-hints.ts";
|
|
20
|
+
import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH } from "../hub/native-permission-request.ts";
|
|
20
21
|
|
|
21
22
|
// A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
|
|
22
23
|
// its MCP server must not silently move to another hub (including while offline).
|
|
@@ -26,6 +27,7 @@ const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
|
|
|
26
27
|
/** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
|
|
27
28
|
const toolsMode = process.env.AGENTHUB_MODE === "tools";
|
|
28
29
|
const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
|
|
30
|
+
let invalidPermissionAttempts = 0;
|
|
29
31
|
/**
|
|
30
32
|
* #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
|
|
31
33
|
* id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
|
|
@@ -212,7 +214,7 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
|
|
|
212
214
|
async function push(envs: Envelope[], deliveryId?: string, generation?: string): Promise<void> {
|
|
213
215
|
const parent = replyParent(envs); // reply_to on this id keeps the hop count honest
|
|
214
216
|
const single = envs.length === 1;
|
|
215
|
-
const content = single ? parent.body : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
|
|
217
|
+
const content = single ? sanitize(parent.body) : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
|
|
216
218
|
const meta = {
|
|
217
219
|
source: single ? parent.from : "hub-digest",
|
|
218
220
|
...(single ? {} : { sources: [...new Set(envs.map((e) => e.from))].join(",") }),
|
|
@@ -396,16 +398,21 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
396
398
|
if (name === "hub_permission_request") {
|
|
397
399
|
const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
|
|
398
400
|
if (!permissionTool || !hub) return deny("hub permission unavailable");
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
if (!acceptsNativePermissionRequest(args)) {
|
|
402
|
+
// Diagnostic validation only: its response can never authorize this invalid native request.
|
|
403
|
+
if (++invalidPermissionAttempts <= NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) {
|
|
404
|
+
const probe = buildNativePermissionProbe(args);
|
|
405
|
+
if (probe) try { await hub.request(probe, 1000); } catch { /* Old or unavailable hubs still deny locally. */ }
|
|
406
|
+
}
|
|
407
|
+
return deny("invalid native permission request");
|
|
408
|
+
}
|
|
402
409
|
try {
|
|
403
410
|
// Only this fixed client capability permits host wrapper grants; older clients keep person approval.
|
|
404
411
|
const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
|
|
405
412
|
if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
|
|
406
413
|
if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
|
|
407
414
|
const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
|
|
408
|
-
if (res.wrapperProtocol === 1 && (args
|
|
415
|
+
if (res.wrapperProtocol === 1 && (args?.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
|
|
409
416
|
return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
|
|
410
417
|
} catch { return deny("hub permission unavailable"); }
|
|
411
418
|
}
|
|
@@ -458,7 +465,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
458
465
|
}
|
|
459
466
|
|
|
460
467
|
// Inbox results still show fresh counts, without telling a held or refused reader to retry in a loop.
|
|
461
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox"));
|
|
468
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox" && req.params.name !== "hub_source_read"));
|
|
462
469
|
|
|
463
470
|
await server.connect(new StdioServerTransport());
|
|
464
471
|
// The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
|
|
@@ -11,9 +11,21 @@ import { sessionSettings } from "../cli/launch.ts";
|
|
|
11
11
|
import { assessVersion, COMPAT } from "./compat.ts";
|
|
12
12
|
import { OwnedPrintChild, PrintChildCleanupError, type OwnedPrintChildOptions } from "./claude-print-child.ts";
|
|
13
13
|
import { ClaudeTaskSessions } from "./claude-task-sessions.ts";
|
|
14
|
+
import { formatClaudeFailure, type ClaudeFailureTrace } from "../hub/claude-failure-stage.ts";
|
|
14
15
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
15
16
|
import type { ReviewerPrintUsage } from "./reviewer-print.ts";
|
|
16
17
|
|
|
18
|
+
const provenVersions = new WeakMap<() => boolean, string>();
|
|
19
|
+
function provenVersion(binding: () => boolean): string | undefined { return provenVersions.get(binding); }
|
|
20
|
+
function sealFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.seal(); } catch { /* diagnostics never affect owned cleanup */ } }
|
|
21
|
+
function discardFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.discard(); } catch { /* diagnostics never affect owned cleanup */ } }
|
|
22
|
+
function readFailureTrace(trace: ClaudeFailureTrace | undefined): unknown {
|
|
23
|
+
if (!trace) return undefined;
|
|
24
|
+
// Every returned trace is sealed by onLeaderExit before authority is revoked.
|
|
25
|
+
try { return trace.take(); }
|
|
26
|
+
catch { discardFailureTrace(trace); return undefined; }
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export interface ClaudeImplementerOptions {
|
|
18
30
|
cwd: string;
|
|
19
31
|
stateDir: string;
|
|
@@ -38,6 +50,12 @@ export interface ClaudeImplementerOptions {
|
|
|
38
50
|
notify?(message: string): void;
|
|
39
51
|
/** Test/embedding-only proof port; project configuration cannot select it. */
|
|
40
52
|
prove?(): Promise<() => boolean>;
|
|
53
|
+
/** Host-observed failure evidence bound to one admitted native launch. */
|
|
54
|
+
failureTrace?(launch: Readonly<OwnedToolsLaunch>, nativeVersion?: string): ClaudeFailureTrace;
|
|
55
|
+
/** Resolve current privacy at final rendering; unknown or a throw is private. */
|
|
56
|
+
failurePrivate?(task?: number): boolean;
|
|
57
|
+
/** Optional host observer of the fixed, already privacy-screened failure message. */
|
|
58
|
+
failureLog?(message: string): void;
|
|
41
59
|
}
|
|
42
60
|
export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
|
|
43
61
|
export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
|
|
@@ -65,7 +83,9 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
|
|
|
65
83
|
const observed = COMPAT["claude-implementer"].parse(version), verdict = assessVersion("claude-implementer", observed);
|
|
66
84
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
67
85
|
if (fingerprint() !== binding) throw new Error("Claude implementer binary changed during proof");
|
|
68
|
-
|
|
86
|
+
const proof = () => { try { return fingerprint() === binding; } catch { return false; } };
|
|
87
|
+
provenVersions.set(proof, verdict.version);
|
|
88
|
+
return proof;
|
|
69
89
|
} finally { clearTimeout(timer); await child.stop(); rmSync(home, { recursive: true, force: true }); }
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -84,6 +104,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
84
104
|
private command: readonly string[] | undefined;
|
|
85
105
|
private deliveryId: string | undefined;
|
|
86
106
|
private inputReleased = false;
|
|
107
|
+
private failureTrace: ClaudeFailureTrace | undefined;
|
|
87
108
|
private sessions: ClaudeTaskSessions;
|
|
88
109
|
private stopping: Promise<void> | undefined;
|
|
89
110
|
private stoppingTarget: "idle" | "offline" | undefined;
|
|
@@ -126,6 +147,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
126
147
|
}
|
|
127
148
|
this.stoppingTarget = next;
|
|
128
149
|
++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
|
|
150
|
+
discardFailureTrace(this.failureTrace); this.failureTrace = undefined;
|
|
129
151
|
this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
|
|
130
152
|
if (this.owned || this.running) this.setState("busy");
|
|
131
153
|
const work: Promise<void> = Promise.resolve().then(async () => {
|
|
@@ -166,8 +188,10 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
166
188
|
}
|
|
167
189
|
private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
|
|
168
190
|
const current = () => generation === this.generation && !this.abort?.signal.aborted;
|
|
191
|
+
let failureReason: string | undefined;
|
|
169
192
|
try {
|
|
170
193
|
for (let group of groups) {
|
|
194
|
+
failureReason = undefined;
|
|
171
195
|
if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
|
|
172
196
|
group = { ...group, envs: group.envs.filter(env => !env.private && this.options.admit(env)) };
|
|
173
197
|
if (!group.envs.length) continue;
|
|
@@ -181,6 +205,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
181
205
|
}
|
|
182
206
|
}
|
|
183
207
|
const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
|
|
208
|
+
let launchFailureTrace: ClaudeFailureTrace | undefined;
|
|
184
209
|
this.cleanup = () => rmSync(directory, { recursive: true, force: true });
|
|
185
210
|
writeFileSync(settings, sessionSettings(undefined, { script: this.options.factsHook, stateDir: this.options.stateDir, purpose: "permission" }), { mode: 0o600, flag: "wx" });
|
|
186
211
|
const env = { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: this.id, AGENTHUB_STATE_DIR: this.options.stateDir, AGENTHUB_PROJECT_DIR: this.options.projectRoot,
|
|
@@ -197,11 +222,15 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
197
222
|
void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
|
|
198
223
|
return;
|
|
199
224
|
}
|
|
200
|
-
this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
|
|
201
225
|
this.childAbort = new AbortController();
|
|
202
|
-
|
|
226
|
+
const launch: OwnedToolsLaunch = Object.freeze({ permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) });
|
|
227
|
+
this.active = launch;
|
|
228
|
+
try { launchFailureTrace = this.options.failureTrace?.(Object.freeze({ ...launch }), provenVersion(this.binding!)); }
|
|
229
|
+
catch { launchFailureTrace = undefined; }
|
|
230
|
+
this.failureTrace = launchFailureTrace;
|
|
231
|
+
this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
|
|
203
232
|
},
|
|
204
|
-
onLeaderExit: () => { this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
|
|
233
|
+
onLeaderExit: () => { sealFailureTrace(launchFailureTrace); this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
|
|
205
234
|
onActivity: () => this.touch(),
|
|
206
235
|
onLimit: () => { void this.cancel("Claude implementer output limit exceeded", "idle").catch(() => {}); },
|
|
207
236
|
onFrame: event => {
|
|
@@ -220,12 +249,21 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
220
249
|
catch (error) {
|
|
221
250
|
// spawn ENOENT has no owned group. A started child with failed group cleanup must keep its handle.
|
|
222
251
|
if (child.process.pid === undefined) this.owned = undefined;
|
|
252
|
+
if (error instanceof PrintChildCleanupError) failureReason = this.failureReason(group.task, launchFailureTrace);
|
|
253
|
+
else discardFailureTrace(launchFailureTrace);
|
|
254
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
223
255
|
throw error;
|
|
224
256
|
}
|
|
225
257
|
this.active = undefined; this.owned = undefined;
|
|
226
258
|
this.cleanup?.(); this.cleanup = undefined;
|
|
227
|
-
if (!current()) return;
|
|
228
|
-
if (code !== 0 || !result || failed)
|
|
259
|
+
if (!current()) { discardFailureTrace(launchFailureTrace); if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined; return; }
|
|
260
|
+
if (code !== 0 || !result || failed) {
|
|
261
|
+
failureReason = this.failureReason(group.task, launchFailureTrace) ?? "Claude implementer turn failed";
|
|
262
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
263
|
+
throw new Error("Claude implementer turn failed");
|
|
264
|
+
}
|
|
265
|
+
discardFailureTrace(launchFailureTrace);
|
|
266
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
229
267
|
}
|
|
230
268
|
if (current()) {
|
|
231
269
|
if (this.running === running) this.running = undefined;
|
|
@@ -234,7 +272,11 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
234
272
|
} catch (error) {
|
|
235
273
|
this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined;
|
|
236
274
|
if (current()) {
|
|
237
|
-
|
|
275
|
+
const reason = error instanceof PrintChildCleanupError
|
|
276
|
+
? failureReason === "Claude implementer turn failed (details withheld)" ? "Claude implementer cleanup incomplete; details withheld"
|
|
277
|
+
: failureReason ? `Claude implementer cleanup incomplete; ${failureReason}` : "Claude implementer cleanup incomplete"
|
|
278
|
+
: failureReason ?? "Claude implementer turn failed";
|
|
279
|
+
this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
|
|
238
280
|
if (this.owned) this.setState("busy"); else {
|
|
239
281
|
this.cleanup?.(); this.cleanup = undefined;
|
|
240
282
|
if (this.running === running) this.running = undefined;
|
|
@@ -243,4 +285,15 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
243
285
|
}
|
|
244
286
|
}
|
|
245
287
|
}
|
|
288
|
+
|
|
289
|
+
private failureReason(task: number | undefined, trace: ClaudeFailureTrace | undefined): string | undefined {
|
|
290
|
+
if (!trace) return undefined;
|
|
291
|
+
const evidence = readFailureTrace(trace);
|
|
292
|
+
if (!evidence) return undefined;
|
|
293
|
+
let privateTurn = true;
|
|
294
|
+
try { privateTurn = this.options.failurePrivate?.(task) !== false; } catch { /* unknown privacy is private */ }
|
|
295
|
+
const message = formatClaudeFailure(evidence, privateTurn);
|
|
296
|
+
try { this.options.failureLog?.(message); } catch { /* host observation never changes settlement */ }
|
|
297
|
+
return message;
|
|
298
|
+
}
|
|
246
299
|
}
|
|
@@ -9,6 +9,15 @@ import type { ContextReader } from "./registry-types.ts";
|
|
|
9
9
|
import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
|
|
10
10
|
|
|
11
11
|
const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
|
|
12
|
+
const PLANNER_SANDBOX_REFUSAL = "Codex planner thread requires a confirmed read-only sandbox";
|
|
13
|
+
|
|
14
|
+
/** Codex 0.164.0 SandboxPolicy readOnly variant; an unreadable or widened policy is not proof. */
|
|
15
|
+
function confirmsReadOnlySandbox(value: unknown): boolean {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
17
|
+
const policy = value as Record<string, unknown>;
|
|
18
|
+
return policy.type === "readOnly" && Object.keys(policy).every(key => key === "type" || key === "networkAccess")
|
|
19
|
+
&& (!Object.hasOwn(policy, "networkAccess") || typeof policy.networkAccess === "boolean");
|
|
20
|
+
}
|
|
12
21
|
|
|
13
22
|
/** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
|
|
14
23
|
const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
|
|
@@ -16,6 +25,8 @@ export interface CodexOptions {
|
|
|
16
25
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
17
26
|
proxyPort: number;
|
|
18
27
|
permissionMode?: PermissionMode;
|
|
28
|
+
/** Live host planner ceiling on initial thread start/resume; the attached person may change later turn policy. */
|
|
29
|
+
threadSandbox?: "read-only" | (() => "read-only" | "unavailable" | undefined);
|
|
19
30
|
/** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
|
|
20
31
|
actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
|
|
21
32
|
onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
|
|
@@ -71,6 +82,10 @@ interface Link {
|
|
|
71
82
|
backlog: string[]; // TUI frames that arrived before the upstream socket opened
|
|
72
83
|
approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
|
|
73
84
|
tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
|
|
85
|
+
/** Correlated native initial policy, retained even before this instance gains the planner role. */
|
|
86
|
+
readOnlyThread?: string;
|
|
87
|
+
/** An ordinary client reused an outstanding id; this link cannot attest a planner thread until reattach. */
|
|
88
|
+
ambiguousThreadRpc?: true;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
91
|
const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
@@ -233,7 +248,7 @@ export class CodexPeer extends BasePeer {
|
|
|
233
248
|
/** Resolves when app-server accepts the turn. Only called while idle, i.e. a thread exists and no turn runs. */
|
|
234
249
|
deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
235
250
|
const link = this.link;
|
|
236
|
-
if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN) {
|
|
251
|
+
if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN || !this.threadSandboxReady(link)) {
|
|
237
252
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not injectable` });
|
|
238
253
|
return Promise.reject(new Error(`${this.id} is not injectable`));
|
|
239
254
|
}
|
|
@@ -285,6 +300,10 @@ export class CodexPeer extends BasePeer {
|
|
|
285
300
|
reject(e);
|
|
286
301
|
},
|
|
287
302
|
});
|
|
303
|
+
if (!this.threadSandboxReady(link)) {
|
|
304
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
305
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
306
|
+
}
|
|
288
307
|
link.up.send(
|
|
289
308
|
JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
|
|
290
309
|
);
|
|
@@ -299,7 +318,7 @@ export class CodexPeer extends BasePeer {
|
|
|
299
318
|
steer(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
300
319
|
const link = this.link;
|
|
301
320
|
const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
|
|
302
|
-
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) {
|
|
321
|
+
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) {
|
|
303
322
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} has no steerable turn` });
|
|
304
323
|
return Promise.reject(new Error(`${this.id} has no steerable turn`));
|
|
305
324
|
}
|
|
@@ -346,6 +365,10 @@ export class CodexPeer extends BasePeer {
|
|
|
346
365
|
reject: fail,
|
|
347
366
|
});
|
|
348
367
|
const input = [{ type: "text", text: this.render(envs) }];
|
|
368
|
+
if (!this.threadSandboxReady(link)) {
|
|
369
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
370
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
371
|
+
}
|
|
349
372
|
link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
|
|
350
373
|
});
|
|
351
374
|
}
|
|
@@ -358,12 +381,16 @@ export class CodexPeer extends BasePeer {
|
|
|
358
381
|
steerText(text: string): Promise<"accepted" | "refused" | "unanswered"> {
|
|
359
382
|
const link = this.link;
|
|
360
383
|
const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
|
|
361
|
-
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) return Promise.resolve("refused");
|
|
384
|
+
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) return Promise.resolve("refused");
|
|
362
385
|
const id = this.nextId--;
|
|
363
386
|
return new Promise((resolve) => {
|
|
364
387
|
const timer = setTimeout(() => this.pending.delete(id) && resolve("unanswered"), this.opts.steerTimeoutMs ?? 10_000);
|
|
365
388
|
timer.unref?.();
|
|
366
389
|
this.pending.set(id, { resolve: () => (clearTimeout(timer), resolve("accepted")), reject: () => (clearTimeout(timer), resolve("refused")) });
|
|
390
|
+
if (!this.threadSandboxReady(link)) {
|
|
391
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
392
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
393
|
+
}
|
|
367
394
|
link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input: [{ type: "text", text }] } }));
|
|
368
395
|
});
|
|
369
396
|
}
|
|
@@ -483,6 +510,7 @@ export class CodexPeer extends BasePeer {
|
|
|
483
510
|
this.link = undefined;
|
|
484
511
|
this.restoringApprovals.clear();
|
|
485
512
|
this.threadId = "";
|
|
513
|
+
this.onDoing?.("reset");
|
|
486
514
|
this.contextEpoch++;
|
|
487
515
|
this.deliveryEpoch++;
|
|
488
516
|
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
@@ -560,6 +588,9 @@ export class CodexPeer extends BasePeer {
|
|
|
560
588
|
private fromTui(link: Link, raw: string): void {
|
|
561
589
|
const msg = parse(raw);
|
|
562
590
|
if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
|
|
591
|
+
const sandbox = this.threadSandboxPolicy();
|
|
592
|
+
const threadRequest = TRACKED.has(msg?.method), turnRequest = msg?.method === "turn/start" || msg?.method === "turn/steer";
|
|
593
|
+
if (sandbox === "unavailable" && (threadRequest || turnRequest) || sandbox === "read-only" && turnRequest && (link !== this.link || link.tracked.size > 0 || link.readOnlyThread !== this.threadId)) return this.refusePlannerThread(link, msg.id);
|
|
563
594
|
if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
|
|
564
595
|
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
565
596
|
const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
|
|
@@ -572,7 +603,15 @@ export class CodexPeer extends BasePeer {
|
|
|
572
603
|
raw = JSON.stringify(msg);
|
|
573
604
|
}
|
|
574
605
|
}
|
|
575
|
-
if (msg?.id !== undefined && TRACKED.has(msg.method))
|
|
606
|
+
if (msg?.id !== undefined && TRACKED.has(msg.method)) {
|
|
607
|
+
if (link.tracked.has(msg.id)) link.ambiguousThreadRpc = true;
|
|
608
|
+
if (sandbox === "read-only") {
|
|
609
|
+
if (link.ambiguousThreadRpc || msg.params !== undefined && (!msg.params || typeof msg.params !== "object" || Array.isArray(msg.params))) return this.refusePlannerThread(link, msg.id);
|
|
610
|
+
msg.params = { ...msg.params, sandbox: "read-only" };
|
|
611
|
+
raw = JSON.stringify(msg);
|
|
612
|
+
}
|
|
613
|
+
link.tracked.set(msg.id, msg.method);
|
|
614
|
+
}
|
|
576
615
|
if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
|
|
577
616
|
else link.backlog.push(raw);
|
|
578
617
|
}
|
|
@@ -595,7 +634,11 @@ export class CodexPeer extends BasePeer {
|
|
|
595
634
|
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
596
635
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
597
636
|
if (tracked && link.tracked.delete(msg.id)) {
|
|
637
|
+
const sandbox = this.threadSandboxPolicy();
|
|
638
|
+
const readOnly = !link.ambiguousThreadRpc && msg.error === undefined && typeof msg.result?.thread?.id === "string" && !!msg.result.thread.id && confirmsReadOnlySandbox(msg.result?.sandbox);
|
|
639
|
+
if (sandbox === "unavailable" || sandbox === "read-only" && !readOnly) return this.refusePlannerThread(link, msg.id);
|
|
598
640
|
if (typeof msg.result?.thread?.id === "string") {
|
|
641
|
+
link.readOnlyThread = readOnly ? msg.result.thread.id : undefined;
|
|
599
642
|
// A resumed response may report the sticky hub override, not the original native choice.
|
|
600
643
|
const thread = msg.result.thread.id;
|
|
601
644
|
if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
|
|
@@ -606,6 +649,32 @@ export class CodexPeer extends BasePeer {
|
|
|
606
649
|
link.tui.send(raw);
|
|
607
650
|
}
|
|
608
651
|
|
|
652
|
+
private threadSandboxPolicy(): "read-only" | "unavailable" | undefined {
|
|
653
|
+
try {
|
|
654
|
+
const value = typeof this.opts.threadSandbox === "function" ? this.opts.threadSandbox() : this.opts.threadSandbox;
|
|
655
|
+
return value === undefined || value === "read-only" ? value : "unavailable";
|
|
656
|
+
} catch { return "unavailable"; }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Role changes never turn an ordinary writable/unknown thread into a proved planner thread. */
|
|
660
|
+
private threadSandboxReady(link: Link): boolean {
|
|
661
|
+
const sandbox = this.threadSandboxPolicy();
|
|
662
|
+
if (sandbox === "unavailable") { this.refusePlannerThread(link, undefined); return false; }
|
|
663
|
+
if (sandbox === "read-only") {
|
|
664
|
+
if (link.tracked.size > 0) return false;
|
|
665
|
+
if (link !== this.link || link.readOnlyThread !== this.threadId) { this.refusePlannerThread(link, undefined); return false; }
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Refuse before adoption or another host delivery, including while a previous thread was idle. */
|
|
671
|
+
private refusePlannerThread(link: Link, id: unknown): void {
|
|
672
|
+
if (link.tui.readyState === WebSocket.OPEN && (typeof id === "string" || typeof id === "number")) link.tui.send(JSON.stringify({ id, error: { code: -32000, message: PLANNER_SANDBOX_REFUSAL } }));
|
|
673
|
+
link.tracked.clear();
|
|
674
|
+
this.detach(link);
|
|
675
|
+
link.tui.close(1008, PLANNER_SANDBOX_REFUSAL);
|
|
676
|
+
}
|
|
677
|
+
|
|
609
678
|
private abortActionWaits(link?: Link, turn?: string): void {
|
|
610
679
|
for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
|
|
611
680
|
}
|
|
@@ -673,6 +742,7 @@ export class CodexPeer extends BasePeer {
|
|
|
673
742
|
this.freshThread = fresh;
|
|
674
743
|
this.link = link;
|
|
675
744
|
this.threadId = threadId;
|
|
745
|
+
this.onDoing?.("reset");
|
|
676
746
|
this.contextEpoch++;
|
|
677
747
|
this.deliveryEpoch++;
|
|
678
748
|
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
@@ -722,6 +792,9 @@ export class CodexPeer extends BasePeer {
|
|
|
722
792
|
this.opts.onUsage?.({ rateLimitReachedType: "usageLimitExceeded" }, true);
|
|
723
793
|
}
|
|
724
794
|
if (link !== this.link || params.threadId !== this.threadId) return;
|
|
795
|
+
// #878: the bound thread's name and its plan's in-progress step, for the hub's DOING column only.
|
|
796
|
+
if (method === "thread/name/updated") return void this.onDoing?.("title", params.threadName);
|
|
797
|
+
if (method === "turn/plan/updated") return void this.onDoing?.("todo", Array.isArray(params.plan) ? params.plan.find((s: any) => s?.status === "inProgress")?.step : undefined);
|
|
725
798
|
const nativeId = method === "turn/started" || method === "turn/completed" ? params.turn?.id : params.turnId;
|
|
726
799
|
if (typeof nativeId === "string" && this.retiredTurns.has(nativeId)) {
|
|
727
800
|
if (method === "turn/completed") this.retiredTurns.delete(nativeId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:net";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
const args = process.argv.slice(2), value = (key: string) => args[args.indexOf(key) + 1];
|
|
6
6
|
const record = args.includes("--record") ? value("--record") : undefined;
|
|
@@ -76,7 +76,11 @@ if (args.includes("version")) {
|
|
|
76
76
|
}
|
|
77
77
|
const finish = () => {
|
|
78
78
|
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: session, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "JCODE_REPLY" } } } });
|
|
79
|
-
|
|
79
|
+
// `--usage-script F`: the 0.99.1 shapes, as probed (docs/verification/2026-10-28-jcode-0.99.1-usage.md). F is a JSON object `{ updates: [...], usage: {...} | null, sessionId?: string }`:
|
|
80
|
+
// each `updates` entry becomes a `usage_update` notification (for `sessionId` when given, else this session), and `usage` is the prompt result's `usage` member (absent when null).
|
|
81
|
+
const script = args.includes("--usage-script") && existsSync(value("--usage-script")!) ? JSON.parse(readFileSync(value("--usage-script")!, "utf8")) : undefined;
|
|
82
|
+
for (const update of script?.updates ?? []) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: script.sessionId ?? session, update: { sessionUpdate: "usage_update", ...update } } });
|
|
83
|
+
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn", ...(script?.usage ? { usage: script.usage } : {}) } });
|
|
80
84
|
if (args.includes("--exit-after-prompt")) setTimeout(() => process.exit(0), 30);
|
|
81
85
|
};
|
|
82
86
|
const steerFile = args.includes("--steer-file") ? value("--steer-file")! : undefined;
|