@staix/agent-hub 0.12.13 → 0.12.15
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 +12 -0
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/models.md +1 -0
- package/docs/cooperbench.md +17 -1
- 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/pi.ts +24 -5
- package/src/cli/terminal-recovery.ts +2 -9
- package/src/models/mlx.ts +3 -1
- package/src/models/relay.ts +73 -3
- package/src/pi/ceiling.ts +54 -0
- package/src/pi/extension.ts +20 -2
- package/src/pi/process-signature.ts +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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.15
|
|
8
|
+
|
|
9
|
+
- Bind Pi tool-step ceiling diagnostics to the trusted producer's session and turn, retain the rejected pre-effect invocation count, and keep the first active failure cause frozen (#179).
|
|
10
|
+
- Add bounded sealed-study status coverage, with audit/ledger/manifest bytes bound to the seal index and planned, scored, passed, unavailable, missing, native end classes and recorded restoration shown separately (#180).
|
|
11
|
+
- Carry the shared managed-tool failure verdict through Pi's bridge to native `isError`, preserving result text and compatibility with older bridges (#181).
|
|
12
|
+
- Publish Pi benchmark descriptors for the executor's actual source-write and git ls-files policy, with peer messaging only in the joint arm; production descriptors stay unchanged (#182).
|
|
13
|
+
- Attest every native bootstrap publication against structural schema and source fingerprints. Reject malformed, mixed, changed or missing final publications before scored generation; permit only the pinned Qwen MCP startup transition. Declare the corrected surface as a new study condition and preserve historical studies (#183).
|
|
14
|
+
|
|
15
|
+
## 0.12.14
|
|
16
|
+
|
|
17
|
+
- 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).
|
|
18
|
+
|
|
7
19
|
## 0.12.13
|
|
8
20
|
|
|
9
21
|
- 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,3 +14,5 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
|
|
|
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
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.
|
|
17
|
+
- Return the shared `toolResultFailed` verdict as `/tool`'s `failed`; map only `failed === true` to Pi's `isError`. Preserve text and the older bridge's absent-flag behavior.
|
|
18
|
+
- Emit Pi's ceiling event at the pre-effect rejection boundary (`src/pi/ceiling.ts`), before `agent_end`. Accept only a valid current-session/current-generation signal during a running turn; retain the first signal and attach it to that turn's failure. Never classify a ceiling from free text.
|
|
@@ -6,4 +6,7 @@ Scope: the CooperBench runners, their fixtures, teardown and recovery. Read befo
|
|
|
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
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.
|
|
9
|
+
- Classify Pi's ceiling only from the adapter's validated producer signal bound to the latched peer/active generation. Keep shared budgets and Qwen's guard distinct; diagnostics never change the frozen end cause.
|
|
10
|
+
- Read terminal coverage only through bounded `terminal_coverage` metadata reads, rebinding the audit, ledger and manifest to the seal index. Require complete sealed state, all audit checks, plan/cell/count reconciliation and zero recorded live owners. Report recorded verification separately from fresh audit; missing or inconsistent evidence is unavailable.
|
|
11
|
+
- Publish Pi benchmark descriptors from `benchPiToolProblem`; keep production schemas unchanged and omit solo peer messaging. Attest every bootstrap tool publication against declared native/schema/source pins before scored generation. Fail/defer missing, mixed, malformed, truncated or changed publications; retain only bounded names/counts/hashes. Declare a new study condition before any changed collection; never rewrite historical artifacts or select retries by score.
|
|
9
12
|
- 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/docs/cooperbench.md
CHANGED
|
@@ -99,6 +99,22 @@ A required peer's terminal active-turn failure ends the whole attempt at once (#
|
|
|
99
99
|
|
|
100
100
|
Grading flows through the same official evaluator adapter and controls; a quality failure is never a retry selector and no evaluator feedback reaches the candidate agents during generation. Reports keep quality, model-identity and request-linkage coverage separate per arm, with unavailable attempts (failed, missing or unavailable) retained in the planned denominator, and name the usage units (Pi's incremental `onTokens` counter, Qwen's session `usage_update` running total — never added together) and the tool-surface difference (Pi's hub-moderated tools against Qwen's own seatbelted auto-edit tools). A native actor's usage is counted only in attempts where that actor participated (#152): every v3 grade row carries `native_participants`, the actors the attempt record shows actually started — the arm bounds the candidates (a solo-qwen attempt has no Pi peer), and among them a peer participated only once its readiness entry carries the sessionId its adapter reported after start. A peer that was constructed but never started (a setup failure: `elapsedMs` 0, no active start) is absent; its token counter's initial 0 is never read as a measurement. An absent actor reports zero known observations and a null total (`*_tokens_known: 0`, `*_tokens: null`), a participant whose native reading is missing stays unknown rather than zero, and a participant's genuinely observed 0 stays counted. The summary's `pi_participating`/`qwen_participating` fields give each coverage figure's participation denominator. Grade rows from before #152 carry no `native_participants`; the report takes their participants from the arm, which is exact for them: their `native_usage` was attached only to scored attempts, whose readiness gate had proved every required actor's session. A live cohort, the official Docker controls and a native readback of an attempt's records remain manual live legs requiring accounts and the pinned archives; they are not part of the checked-in tests.
|
|
101
101
|
|
|
102
|
+
### Tool surface: declared descriptors and bootstrap readback (#182, #183)
|
|
103
|
+
|
|
104
|
+
Amended on 2026-10-07, after the sealed `pi-qwen-01214-261007` collection and a controlled no-model probe of the actual pinned Qwen 0.24.7 ACP binary (`qwen-surface-probe.json`; both stay private).
|
|
105
|
+
|
|
106
|
+
The sealed study published the general production `TOOL_SCHEMAS` to Pi while the benchmark executor permitted a narrower set: the git descriptor advertised status/diff/log/show/blame/rev-parse as freely available and approval-gated writes, although the wrapper permits only `git ls-files` with plain relative path arguments and auto-permits in-scope source writes, and `hub_send` was registered for solo Pi although no peer is assigned. The driver now publishes benchmark-specific descriptors generated from the executor's own argument-level policy (`benchPiToolProblem` in the driver is the single source of truth): source read/edit/write with in-scope writes applied directly (no approval flow exists in the benchmark), git ls-files only with the other operations named as refused, and `hub_send` registered only in the joint arm, where it fixes the assigned peer. The production `TOOL_SCHEMAS` are unchanged for normal managed peers, and the execution policy is not widened to match any description.
|
|
107
|
+
|
|
108
|
+
This descriptor correction is a declared study-condition change: it is declared as `agent-hub.native-tools/v2` in `scripts/benchmarks/manifest-v3-pi-qwen-tools-v2.json` before any future collection; all artifacts of `pi-qwen-01214-261007` are preserved unchanged and its planned denominators stand; results collected under the corrected surface are never compared causally with the 2026-10-07 collection as a release improvement; and no failed cell is selectively rerun.
|
|
109
|
+
|
|
110
|
+
The intended effective tool surface is declared per native and arm (`intendedSurfaces`): Pi's published names are the #182 descriptors (driver-published evidence); Qwen's are the pinned bare publication `edit`/`read_file` observed by the controlled probe, plus the canonical `mcp__pilot-peer-bus__hub_send` in the joint arm, with the launch's `--exclude-tools` list recorded as intentionally excluded (count and hash) and no native registry compatibility aliases pinned — a non-excluded name is never assumed registered, and pinned aliases never widen a permission. The current treatment (`--bare`, the exclusion list, the #138 canonical binding) is pinned and verified, not changed.
|
|
111
|
+
|
|
112
|
+
The bootstrap readback uses the relay's opt-in bounded projection (`observeToolSurface` in `src/models/relay.ts`): each journaled request carries only allowlist-charset tool names, the entry count and an opaque hash over complete structural parameter schemas (including nested types and constraints, excluding prose) — never descriptions, arguments or message text. After every peer's probe turn and before any scored generation the driver reconciles the observed publications against the declared surface (`reconcileToolSurfaces`, every observation accounted for, including schema drift): a publication that intersects a native's declared set without equalling it, or any published name matching no declared surface, alias or exclusion, is a condition mismatch and fails the attempt as a setup failure before scored generation; a native whose publication was never observed is recorded `unobserved` and deferred before scored generation, never inferred. Attempt records carry the bounded surface block only: the definition hash, per-native allowlisted names/counts/hashes, the exclusion count/hash, and the unknown-name count, with the fail/defer policy quoted in the block. Requested names that fail natively (the persisted `tool_not_registered` rejections) are classed against the verified surface as declared/alias/excluded/unknown (`surfaceNameClass`), without synthesizing execution counts or native guard thresholds.
|
|
113
|
+
|
|
114
|
+
Qwen's pinned MCP discovery completes asynchronously at session startup: a controlled no-model capture observed the initial bare two-tool publication before the peer bus registered, then the settled three-tool joint publication. Only that exact solo schema before the first settled joint schema is an allowed bootstrap transition. Every later joint publication must retain the settled schema; a missing final publication defers the attempt. Structural hashes and native source file pins come from isolated pinned-build captures, never from the candidate study's traffic. This attests publication and source bindings; it does not claim that every advertised tool was executed.
|
|
115
|
+
|
|
116
|
+
A source or native update that changes effective publication therefore surfaces as a condition mismatch before scored generation. Any deliberate surface change (removing `--bare`, widening descriptors, pinning an alias) requires an explicit version and spec amendment here and a fresh whole-study plan, never selective retries or causal comparison with historical scores.
|
|
117
|
+
|
|
102
118
|
## Coordination ledger
|
|
103
119
|
|
|
104
120
|
```sh
|
|
@@ -164,7 +180,7 @@ resumes a cohort. Prior archives remain read-only.
|
|
|
164
180
|
|
|
165
181
|
```sh
|
|
166
182
|
python3 -B scripts/benchmarks/study_supervisor.py \
|
|
167
|
-
--manifest scripts/benchmarks/manifest-v3-pi-qwen.json \
|
|
183
|
+
--manifest scripts/benchmarks/manifest-v3-pi-qwen-tools-v2.json \
|
|
168
184
|
--output "$STUDY_ROOT" --archives "$ARCHIVE_ROOT" \
|
|
169
185
|
--private-inputs "$PRIVATE_CASE_ROOT" --upstream-root "$UPSTREAM_ROOT" \
|
|
170
186
|
--probe-target "$PROTECTED_PROBE" --qwen-package "$QWEN_PACKAGE" \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.15",
|
|
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.15",
|
|
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/pi.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
7
7
|
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import { processSignature } from "../pi/process-signature.ts";
|
|
9
9
|
import { realPath } from "../hub/project.ts";
|
|
10
|
+
import { toolResultFailed } from "../local/tools.ts";
|
|
11
|
+
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
10
12
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
11
13
|
|
|
12
14
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
@@ -22,7 +24,8 @@ export interface PiOptions {
|
|
|
22
24
|
admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
|
|
23
25
|
/** Reported message usage as increments, before agent_settled releases the turn. */
|
|
24
26
|
onTokens?: (added: number) => void;
|
|
25
|
-
|
|
27
|
+
/** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
|
|
28
|
+
onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
|
|
26
29
|
watchdogMs?: number; log?: (line: string) => void;
|
|
27
30
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
28
31
|
stopGraceMs?: number;
|
|
@@ -58,6 +61,7 @@ export class PiPeer extends BasePeer {
|
|
|
58
61
|
private executionBudgetTimer?: ReturnType<typeof setTimeout>;
|
|
59
62
|
private executionAbort?: AbortController;
|
|
60
63
|
private readonly budgetStops = new Map<number, string>();
|
|
64
|
+
private readonly ceilingStops = new Map<number, PiToolStepCeiling>();
|
|
61
65
|
private readonly idleBashReservations = new Map<string, { generation: number; expiresAt: number; deadlineAt?: number }>();
|
|
62
66
|
private budgetGeneration = 0;
|
|
63
67
|
private modelStep = 0;
|
|
@@ -185,8 +189,11 @@ export class PiPeer extends BasePeer {
|
|
|
185
189
|
this.activeTools++;
|
|
186
190
|
if (this.state === "idle") this.setState("busy");
|
|
187
191
|
if (this.state === "busy") this.touch();
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
// `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
|
|
193
|
+
// classifies a native tool result by isError === true alone, so the extension needs the flag,
|
|
194
|
+
// not just the text (#181). An older extension simply ignores the extra field.
|
|
195
|
+
try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
|
|
196
|
+
catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
|
|
190
197
|
finally {
|
|
191
198
|
clearTimeout(idleBashTimer);
|
|
192
199
|
this.activeTools--;
|
|
@@ -426,7 +433,17 @@ export class PiPeer extends BasePeer {
|
|
|
426
433
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
427
434
|
if (generation <= this.budgetGeneration) return;
|
|
428
435
|
this.modelStep = 0;
|
|
429
|
-
this.usageSeen.clear(); this.idleBashReservations.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
436
|
+
this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
437
|
+
}
|
|
438
|
+
// #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
|
|
439
|
+
// THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
|
|
440
|
+
// else (stale, cross-session, free text) is dropped and the failure stays unclassified.
|
|
441
|
+
if (event.type === "ceiling") {
|
|
442
|
+
const ceiling = piToolStepCeiling(event);
|
|
443
|
+
if (ceiling && ceiling.sessionId === this.sessionId && ceiling.generation === this.budgetGeneration && this.agentRunning && this.state === "busy" && !this.ceilingStops.has(ceiling.generation)) {
|
|
444
|
+
this.ceilingStops.set(ceiling.generation, ceiling);
|
|
445
|
+
for (const old of this.ceilingStops.keys()) if (old < ceiling.generation - 8) this.ceilingStops.delete(old);
|
|
446
|
+
}
|
|
430
447
|
}
|
|
431
448
|
if (event.type === "activity" && this.state === "busy") this.touch();
|
|
432
449
|
if (event.type === "tokens" && this.state === "busy" && typeof event.id === "string" && event.id.length <= 100 && Number.isSafeInteger(event.tokens) && event.tokens >= 0 && !this.usageSeen.has(event.id)) {
|
|
@@ -451,14 +468,16 @@ export class PiPeer extends BasePeer {
|
|
|
451
468
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
452
469
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
453
470
|
const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
|
|
471
|
+
const ceiling = this.ceilingStops.get(generation);
|
|
454
472
|
this.budgetStops.delete(generation);
|
|
473
|
+
this.ceilingStops.delete(generation);
|
|
455
474
|
this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
|
|
456
475
|
// Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
|
|
457
476
|
// whole delivery here, steered additions included.
|
|
458
477
|
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
459
478
|
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
460
479
|
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
461
|
-
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
|
|
480
|
+
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
|
|
462
481
|
else if (text) this.onMessage?.(text, reply);
|
|
463
482
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
464
483
|
this.activeDeliveryIds.clear();
|
|
@@ -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/models/relay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import type { OmniRoute, ChatMessage } from "../omniroute/client.ts";
|
|
4
4
|
import { ensureMlx, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
|
|
@@ -32,7 +32,9 @@ export interface ModelRelayStatus {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Sanitized per-request identity and lifecycle evidence. One record per upstream dispatch attempt
|
|
35
|
-
* (a fallback dispatch is its own record). Records carry no messages,
|
|
35
|
+
* (a fallback dispatch is its own record). Records carry no messages, keys or Access headers; with the
|
|
36
|
+
* opt-in `observeToolSurface` they additionally carry the bounded `toolSurface` projection (allowlist-
|
|
37
|
+
* charset names, a count and an opaque schema hash — never descriptions, arguments or message text).
|
|
36
38
|
* `identified: false` with `outcome: "cancelled"` is the cancelled-before-identification state; an
|
|
37
39
|
* observed `actualModel` that differs from the trusted expected served model sets `mismatch`.
|
|
38
40
|
* Without an explicit expectation, the upstream-configured identifier remains the comparison default.
|
|
@@ -47,6 +49,63 @@ export interface RelayUsageObservation {
|
|
|
47
49
|
totalTokens?: number;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The bounded tool-surface projection of one request's published `tools` array (#183): what the native
|
|
54
|
+
* declared to the model, reduced to allowlist-charset names (sorted, deduplicated), the raw entry count
|
|
55
|
+
* and an opaque full structural JSON-schema fingerprint. Schema annotations are omitted, while types,
|
|
56
|
+
* constraints and literal default/const/enum values are hashed only, never exported.
|
|
57
|
+
*/
|
|
58
|
+
export interface RelayToolSurface {
|
|
59
|
+
count: number;
|
|
60
|
+
names: string[];
|
|
61
|
+
schemaSha256: string;
|
|
62
|
+
invalidEntries: number;
|
|
63
|
+
duplicateNames: number;
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SURFACE_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
68
|
+
const SURFACE_CAP = 64;
|
|
69
|
+
const SCHEMA_ANNOTATIONS = new Set(["description", "title", "examples", "$comment"]);
|
|
70
|
+
const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependentRequired"]);
|
|
71
|
+
|
|
72
|
+
/** Structural JSON-schema fingerprint, including types, constraints and nested shapes. Annotation text is
|
|
73
|
+
* omitted; all remaining values enter only the opaque hash. Resource ceilings fail explicitly, never silently
|
|
74
|
+
* qualify a partial schema. */
|
|
75
|
+
export function toolSurfaceProjection(tools: unknown): RelayToolSurface | undefined {
|
|
76
|
+
if (!Array.isArray(tools)) return undefined;
|
|
77
|
+
let invalidEntries = 0, duplicateNames = 0, truncated = tools.length > SURFACE_CAP, nodes = 0;
|
|
78
|
+
const names = new Set<string>();
|
|
79
|
+
const canonical = (value: unknown, depth = 0, keyword = "", schemaMap = false, literal = false): unknown => {
|
|
80
|
+
if (++nodes > 8192 || depth > 20) { truncated = true; return null; }
|
|
81
|
+
if (value === null || typeof value === "boolean") return value;
|
|
82
|
+
if (typeof value === "number") { if (Number.isFinite(value)) return value; invalidEntries++; return null; }
|
|
83
|
+
if (typeof value === "string") { if (value.length > 8192) { truncated = true; return null; } return value; }
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
if (value.length > 256) truncated = true;
|
|
86
|
+
const result = value.slice(0, 256).map((v) => canonical(v, depth + 1, "", false, literal || keyword === "enum"));
|
|
87
|
+
return !literal && (keyword === "required" || keyword === "enum" || keyword === "type") ? result.sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; }) : result;
|
|
88
|
+
}
|
|
89
|
+
if (value && typeof value === "object") {
|
|
90
|
+
const keys = Object.keys(value).filter((k) => literal || schemaMap || !SCHEMA_ANNOTATIONS.has(k)).sort();
|
|
91
|
+
if (keys.length > 256) truncated = true;
|
|
92
|
+
return Object.fromEntries(keys.slice(0, 256).map((k) => [k, canonical((value as Record<string, unknown>)[k], depth + 1, schemaMap ? "" : k, !literal && !schemaMap && SCHEMA_MAPS.has(k), literal || (!schemaMap && ["default", "const", "enum"].includes(k)))]));
|
|
93
|
+
}
|
|
94
|
+
invalidEntries++; return null;
|
|
95
|
+
};
|
|
96
|
+
const schema: { name: string; parameters: unknown }[] = [];
|
|
97
|
+
for (const entry of tools.slice(0, SURFACE_CAP)) {
|
|
98
|
+
const e = entry as { function?: unknown; name?: unknown; parameters?: unknown } | null;
|
|
99
|
+
const fn = (e && typeof e === "object" && e.function && typeof e.function === "object" ? e.function : e) as { name?: unknown; parameters?: unknown } | null;
|
|
100
|
+
const name = typeof fn?.name === "string" && SURFACE_NAME.test(fn.name) ? fn.name : "";
|
|
101
|
+
if (!name || !fn?.parameters || typeof fn.parameters !== "object" || Array.isArray(fn.parameters)) invalidEntries++;
|
|
102
|
+
if (name) { if (names.has(name)) duplicateNames++; names.add(name); }
|
|
103
|
+
schema.push({ name, parameters: canonical(fn?.parameters ?? null) });
|
|
104
|
+
}
|
|
105
|
+
schema.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
106
|
+
return { count: tools.length, names: [...names].sort(), schemaSha256: createHash("sha256").update(JSON.stringify(schema)).digest("hex"), invalidEntries, duplicateNames, truncated };
|
|
107
|
+
}
|
|
108
|
+
|
|
50
109
|
export interface RelayRequestRecord {
|
|
51
110
|
id: string;
|
|
52
111
|
dispatchGroupId?: string;
|
|
@@ -68,6 +127,8 @@ export interface RelayRequestRecord {
|
|
|
68
127
|
usageAvailability?: "known" | "partial" | "missing" | "invalid";
|
|
69
128
|
/** Observed served model; never read back from the backend's mutable last label. */
|
|
70
129
|
actualModel?: string;
|
|
130
|
+
/** Bounded tool-surface projection of the request's published tools; only when the owner opted in. */
|
|
131
|
+
toolSurface?: RelayToolSurface;
|
|
71
132
|
identitySource: "header" | "stream" | "configured" | "none";
|
|
72
133
|
// ponytail: the relay cannot see native turn structure, so role stays "unknown"; a native surface
|
|
73
134
|
// that knows primary vs auxiliary work (benchmark wiring, issue #140) is the upgrade path.
|
|
@@ -106,6 +167,9 @@ export interface ModelRelayOptions {
|
|
|
106
167
|
/** Include explicit missing usage/provider metadata and dispatch groups, independently of observers.
|
|
107
168
|
* Omission preserves the legacy absent-metadata journal schema. */
|
|
108
169
|
observeRequestMetadata?: boolean;
|
|
170
|
+
/** Journal each request's bounded tool-surface projection (#183). Omission preserves the no-tools
|
|
171
|
+
* journal contract. */
|
|
172
|
+
observeToolSurface?: boolean;
|
|
109
173
|
/** Called exactly once per journaled request, at its terminal close, with a sanitized copy. */
|
|
110
174
|
onRequest?: (record: RelayRequestRecord) => void;
|
|
111
175
|
}
|
|
@@ -258,7 +322,7 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
|
|
|
258
322
|
});
|
|
259
323
|
}
|
|
260
324
|
|
|
261
|
-
const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}) });
|
|
325
|
+
const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}), ...(record.toolSurface ? { toolSurface: { ...record.toolSurface, names: [...record.toolSurface.names] } } : {}) });
|
|
262
326
|
|
|
263
327
|
export async function startModelRelay(options: ModelRelayOptions): Promise<ModelRelay> {
|
|
264
328
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -482,6 +546,12 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
482
546
|
let primaryDispatchId: string | undefined;
|
|
483
547
|
const dispatch = async (selected: ModelBackend, body: RelayRequest) => {
|
|
484
548
|
const journalEntry = openRequestRecord(aliasOf(selected, mlxAlias), dispatchGroupId);
|
|
549
|
+
// The surface is a property of the request as admitted: even a failed dispatch keeps what the
|
|
550
|
+
// native published. A request without a tools array carries no observation, never an empty one.
|
|
551
|
+
if (options.observeToolSurface) {
|
|
552
|
+
const surface = toolSurfaceProjection(body.tools);
|
|
553
|
+
if (surface) journalEntry.record.toolSurface = surface;
|
|
554
|
+
}
|
|
485
555
|
if (primaryDispatchId) {
|
|
486
556
|
journalEntry.record.fallbackOfId = primaryDispatchId;
|
|
487
557
|
journalEntry.record.dispatchGroupId = dispatchGroupId;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pi tool-step ceiling signal (#179): the one structured record of the hub extension refusing a
|
|
3
|
+
* managed tool call at its configured step limit. The trusted extension emits it over the bridge at the
|
|
4
|
+
* actual rejection boundary; the adapter validates it here before it can classify anything. Free text
|
|
5
|
+
* that merely resembles a ceiling never parses, and an unparsable or unbound signal leaves the failure
|
|
6
|
+
* unknown, exactly as before #179.
|
|
7
|
+
*
|
|
8
|
+
* Counter semantics (the producer's contract):
|
|
9
|
+
* - unit `tool-step`: one admitted managed-tool execution attempt — the extension's `execute` entry for
|
|
10
|
+
* a hub-registered tool, after execution-budget admission succeeds and before the tool runs.
|
|
11
|
+
* - The rejected pre-effect invocation IS counted: the counter increments at that boundary, before the
|
|
12
|
+
* limit check, so the first rejection reports `count = limit + 1` and the refused call's side effect
|
|
13
|
+
* never executes.
|
|
14
|
+
* - Failed tool calls count: the counter does not observe the outcome. A counted call is an execution
|
|
15
|
+
* attempt, never a proven successful effect.
|
|
16
|
+
* - Calls refused by execution-budget admission (#102) do NOT count: admission precedes the counter.
|
|
17
|
+
* - Reset: `agent_start` zeroes the counter and opens a new turn generation; it never resets mid-turn
|
|
18
|
+
* and never carries across turns.
|
|
19
|
+
*
|
|
20
|
+
* The signal carries fixed enums, counts and identity only — never tool arguments, paths, responses or
|
|
21
|
+
* error text.
|
|
22
|
+
*/
|
|
23
|
+
export const PI_CEILING_KIND = "tool-step-ceiling" as const;
|
|
24
|
+
export const PI_CEILING_UNIT = "tool-step" as const;
|
|
25
|
+
|
|
26
|
+
export interface PiToolStepCeiling {
|
|
27
|
+
kind: typeof PI_CEILING_KIND;
|
|
28
|
+
unit: typeof PI_CEILING_UNIT;
|
|
29
|
+
/** The producer's counter after counting the rejected pre-effect invocation: always > limit. */
|
|
30
|
+
count: number;
|
|
31
|
+
/** The configured ceiling (AGENTHUB_PI_MAX_STEPS). */
|
|
32
|
+
limit: number;
|
|
33
|
+
/** The extension's session id and turn generation the rejection belongs to; the adapter binds both. */
|
|
34
|
+
sessionId: string;
|
|
35
|
+
generation: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse and validate a bridge ceiling event. Shape only — finite nonnegative safe-integer counts with
|
|
40
|
+
* count > limit (a rejection means the counted counter passed the configured limit), the fixed kind and
|
|
41
|
+
* unit, a non-empty bounded session id and a nonnegative turn generation. Session/turn binding to the
|
|
42
|
+
* current adapter state is the caller's check, not this function's.
|
|
43
|
+
*/
|
|
44
|
+
export function piToolStepCeiling(value: unknown): PiToolStepCeiling | undefined {
|
|
45
|
+
if (!value || typeof value !== "object") return undefined;
|
|
46
|
+
const e = value as Record<string, unknown>;
|
|
47
|
+
if (e.kind !== PI_CEILING_KIND || e.unit !== PI_CEILING_UNIT) return undefined;
|
|
48
|
+
if (!Number.isSafeInteger(e.count) || (e.count as number) < 1) return undefined;
|
|
49
|
+
if (!Number.isSafeInteger(e.limit) || (e.limit as number) < 0) return undefined;
|
|
50
|
+
if ((e.count as number) <= (e.limit as number)) return undefined;
|
|
51
|
+
if (typeof e.sessionId !== "string" || !e.sessionId || e.sessionId.length > 200) return undefined;
|
|
52
|
+
if (!Number.isSafeInteger(e.generation) || (e.generation as number) < 0) return undefined;
|
|
53
|
+
return { kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: e.count as number, limit: e.limit as number, sessionId: e.sessionId, generation: e.generation as number };
|
|
54
|
+
}
|
package/src/pi/extension.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assistantTokens } from "./usage.ts";
|
|
2
2
|
import { processSignature } from "./process-signature.ts";
|
|
3
|
+
import { PI_CEILING_KIND, PI_CEILING_UNIT } from "./ceiling.ts";
|
|
3
4
|
type ExtensionAPI = any;
|
|
4
5
|
type BudgetUnit = "model_calls" | "tool_calls";
|
|
5
6
|
|
|
@@ -20,6 +21,7 @@ let lastActivity = 0;
|
|
|
20
21
|
let usageSeq = 0;
|
|
21
22
|
let forcedFailure = "";
|
|
22
23
|
let turnGeneration = 0;
|
|
24
|
+
let sessionId = "";
|
|
23
25
|
const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
|
|
24
26
|
let shutdown: (() => void) | undefined;
|
|
25
27
|
let runtimeCtx: any;
|
|
@@ -103,6 +105,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
103
105
|
modelRegistry = ctx.modelRegistry;
|
|
104
106
|
shutdown = ctx.shutdown;
|
|
105
107
|
const state = ctx.sessionManager.getHeader();
|
|
108
|
+
sessionId = String(state?.id ?? "");
|
|
106
109
|
try {
|
|
107
110
|
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
108
111
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
@@ -163,9 +166,24 @@ export default function(pi: ExtensionAPI): void {
|
|
|
163
166
|
if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
|
|
164
167
|
pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
|
|
165
168
|
if (!(await admitBudget("tool_calls")).allowed) return { content: [{ type: "text", text: `error: ${forcedFailure}` }], details: {}, isError: true };
|
|
166
|
-
if (toolSteps++ >= maxSteps) {
|
|
169
|
+
if (toolSteps++ >= maxSteps) {
|
|
170
|
+
const reason = `Pi tool step limit ${maxSteps} reached`;
|
|
171
|
+
forcedFailure = reason;
|
|
172
|
+
// #179: the structured ceiling signal at the actual rejection boundary, before the failure
|
|
173
|
+
// event, so the adapter binds it while the turn still owns it. The counter already counts the
|
|
174
|
+
// rejected pre-effect invocation (toolSteps++ above); its side effect never executes. The
|
|
175
|
+
// reason text stays the failure record; the signal adds the validated counts.
|
|
176
|
+
try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId, generation: turnGeneration }); } catch { /* the agent_end failure below remains authoritative */ }
|
|
177
|
+
await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
|
|
178
|
+
runtimeCtx?.abort?.();
|
|
179
|
+
return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true };
|
|
180
|
+
}
|
|
167
181
|
const result = await post("/tool", { name: raw.name, args: params, toolCallId });
|
|
168
|
-
|
|
182
|
+
const text = String(result.text ?? result);
|
|
183
|
+
// Pi 1.0.1 reads isError === true alone (#181). The bridge computes `failed` with the
|
|
184
|
+
// managed-tool failure contract (toolResultFailed); the extension never re-parses the text.
|
|
185
|
+
// A bridge too old to send `failed` leaves the result unflagged, exactly as before the fix.
|
|
186
|
+
return { content: [{ type: "text", text }], details: {}, isError: result.failed === true };
|
|
169
187
|
} });
|
|
170
188
|
}
|
|
171
189
|
}
|
|
@@ -2,13 +2,14 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The one process-identity contract behind the Pi owner claim
|
|
6
|
-
* re-inspection
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
13
|
*/
|
|
13
14
|
export function processSignature(pid: number): string | undefined {
|
|
14
15
|
try {
|