@staix/agent-hub 0.12.12 → 0.12.14
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 +10 -0
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/models.md +1 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +11 -4
- package/src/adapters/pi.ts +1 -1
- package/src/cli/terminal-recovery.ts +2 -9
- package/src/models/mlx.ts +3 -1
- package/src/pi/extension.ts +2 -5
- package/src/pi/process-signature.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.12.14
|
|
8
|
+
|
|
9
|
+
- Normalize the remaining `ps lstart` identity reads to the shared pinned `LC_ALL=C`/`TZ=UTC` contract: the terminal-recovery launcher signature now goes through `processSignature`, and the MLX owner/generation identity read pins the same environment, so a record written under one timezone or locale still authenticates the same live process under another; strict PID/start/command identity and the fail-closed refusal paths are unchanged (#177).
|
|
10
|
+
|
|
11
|
+
## 0.12.13
|
|
12
|
+
|
|
13
|
+
- Bind serialized original manifest copies to their own pin in study summary export: the copy's bytes verify against `original_copy_sha256` with the raw source pin kept distinct, the copy must equal the supervisor's canonical serialization of its parsed contents, and the runtime manifest may differ only by the recorded hub-version amendments; legacy provenance without the copy pin refuses explicitly instead of receiving an invented pin (#173).
|
|
14
|
+
- Normalize Pi process-signature inspection to one pinned `LC_ALL=C`/`TZ=UTC` ps contract shared by the adapter, the Pi extension and the TUI owner monitor, so a valid native owner is accepted when the parent's timezone or locale differs from the child's; strict PID/start/command identity, owner token and generation/session checks are unchanged (#174).
|
|
15
|
+
- Export bounded active-window diagnostics for Qwen's native tool-call loop protection stops: a fixed terminal class bound to peer/session/generation with pinned-message evidence (anything else stays unknown), scalar-only counters that keep update events, distinct call ids, settlements, expected denials, tool failures and repeated announcements distinct, and an allowlist-built public view that cannot carry titles, arguments, paths, raw messages or dynamic keys; no native guard threshold is derived from event totals (#175).
|
|
16
|
+
|
|
7
17
|
## 0.12.12
|
|
8
18
|
|
|
9
19
|
- Restore owned native cohorts when a study supervisor is interrupted: bounded orderly cancellation with signal-time verified child identity, restoration settled only when the marker and ledger agree, a bounded identity-checked fallback with explicit restoration-failure records, and the interrupted phase, cause and restoration status persisted before the incomplete exit. Grading never starts after interrupted generation, and no resume, root reuse or retry is added (#168).
|
|
@@ -13,3 +13,4 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
|
|
|
13
13
|
- A peer must set `busy` synchronously inside `deliver()`, otherwise the bus drains the next envelope into a running turn (Kimi answers `turn.agent_busy`).
|
|
14
14
|
- A watchdog-cancelled turn still reports later. Anything a turn does on completion must check it is still the current turn (`turn` generation in `acp.ts`, `activeTurns` in `codex-appserver.ts`). A rejected prompt or abnormal prompt end reports through the adapter's `onTurnFailure` (same shape in `acp.ts` and `pi.ts`), even when ACP streamed partial text; a stale cancelled turn never reports it.
|
|
15
15
|
- Native owner teardown must handle a lost shutdown acknowledgement using verified process identity. Pending launches must be revocable, and active tools/streaming output must count as watchdog activity.
|
|
16
|
+
- Pi owner identity is one shared contract, `processSignature` in `src/pi/process-signature.ts`; the startup claim check, the extension's claim, the TUI owner monitor, `recoveryReady` and the verified teardown all go through it. Its `ps` read pins LC_ALL=C and TZ=UTC (the `processTable` contract) because `lstart` follows the reader's timezone and locale, and a hub on UTC with a Pi child on system time otherwise hashes different strings for the same process and refuses the valid owner (#174). Strict pid + start + command identity stays; never accept PID-only or a fuzzy command match.
|
|
@@ -5,4 +5,5 @@ Scope: the CooperBench runners, their fixtures, teardown and recovery. Read befo
|
|
|
5
5
|
- Never auto-register Orca benchmark fixtures, including ad-hoc or copied runners; require an explicit pre-existing repo and exact worktree registration, and preserve fixture directories, Git state and benchmark evidence during cleanup.
|
|
6
6
|
- The manifest-v3 Pi/Qwen CooperBench driver (`scripts/benchmarks/native-pi-qwen.ts`, #140) is headless and never touches Orca. Its pinned build is the effective one: each native's `--version` runs under the final isolation environment (Qwen inside its seatbelt profile with `QWEN_HOME` set; the PATH bootstrap lies about the managed build under isolation). Its protected-file probe counts only with structured denial evidence (a guard denial or a failed read tool call with EPERM/EACCES), never a model-written marker alone, and each peer's serialized `sandboxProbe` readiness reports only that peer's observed probe outcome (`denied` with its evidence, else explicit `failed`/`unknown`; #150), never a synthesized denial. Served-model evidence is each request's own journaled `RelayRequestRecord` (#139): a heartbeat-only or cancelled-before-identification record certifies nothing, and every identified record is evidence whatever its outcome; a confirmed mismatch on a request cancelled after identification fails the model gate. A backend's last-served label is never a request's evidence. Qwen's peer tool approval is the exact canonical `mcp__pilot-peer-bus__hub_send` through the adapter's #138 binding, never a title-prefix workaround.
|
|
7
7
|
- The benchmark run's restoration state: `restoration.json` says `restored: false` from before the runner locks anything until it writes its outcome, and the recovery trusts `restored: true` only when `restoration-ledger.json` agrees (`unrestored` in `scripts/benchmarks/teardown.ts`). A run directory that is not restored is never reused: its locked modes would become the originals. A ledger from before 0.12.5 (no runner identity) owes its locks but, when `restoration.json` says `restored: true` or names a runner, keeps its trust entry as it was then (`owed`; with no `restoration.json`, its runner did not finish and the entry is owed too): the reuse check and the recovery read both through these two functions, never on their own (#120).
|
|
8
|
+
- A latched active-window terminal failure carries the #175 bounded diagnosis: the terminal class comes only from the pinned Qwen build's exact loop-protection message contract (anything else stays `unknown`), the active-window tool trace is count-only and phase/generation-fenced like the latch (setup probes and teardown never count), tool categories come from the protocol kinds and the #138 canonical binding (`canonicalMcpToolName` in `src/adapters/acp.ts`), and the native guard's threshold, predicate and no-op signals are declared unavailable, never derived from event totals. The diagnosis explains the latched failure and never changes it: no retry, no continuation, no guard relaxation.
|
|
8
9
|
- The benchmark runner's Claude trust entry: a write still `pending` in the runner's own process never landed, so it is `not_written` on every path (contained or not, its temp file removed or left), and neither the runner nor the recovery touches an entry then. Only a dead runner's `pending` leaves that question to the recovery, which takes back only an entry exactly as the runner would have written it, and none while the runner's temp file is still there (the rename never happened). `changed_concurrently` is the user's entry and is never taken back. The runner's settlement is `settleTrust` in `scripts/benchmarks/teardown.ts`, tested as a table: change it there, never inline.
|
|
@@ -11,3 +11,4 @@ Scope: the control WS and its protocol, state files, the Claude channel's reconn
|
|
|
11
11
|
- Adding a native peer requires testing the command emitted by the actual recovery driver, not only its inspection or launch helpers. Never let a generic non-Codex branch treat a new peer as Claude.
|
|
12
12
|
- Cross-version recovery must use the authenticated source protocol for prepare/commit/abort and the target protocol after startup. Prove the transition against a real prior release before claiming compatibility.
|
|
13
13
|
- A zero-turn Claude session has no transcript, so it can never be resumed: every identity gate that compares session ids (restore, coordinator verify, daemon readiness) must tolerate a fresh session while the transcript is absent and stay strict the moment one exists. The commit snapshot records `sessionPersisted` for the restored daemon, and snapshots too old to have it are re-derived from disk. Never treat "session id changed" as proof of a lost conversation without checking the transcript first.
|
|
14
|
+
- The terminal-recovery launcher signature is the shared pinned `processSignature` (`src/pi/process-signature.ts`): a record written by one CLI invocation must compare equal when a later invocation re-reads the live launcher under another TZ/LC_ALL (#177). In-process `process.env` mutation does not reach Bun.spawnSync's default child environment, so cross-environment regressions are tested with real subprocesses.
|
|
@@ -7,3 +7,4 @@ Scope: the model relay and its journal, the hub's own inference and its slots, O
|
|
|
7
7
|
- Secrets stay inside `OmniRoute`: never put the key or Access values in a log line, an error message, a return value or the generated Switchyard file (the key goes by env var name).
|
|
8
8
|
- Switchyard's docs drift from the released binary. Any change to `switchyardToml` is checked with the real `switchyard-server --dry-run`, not only the stand-in in `test/fakes/`.
|
|
9
9
|
- Shared inference slots must recover after a hub process dies, without evicting a live owner. Cancellation has to reach slot acquisition from the relay caller, not just exist in the helper signature.
|
|
10
|
+
- MLX owner and generation identity read `ps lstart` with LC_ALL=C/TZ=UTC pinned (#177): unpinned, a record written under one environment reads as a foreign process under another, and the fail-closed paths would refuse the hub's own server.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -15403,7 +15403,7 @@ class ControlClient {
|
|
|
15403
15403
|
// package.json
|
|
15404
15404
|
var package_default = {
|
|
15405
15405
|
name: "@staix/agent-hub",
|
|
15406
|
-
version: "0.12.
|
|
15406
|
+
version: "0.12.14",
|
|
15407
15407
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15408
15408
|
license: "MIT",
|
|
15409
15409
|
type: "module",
|
package/src/adapters/acp.ts
CHANGED
|
@@ -84,6 +84,16 @@ const HANDSHAKE_MS = 30_000;
|
|
|
84
84
|
/** Tool calls whose arguments are remembered until their permission request arrives. */
|
|
85
85
|
const TOOL_INPUT_CAP = 64;
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
|
|
89
|
+
* `mcp__<server>__<tool>` only when <server> is one this session was configured with. A title that
|
|
90
|
+
* quotes argument JSON or names an unconfigured server resolves to nothing.
|
|
91
|
+
*/
|
|
92
|
+
export function canonicalMcpToolName(announcedTitle: string | undefined, serverNames: readonly string[] | undefined): string | undefined {
|
|
93
|
+
const m = announcedTitle?.match(/^([\w-]{1,80}) \((.+) MCP Server\)$/);
|
|
94
|
+
return m && serverNames?.some((name) => name === m[2]) ? `mcp__${m[2]}__${m[1]}` : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
|
|
88
98
|
|
|
89
99
|
/** Streamed argument text as a payload: a complete JSON object, or nothing (a partial stream is not what will run). */
|
|
@@ -302,10 +312,7 @@ export class AcpPeer extends BasePeer {
|
|
|
302
312
|
// the call id, and only when the server half is a server this session was configured with: the canonical
|
|
303
313
|
// `mcp__<server>__<tool>` name is the only derived candidate the exact-match whitelist ever sees, and
|
|
304
314
|
// argument text is never one.
|
|
305
|
-
const canonical = (() =>
|
|
306
|
-
const m = announced?.match(/^([\w-]{1,80}) \((.+) MCP Server\)$/);
|
|
307
|
-
return m && this.opts.mcpServers?.some((s) => s.name === m[2]) ? `mcp__${m[2]}__${m[1]}` : undefined;
|
|
308
|
-
})();
|
|
315
|
+
const canonical = canonicalMcpToolName(announced, this.opts.mcpServers?.map((s) => s.name));
|
|
309
316
|
const identity = [typeof call.title === "string" ? call.title : undefined, canonical].find((candidate) => candidate !== undefined && this.opts.autoApprove?.(candidate));
|
|
310
317
|
if (once && identity) {
|
|
311
318
|
this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
|
package/src/adapters/pi.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
|
5
5
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
6
6
|
import { BasePeer } from "../hub/peers.ts";
|
|
7
7
|
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
|
+
import { processSignature } from "../pi/process-signature.ts";
|
|
8
9
|
import { realPath } from "../hub/project.ts";
|
|
9
10
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
10
11
|
|
|
@@ -29,7 +30,6 @@ export interface PiOptions {
|
|
|
29
30
|
export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
|
|
30
31
|
type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
|
|
31
32
|
type VerifiedEmptyResume = { sessionId: string };
|
|
32
|
-
function processSignature(pid: number): string | undefined { try { const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" }); if (result.exitCode !== 0) return undefined; const text = result.stdout.toString().trim(); return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined; } catch { return undefined; } }
|
|
33
33
|
function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
34
34
|
|
|
35
35
|
const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
|
|
@@ -2,6 +2,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { processSignature } from "../pi/process-signature.ts";
|
|
5
6
|
|
|
6
7
|
/** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
|
|
7
8
|
export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
|
|
@@ -226,15 +227,7 @@ function resolveOrcaExecutable(): string {
|
|
|
226
227
|
|
|
227
228
|
function defaultProcessIdentity(pid: number): string | undefined {
|
|
228
229
|
if (!Number.isInteger(pid) || pid <= 0) return undefined;
|
|
229
|
-
|
|
230
|
-
const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
|
|
231
|
-
if (result.exitCode !== 0) return undefined;
|
|
232
|
-
const identity = result.stdout.toString().trim();
|
|
233
|
-
if (!identity) return undefined;
|
|
234
|
-
return new Bun.CryptoHasher("sha256").update(identity).digest("hex");
|
|
235
|
-
} catch {
|
|
236
|
-
return undefined;
|
|
237
|
-
}
|
|
230
|
+
return processSignature(pid);
|
|
238
231
|
}
|
|
239
232
|
|
|
240
233
|
function defaultRunner(executable: string): CommandRunner {
|
package/src/models/mlx.ts
CHANGED
|
@@ -97,7 +97,9 @@ function readOwner(runtimeDir: string): OwnerRecord | undefined {
|
|
|
97
97
|
|
|
98
98
|
function processInfo(pid: number): ProcessSignature | undefined {
|
|
99
99
|
try {
|
|
100
|
-
|
|
100
|
+
// `lstart` follows the reader's TZ and locale; pin the read so a record written by
|
|
101
|
+
// one hub invocation compares equal when a later one re-reads it elsewhere (#177).
|
|
102
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } });
|
|
101
103
|
if (result.status !== 0) return undefined;
|
|
102
104
|
const line = String(result.stdout).trim();
|
|
103
105
|
const match = /^(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(.*)$/.exec(line);
|
package/src/pi/extension.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { assistantTokens } from "./usage.ts";
|
|
2
|
+
import { processSignature } from "./process-signature.ts";
|
|
2
3
|
type ExtensionAPI = any;
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
4
|
type BudgetUnit = "model_calls" | "tool_calls";
|
|
6
5
|
|
|
7
6
|
type BridgeEvent = { type: string; text?: string; sessionId?: string; sessionFile?: string; error?: string };
|
|
@@ -49,8 +48,6 @@ async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ al
|
|
|
49
48
|
runtimeCtx?.abort?.();
|
|
50
49
|
return { allowed: false };
|
|
51
50
|
}
|
|
52
|
-
function processSignature(): string | undefined { try { const text = execFileSync("ps", ["-p", String(process.pid), "-o", "lstart=,comm="], { encoding: "utf8" }).trim(); return text ? createHash("sha256").update(text).digest("hex") : undefined; } catch { return undefined; } }
|
|
53
|
-
|
|
54
51
|
async function poll(pi: ExtensionAPI): Promise<void> {
|
|
55
52
|
while (!pollStopped) {
|
|
56
53
|
try {
|
|
@@ -107,7 +104,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
107
104
|
shutdown = ctx.shutdown;
|
|
108
105
|
const state = ctx.sessionManager.getHeader();
|
|
109
106
|
try {
|
|
110
|
-
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
107
|
+
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
111
108
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
112
109
|
} catch (error) { ctx.shutdown?.(); throw error; }
|
|
113
110
|
if (!pollStarted) { pollStarted = true; void poll(pi); }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one process-identity contract behind the Pi owner claim, every later liveness
|
|
6
|
+
* re-inspection, the terminal-recovery launcher signature and any other hub identity
|
|
7
|
+
* hash: pid plus the `ps` rendering of its start time and command. `lstart` follows
|
|
8
|
+
* the reader's TZ and locale, so the read pins LC_ALL=C and TZ=UTC, the same contract
|
|
9
|
+
* as `processTable` in src/hub/child-process.ts. Without the pin a writer and a reader
|
|
10
|
+
* in different environments hash different strings for the same process and the valid
|
|
11
|
+
* owner is refused (#174, #177). Only the `ps` invocation is pinned; the user's own
|
|
12
|
+
* environment is untouched. Node APIs only: the extension runs inside Pi, not Bun.
|
|
13
|
+
*/
|
|
14
|
+
export function processSignature(pid: number): string | undefined {
|
|
15
|
+
try {
|
|
16
|
+
const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
|
|
17
|
+
return text ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
18
|
+
} catch { return undefined; }
|
|
19
|
+
}
|