@evoclock/pi-agentic-driver 0.4.3 → 0.5.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/PROVENANCE.md CHANGED
@@ -67,3 +67,8 @@ This repository is the public package source for `@evoclock/pi-agentic-driver`.
67
67
  native-TUI predicate and remain fail-closed. This release also adds the
68
68
  portable `templates/AGENTS.md` repository contract. The release artifact is
69
69
  built from this repository revision.
70
+ - Release 0.5.0 adds session-scoped microVM activation. Users enable or disable
71
+ the switch from the native Pi TUI for the current session. The model cannot
72
+ change it, the switch is not persisted, and each microVM run still requires
73
+ native confirmation. Headless sessions fail closed. The existing trusted
74
+ facts, fixed argv, receipt validation, and cleanup checks remain unchanged.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-agentic-driver v0.4.3
1
+ # pi-agentic-driver v0.5.0
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/Yamagane-origami.png" alt="pi-agentic-driver — Yamagane origami mark" width="140"/>
@@ -6,7 +6,8 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
9
- <img src="https://img.shields.io/badge/version-0.4.3-blue?style=flat" alt="Version 0.4.3"/>
9
+ <a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
10
+ <img src="https://img.shields.io/badge/version-0.5.0-blue?style=flat" alt="Version 0.5.0"/>
10
11
  <img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
11
12
  <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
12
13
  <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
@@ -32,12 +33,23 @@ Extensions for [Pi](https://github.com/earendil-works/pi-coding-agent):
32
33
  advisory code review, bounded role communication, and governed isolation
33
34
  proofs for agentic workflows.
34
35
 
36
+ ## Shipped features
37
+
38
+ | Tool | What it does | Status |
39
+ |------|--------------|--------|
40
+ | `code_phage` | Reviews a plan against a stated goal before code is written. | shipped |
41
+ | `agentic_herdr_communication` | Exchanges marked reports with worker agents; never grants authority. | shipped |
42
+ | `agentic_herdr_spawn_worker` | Starts one Pi worker in a pane or tab, with native confirmation. | shipped |
43
+ | `agentic_aidr` | Reviews writing for clarity, simplicity, brevity, and humanity. | shipped |
44
+ | `agentic_linux_microvm_cutover` | Runs one job in a throwaway QEMU/KVM virtual machine on a Linux host. | user-enabled, native confirmation |
45
+ | `agentic_worker_dispatch` | Runs bounded worker journeys and observes worker liveness. | shipped |
46
+
35
47
  **Status: active development and testing.** Each extension ships only after
36
48
  it passes fixture-based acceptance, native tests, live-session checks, and
37
49
  independent model review. You can install released components. This README
38
50
  lists pending components for transparency; pending components are not packaged.
39
51
 
40
- ## Keeping work bounded
52
+ ## Code review and planning
41
53
 
42
54
  *Extensions that review, route, and bound what an agent does.*
43
55
 
@@ -83,9 +95,9 @@ only, not a runtime dependency).
83
95
  - **inventory refresh** — Git-aware codebase inventory regeneration with
84
96
  verification receipts, so prior-art matching stays honest.
85
97
 
86
- ## Communicating without granting authority
98
+ ## Multi-agent communication
87
99
 
88
- *Extensions for bounded coordination between agents and boards.*
100
+ *Extensions for bounded coordination between agents.*
89
101
 
90
102
  <details>
91
103
  <summary><strong>herdr-communication — bounded role communication</strong> <em>(released, 0.2.1)</em></summary>
@@ -119,7 +131,6 @@ configured Pi worker roles running under [Herdr](https://herdr.dev/)
119
131
 
120
132
  <details>
121
133
  <summary><strong>herdr-lifecycle — role-labelled worker dispatch</strong> <em>(released, 0.2.1)</em></summary>
122
-
123
134
  `agentic_herdr_spawn_worker` turns one natural-language request into Herdr's
124
135
  native documented lifecycle. Select `right`, `below`, or `tab`; give the
125
136
  worker a safe role label; choose a model from the active Pi model roster; and
@@ -161,15 +172,43 @@ for the wider task/model-routing and remote-session workflow.
161
172
  affinity is an optimisation, never authority: an incompatible or
162
173
  unavailable lane yields an explicit review-required result, never silent
163
174
  model substitution. Routing grants no dispatch or shell authority.
164
- - **worker pulse** — liveness observation and dispatch-eligibility
165
- observation across role lanes: which agents are alive, what state they
166
- are in, and what is ready for work. This observation grants no authority;
167
- the system cannot dispatch planned work without it.
175
+ - **worker pulse** — now part of the shipped `agentic_worker_dispatch` tool
176
+ (see below). Broader role-lane observation remains future work.
168
177
  - **task-ledger integration for planned work** — agents read and act
169
178
  within the task ledger's card states (what is dispatchable, in progress,
170
179
  blocked) without owning board authority: no admission, completion,
171
180
  reconciliation, or migration by the agent itself.
172
181
 
182
+ <details>
183
+ <summary><strong>herdr-dispatch — continuous worker journeys</strong> <em>(released, 0.5.0)</em></summary>
184
+
185
+ `agentic_worker_dispatch` runs bounded worker journeys and observes worker
186
+ liveness. Two actions:
187
+
188
+ - **pulse** reports whether a worker role is alive, its current state, and
189
+ whether it is dispatch-eligible.
190
+ - **dispatch** runs one journey. The worker works through the existing task
191
+ sequence (pending, unblocked, unowned items) and handles one
192
+ prompt-and-report exchange per task. A journey runs at most `maxSteps`
193
+ steps (default 50, cap 200).
194
+
195
+ Continuous mode is the default: the journey keeps going until the worker
196
+ finishes the queue or reaches the step bound. Turn-by-turn mode stops after
197
+ each step and is explicit opt-in. Each journey emits one collated marked
198
+ report that covers every step.
199
+
200
+ A hung worker ends the journey with an explicit `worker-hung` state. A
201
+ worker hangs when it never reaches `idle` across the observed exchange
202
+ cycle, or when its exchange stalls. You can then spawn a replacement agent
203
+ through the guarded lifecycle boundary. The replacement resumes the same
204
+ pending tasks; the journey reuses existing task cards and never duplicates
205
+ them. The stuck exchange is never resent to the same worker.
206
+
207
+ Journeys never create, own, or complete task cards themselves, never retry
208
+ silently, and return results as untrusted evidence.
209
+
210
+ </details>
211
+
173
212
  ## Writing clearly
174
213
 
175
214
  *AI;DR (AI; Didn't Read) keeps technical writing clear without flattening the writer's voice.*
@@ -205,23 +244,47 @@ bounded inputs, atomic replacement, drift checks, and exact write verification.
205
244
 
206
245
  </details>
207
246
 
208
- ## Executing in isolation
247
+ ## Sandboxed execution
209
248
 
210
- *Extensions that prove automation ran and stopped exactly as declared.*
249
+ *Extensions that run agent jobs in a sealed environment and prove it.*
211
250
 
212
- **In development:**
251
+ **Available in this release:**
213
252
 
214
253
  <details>
215
- <summary><strong>microVM-isolation proof</strong> <em>(qualified, activation deferred)</em></summary>
216
-
217
- `agentic_linux_microvm_cutover` is the reviewed native proof for a transient
218
- QEMU/KVM guest on `linux-backend`. It returns a non-authorizing receipt with
219
- the marker, hashes, isolation context, checked domain teardown, and ACL
220
- restoration. The live qualification returned `VERIFIED`.
221
-
222
- The system reserves runtime isolation for planned and automated execution
223
- paths. It excludes runtime isolation from ordinary ad-hoc work. Public
224
- activation remains deferred until the system enables those paths.
254
+ <summary><strong>microVM isolation</strong> <em>(user-enabled, native confirmation)</em></summary>
255
+
256
+ The `agentic_linux_microvm_cutover` tool runs a single job inside a throwaway
257
+ QEMU/KVM virtual machine on a Linux host. The job cannot reach the host, the
258
+ network, or anything else outside the machine. The system deletes the machine
259
+ after the job ends.
260
+
261
+ Each session starts with isolation disabled. To enable it, run
262
+ `/agentic-isolation-enable` in the interactive Pi TUI and confirm. The model
263
+ cannot run this command. Each microVM run asks for its own confirmation. To
264
+ turn isolation off, run `/agentic-isolation-disable`. The switch never saves
265
+ to settings. Headless sessions stay blocked.
266
+
267
+ **Setup and run.** You choose where the microVM runs. The model cannot choose
268
+ it silently — it only relays your words, and every target choice requires your
269
+ native confirmation dialog before anything is saved or run.
270
+
271
+ 1. Ask your agent to run the microVM cutover and tell it which machine to use
272
+ (e.g. "run the microVM proof on deploy@192.168.1.50", or "...on this
273
+ machine"). The first run asks you to confirm natively: it names the
274
+ machine and states that your choice is saved to your Pi config. On your
275
+ Yes, the choice is saved and the run continues in the same invocation.
276
+ 2. Run `/agentic-isolation-enable` to enable isolation for this session
277
+ (native confirmation; not saved to settings).
278
+ 3. Each cutover run asks for its own confirmation. Saved targets are reused
279
+ without asking again where the microVM runs.
280
+
281
+ The tool discovers the technical details from the machine itself. It reads the
282
+ architecture, kernel, and libvirt URI at probe time. You configure nothing.
283
+ Before any run, the probe checks three requirements: `/dev/kvm` exists and is
284
+ accessible. Libvirt reports the system-level driver (`qemu:///system`). A
285
+ matching `qemu-system-<arch>` binary is present. A failed check denies the run
286
+ before anything happens. Note: the shipped fixture guest is x86_64-built, so
287
+ the full proof currently needs an x86_64 KVM host.
225
288
 
226
289
  </details>
227
290
 
@@ -241,9 +304,9 @@ activation remains deferred until the system enables those paths.
241
304
  headless runs where no human can confirm, the system refuses destructive
242
305
  commands rather than silently allowing them.
243
306
 
244
- ## Managing context pressure, compaction, and avoiding lossy handover
307
+ ## Session continuity
245
308
 
246
- *Extensions for session continuity when context runs out or a session ends.*
309
+ *Managing context pressure, compaction, and avoiding lossy handover.*
247
310
 
248
311
  **In development:**
249
312
 
@@ -283,7 +346,7 @@ activation remains deferred until the system enables those paths.
283
346
  to a verified assignment, so consequential Git operations carry their
284
347
  own recorded provenance.
285
348
 
286
- ## Trust in the extension set itself
349
+ ## Package integrity
287
350
 
288
351
  *Extensions that keep the installed set honest and the record bounded.*
289
352
 
@@ -306,19 +369,50 @@ acceptance with all prohibited effects absent.
306
369
 
307
370
  ### Portable repository contract
308
371
 
309
- `templates/AGENTS.md` is a portable starting contract for repositories that
310
- use Pi agents. It keeps routine work low-friction, requires bounded tasks and
311
- realistic tests, and treats reports as non-authorizing evidence. It does not
312
- overwrite repository-specific instructions or create a second authority store.
372
+ `templates/AGENTS.md` is a starting contract you can copy into any repository
373
+ that uses Pi agents. It asks for bounded tasks and realistic tests, and
374
+ treats agent reports as untrusted evidence. It never overwrites an existing
375
+ `AGENTS.md`; add your project rules below it.
313
376
 
314
377
  ## Install
315
378
 
379
+ From npm:
380
+
316
381
  ```sh
317
- pi install <tarball-or-npm-package>
382
+ pi install npm:@evoclock/pi-agentic-driver
383
+ ```
384
+
385
+ Or from Git at a pinned tag:
386
+
387
+ ```sh
388
+ pi install git:github.com/evoclock/pi-agentic-driver@v0.5.0
318
389
  ```
319
390
 
320
391
  Released extensions load standalone; neither requires the other.
321
392
 
393
+ ### Choose which extensions load
394
+
395
+ A normal install loads all five extensions by default. You can load only the
396
+ extensions you want by using the object form in your Pi settings:
397
+
398
+ ```json
399
+ {
400
+ "packages": [
401
+ {
402
+ "source": "npm:@evoclock/pi-agentic-driver@0.5.0",
403
+ "extensions": [
404
+ "extensions/aidr.ts",
405
+ "extensions/code-phage.js"
406
+ ]
407
+ }
408
+ ]
409
+ }
410
+ ```
411
+
412
+ The package also includes `herdr-communication.ts`, `herdr-lifecycle.ts`, and
413
+ `linux-microvm.ts`. Use `pi config` to enable or disable individual resources
414
+ from an installed package. You are not required to use every extension.
415
+
322
416
  ## License
323
417
 
324
418
  AGPL-3.0-or-later with author-attribution additional terms (Section 7(b));
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
3
+ "schema": "agentic-driver.microvm-target.v1",
4
+ "sshTarget": "REPLACE-WITH-user@host-or-ip"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
3
+ "schema": "agentic-driver.microvm-target.v1",
4
+ "sshTarget": "REPLACE-WITH-user@host-or-ip"
5
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ export default async function herdrDispatchPi(pi) {
5
+ const dispatch = await import(new URL("../scripts/enforcement/herdr_async_dispatch_pi.js", import.meta.url).href);
6
+ const lifecycle = await import(new URL("../scripts/enforcement/herdr_lifecycle_pi.js", import.meta.url).href);
7
+
8
+ // Production replacement seam: an unresponsive-session handoff spawns through the
9
+ // existing guarded herdr-lifecycle boundary only — executeHerdrSpawnWorker
10
+ // performs native confirmation, trusted-repository resolution, installed
11
+ // model-roll resolution, fixed argv, and shell:false. No raw pane/agent
12
+ // management is added; the receipt is observed state, never authority.
13
+ const spawnReplacement = ({ role, repository, model, context, signal }) =>
14
+ lifecycle.executeHerdrSpawnWorker(
15
+ {
16
+ placement: "tab",
17
+ role,
18
+ model,
19
+ repository,
20
+ },
21
+ context,
22
+ {},
23
+ signal,
24
+ );
25
+
26
+ return dispatch.registerWorkerDispatchInterface(pi, { spawnReplacement });
27
+ }
@@ -1,10 +1,14 @@
1
- import { registerLinuxMicroVMCutoverInterface } from "../scripts/enforcement/linux_microvm_cutover_pi.js";
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ import { registerLinuxMicroVMCutoverInterface, registerIsolationSwitchCommands, createIsolationSwitch } from "../scripts/enforcement/linux_microvm_cutover_pi.js";
2
5
 
3
6
  export default function registerLinuxMicroVMCutover(pi: any) {
4
- // Registration is limited to the activation-deferred cutover interface:
5
- // native confirmation, trusted facts, and fixed boundaries are preserved by
6
- // the interface itself; the transient runtime is local to this
7
- // registration and only carries adapter state it observes at cutover time.
8
- const runtime: { workMode?: string; workContext?: unknown; isolationEnabled?: boolean } = {};
9
- return registerLinuxMicroVMCutoverInterface(pi, { runtime });
7
+ // Registration is limited to the activation-deferred cutover interface and
8
+ // the session-scoped isolation switch commands. The legacy work-mode
9
+ // lifecycle is not registered: agentic_work_mode is not exposed.
10
+ // The isolation switch state is created per registration, so each fresh
11
+ // extension registration (session) starts disabled.
12
+ const isolationSwitch = registerIsolationSwitchCommands(pi, { isolationSwitch: createIsolationSwitch() });
13
+ registerLinuxMicroVMCutoverInterface(pi, { isolationSwitch });
10
14
  }
package/package.json CHANGED
@@ -1,17 +1,42 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "keywords": [
8
8
  "pi-package",
9
9
  "pi-extension",
10
- "agentic-driver"
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "agentic-driver",
13
+ "agentic-workflows",
14
+ "multi-agent",
15
+ "sub-agents",
16
+ "agent-coordination",
17
+ "worker-dispatch",
18
+ "continuous-agents",
19
+ "task-orchestration",
20
+ "herdr",
21
+ "code-review",
22
+ "writing-review",
23
+ "ai-dr",
24
+ "microvm",
25
+ "isolation",
26
+ "containers",
27
+ "git-workflow",
28
+ "git-safety",
29
+ "context-management",
30
+ "compaction",
31
+ "checkpoint-recovery",
32
+ "developer-tools",
33
+ "automation",
34
+ "governance"
11
35
  ],
12
36
  "files": [
13
37
  "extensions/code-phage.js",
14
38
  "extensions/herdr-communication.ts",
39
+ "extensions/herdr-dispatch.ts",
15
40
  "lib/adapters/diff-scope.mjs",
16
41
  "lib/adapters/evidence.mjs",
17
42
  "lib/adapters/narrative.mjs",
@@ -21,10 +46,13 @@
21
46
  "lib/python_ast_metrics.py",
22
47
  "lib/typescript_ast_metrics.mjs",
23
48
  "scripts/enforcement/herdr_communication_pi.js",
49
+ "scripts/enforcement/herdr_async_dispatch_pi.js",
24
50
  "README.md",
25
51
  "LICENSE",
26
52
  "extensions/herdr-lifecycle.ts",
27
53
  "scripts/enforcement/herdr_lifecycle_pi.js",
54
+ "config/microvm-target.v1.example.json",
55
+ "config/microvm-target.v1.json",
28
56
  "config/herdr-worker-repositories.v1.json",
29
57
  "extensions/linux-microvm.ts",
30
58
  "scripts/enforcement/linux_microvm_cutover_pi.js",
@@ -0,0 +1,402 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Async continuous worker dispatch and pulse. One bounded step on top of the
5
+ // shipped herdr-communication interface: a worker progresses through an
6
+ // existing task sequence (observed, never created) and emits one collated
7
+ // marked report. Continuous mode is the default; interactive turn-by-turn is
8
+ // explicit opt-in. Journeys are bounded by step count, never by a wall-clock
9
+ // timeout; cancellation and terminal failure are explicit and there are no
10
+ // invisible retries or resends.
11
+
12
+ import {
13
+ executeHerdrCommunication,
14
+ HERDR_REPORT_MARKERS,
15
+ HERDR_COMMUNICATION_SCHEMA,
16
+ HERDR_COMMUNICATION_ACTIONS,
17
+ } from "./herdr_communication_pi.js";
18
+ import { isNativeTuiContext } from "./native_tui_context.js";
19
+
20
+ export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
21
+ export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
22
+ export const WORKER_DISPATCH_MODES = Object.freeze(["continuous", "turn-by-turn"]);
23
+ export const DEFAULT_MODE = "continuous";
24
+ const DEFAULT_JOURNEY_STEPS = 50;
25
+ const MAX_JOURNEY_STEPS = 200;
26
+ const MAX_REPORT_BYTES = 32 * 1024;
27
+ const REGISTRATIONS = new WeakSet();
28
+
29
+ // Terminal journey states. Only `cancelled` and `failed` are failures; every
30
+ // other terminal state is an observed outcome, and no state is retried.
31
+ export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
32
+ "completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
33
+ ]);
34
+
35
+ export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
36
+ type: "object",
37
+ additionalProperties: false,
38
+ properties: {
39
+ action: { type: "string", enum: ["dispatch", "pulse"] },
40
+ role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
41
+ mode: { type: "string", enum: WORKER_DISPATCH_MODES },
42
+ maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
43
+ stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
44
+ model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
45
+ },
46
+ required: ["action", "role"],
47
+ allOf: [
48
+ {
49
+ if: { properties: { action: { const: "dispatch" } }, required: ["action"] },
50
+ then: { required: ["stepPrompt"] },
51
+ },
52
+ ],
53
+ });
54
+
55
+ class WorkerDispatchError extends Error {
56
+ constructor(code, message, status = "blocked") {
57
+ super(message);
58
+ this.name = "WorkerDispatchError";
59
+ this.code = code;
60
+ this.status = status;
61
+ }
62
+ }
63
+
64
+ function dispatchError(code, message, status = "blocked") {
65
+ return new WorkerDispatchError(code, message, status);
66
+ }
67
+
68
+ function result(details) {
69
+ return { ok: details.ok === true, ...details };
70
+ }
71
+
72
+ function failure(action, error) {
73
+ const code = error?.code || "dispatch-failed";
74
+ return {
75
+ schema: WORKER_DISPATCH_SCHEMA,
76
+ ok: false,
77
+ action,
78
+ status: error?.status || "blocked",
79
+ code,
80
+ error: String(error?.message || error).slice(0, 512),
81
+ nonAuthorizing: true,
82
+ persisted: false,
83
+ };
84
+ }
85
+
86
+ // Worker pulse: liveness, current state, and dispatch eligibility, observed
87
+ // through the existing non-authorizing get seam. Grants no authority.
88
+ export async function workerPulse(role, context, options = {}, signal) {
89
+ if (typeof role !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(role) || role.length > 64) {
90
+ throw dispatchError("role-invalid", "worker pulse requires a valid non-coordinator role name", "denied");
91
+ }
92
+ const observation = await executeHerdrCommunication(
93
+ { action: "get", role },
94
+ context,
95
+ options.communication ?? options,
96
+ signal,
97
+ );
98
+ if (observation.ok !== true) {
99
+ throw dispatchError(observation.code || "pulse-failed",
100
+ observation.reason || observation.error || "worker pulse could not observe the role",
101
+ observation.status || "blocked");
102
+ }
103
+ const agent = observation.observation ?? {};
104
+ const status = typeof agent.status === "string" ? agent.status : "unknown";
105
+ return {
106
+ role,
107
+ alive: status !== "unknown" && status !== "gone",
108
+ status,
109
+ dispatchEligible: status === "idle",
110
+ observed: true,
111
+ };
112
+ }
113
+
114
+ // Queue progression observes the next dispatchable item without creating
115
+ // duplicate task cards. The injected task store is read-only here; marking a
116
+ // dispatched task done is the worker's job through its own task tools.
117
+ export function nextDispatchableTask(taskStore) {
118
+ if (!taskStore || typeof taskStore.list !== "function") {
119
+ throw dispatchError("task-store-invalid", "a read-only task store is required to observe the queue", "denied");
120
+ }
121
+ const tasks = taskStore.list();
122
+ if (!Array.isArray(tasks)) throw dispatchError("task-store-invalid", "the task store did not return a task list", "denied");
123
+ return tasks.find((task) => task
124
+ && task.status === "pending"
125
+ && !(Array.isArray(task.blockedBy) && task.blockedBy.length > 0)
126
+ && !task.owner) ?? null;
127
+ }
128
+
129
+ // Tasks already dispatched during this journey are excluded from re-selection
130
+ // without mutating the store: the journey keeps its own dispatched set.
131
+ function selectNextTask(taskStore, dispatched) {
132
+ for (let attempt = 0; attempt < 32; attempt += 1) {
133
+ const task = nextDispatchableTask(taskStore);
134
+ if (!task) return null;
135
+ if (!dispatched.has(task.id)) return task;
136
+ // The observed head is already dispatched on this journey; ask the store
137
+ // to advance by observing its next state. If the store never changes, the
138
+ // loop exits via the attempt bound and the journey ends as exhausted.
139
+ if (typeof taskStore.observeAdvance === "function") taskStore.observeAdvance(task.id);
140
+ else return null;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function journeyReceipt(journey) {
146
+ const body = [
147
+ "[WORKER_JOURNEY_REPORT_BEGIN]",
148
+ `mode: ${journey.mode}`,
149
+ `role: ${journey.role}`,
150
+ `steps: ${journey.steps.length}`,
151
+ `status: ${journey.status}`,
152
+ ...(journey.handoff ? [`handoff: attempted=${journey.handoff.attempted} ok=${journey.handoff.ok ?? false} role=${journey.handoff.role ?? journey.role} reason=${journey.handoff.reason ?? "none"}`] : []),
153
+ ...journey.steps.map((step, index) =>
154
+ `step ${index + 1}: task=${step.taskId ?? "none"} status=${step.status} report=${step.report ?? "(none)"}`),
155
+ "[WORKER_JOURNEY_REPORT_END]",
156
+ ].join("\n");
157
+ if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
158
+ return `${body.slice(0, MAX_REPORT_BYTES)}\n[WORKER_JOURNEY_REPORT_TRUNCATED]`;
159
+ }
160
+ return body;
161
+ }
162
+
163
+ // One continuous journey. Each step: pulse (liveness + eligibility), observe
164
+ // the next dispatchable item, one prompt exchange (no retry on any failure),
165
+ // read the marked report, collate. Bounded by maxSteps, never wall-clock.
166
+ // A worker that is not idle within an observed exchange cycle is unresponsive for
167
+ // dispatch purposes and ends the journey explicitly as worker-unresponsive.
168
+ export async function runWorkerJourney(params, context, options = {}, signal) {
169
+ const mode = params.mode ?? DEFAULT_MODE;
170
+ const maxSteps = params.maxSteps ?? DEFAULT_JOURNEY_STEPS;
171
+ if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > MAX_JOURNEY_STEPS) {
172
+ return failure("dispatch", dispatchError("max-steps-invalid",
173
+ `maxSteps must be an integer between 1 and ${MAX_JOURNEY_STEPS}`, "denied"));
174
+ }
175
+ const role = params.role;
176
+ const stepPrompt = params.stepPrompt;
177
+ const taskStore = options.taskStore;
178
+ const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
179
+ const journey = { mode, role, steps: [], status: "failed", code: null, handoff: null };
180
+ const dispatched = new Set();
181
+ const communicationOptions = options.communication ?? options;
182
+
183
+ const finish = (status) => ({
184
+ schema: WORKER_DISPATCH_SCHEMA,
185
+ ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
186
+ action: "dispatch",
187
+ mode,
188
+ role,
189
+ status,
190
+ steps: journey.steps,
191
+ stepCount: journey.steps.filter((step) => step.status === "done").length,
192
+ code: journey.code,
193
+ report: journeyReceipt(journey),
194
+ reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
195
+ handoff: journey.handoff,
196
+ nonAuthorizing: true,
197
+ persisted: false,
198
+ });
199
+
200
+ // Explicit unstuck path: with native confirmation, spin up a replacement
201
+ // worker for the same trusted repository/role through the existing
202
+ // herdr-lifecycle spawn boundary (fixed argv, shell:false) and resume the
203
+ // pending task sequence. Reuses the same task cards; never duplicates them.
204
+ const handoffToReplacement = async (reason, taskId = null) => {
205
+ journey.status = "worker-unresponsive";
206
+ journey.code = "worker-unresponsive";
207
+ journey.steps.push({ step: journey.steps.length + 1, taskId, status: "worker-unresponsive", error: reason });
208
+ if (!spawnReplacement) {
209
+ journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
210
+ return finish("worker-unresponsive");
211
+ }
212
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
213
+ journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
214
+ return finish("worker-unresponsive");
215
+ }
216
+ let confirmed;
217
+ try {
218
+ confirmed = await context.ui.confirm("Spin up replacement worker", [
219
+ `Agent session for role ${role} became unresponsive (${reason}).`,
220
+ "Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
221
+ "The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
222
+ ].join("\n"));
223
+ } catch (error) {
224
+ journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
225
+ return finish("worker-unresponsive");
226
+ }
227
+ if (confirmed !== true) {
228
+ journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
229
+ return finish("worker-unresponsive");
230
+ }
231
+ let spawned;
232
+ try {
233
+ spawned = await spawnReplacement({ role, repository: options.repository, model: options.model, context, signal });
234
+ } catch (error) {
235
+ journey.handoff = { attempted: true, ok: false, error: String(error?.message || error).slice(0, 256) };
236
+ return finish("worker-unresponsive");
237
+ }
238
+ journey.handoff = {
239
+ attempted: true,
240
+ ok: spawned?.ok === true,
241
+ role: spawned?.role ?? role,
242
+ repository: spawned?.repository,
243
+ modelArgv: spawned?.modelArgv,
244
+ nonAuthorizing: true,
245
+ };
246
+ return finish("worker-unresponsive");
247
+ };
248
+
249
+ if (!HERDR_COMMUNICATION_ACTIONS.includes) { /* unreachable guard */ }
250
+ if (mode !== "continuous" && mode !== "turn-by-turn") {
251
+ return failure("dispatch", dispatchError("mode-invalid", "dispatch mode must be continuous or turn-by-turn", "denied"));
252
+ }
253
+ if (!taskStore || typeof taskStore.list !== "function") {
254
+ return failure("dispatch", dispatchError("task-store-invalid", "a read-only task store is required", "denied"));
255
+ }
256
+ if (signal?.aborted) return finish("cancelled");
257
+
258
+ for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
259
+ if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
260
+
261
+ // Pulse: liveness and dispatch eligibility, no authority.
262
+ let pulse;
263
+ try {
264
+ pulse = await workerPulse(role, context, communicationOptions, signal);
265
+ } catch (error) {
266
+ journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
267
+ journey.code = error?.code || "pulse-failed";
268
+ journey.steps.push({ step: stepIndex, taskId: null, status: journey.status, error: String(error?.message || error).slice(0, 256) });
269
+ return finish(journey.status);
270
+ }
271
+ if (!pulse.alive) {
272
+ return handoffToReplacement("worker role is not alive");
273
+ }
274
+ if (!pulse.dispatchEligible) {
275
+ if (pulse.status === "blocked") {
276
+ journey.status = "role-blocked";
277
+ journey.code = "role_blocked";
278
+ journey.steps.push({ step: stepIndex, taskId: null, status: "role-blocked", workerStatus: pulse.status });
279
+ return finish("role-blocked");
280
+ }
281
+ // Not idle within an observed exchange cycle: unresponsive for dispatch.
282
+ return handoffToReplacement(`worker not idle in the observed exchange cycle (status: ${pulse.status})`);
283
+ }
284
+
285
+ // Observe the next dispatchable item; never create or mutate cards.
286
+ let task;
287
+ try {
288
+ task = selectNextTask(taskStore, dispatched);
289
+ } catch (error) {
290
+ journey.status = "failed";
291
+ journey.steps.push({ step: stepIndex, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
292
+ return finish("failed");
293
+ }
294
+ if (!task) {
295
+ journey.status = "exhausted";
296
+ journey.steps.push({ step: stepIndex, taskId: null, status: "exhausted" });
297
+ return finish("exhausted");
298
+ }
299
+
300
+ // Interactive opt-in: stop after each step for explicit approval.
301
+ if (mode === "turn-by-turn" && stepIndex > 1) {
302
+ journey.status = "waiting-approval";
303
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "waiting-approval" });
304
+ return finish("waiting-approval");
305
+ }
306
+
307
+ // Consequential dispatch requires native confirmation, once per step.
308
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
309
+ journey.status = "failed";
310
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
311
+ return finish("failed");
312
+ }
313
+ let confirmed;
314
+ try {
315
+ confirmed = await context.ui.confirm("Dispatch task to worker", [
316
+ `Dispatch one bounded step to role ${role}?`,
317
+ `Task: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`,
318
+ `Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
319
+ "One prompt exchange, no retries; the worker returns one marked report.",
320
+ ].join("\n"));
321
+ } catch (error) {
322
+ journey.status = "failed";
323
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
324
+ return finish("failed");
325
+ }
326
+ if (confirmed !== true) {
327
+ journey.status = "cancelled";
328
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
329
+ return finish("cancelled");
330
+ }
331
+
332
+ // One prompt exchange. Any failure is terminal for the journey; there is
333
+ // no invisible retry or resend.
334
+ const exchange = await executeHerdrCommunication(
335
+ { action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
336
+ context,
337
+ communicationOptions,
338
+ signal,
339
+ );
340
+ if (exchange.ok !== true) {
341
+ const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "process_timeout";
342
+ if (unresponsive) {
343
+ return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
344
+ }
345
+ journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";
346
+ journey.code = exchange.code || "exchange-failed";
347
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: journey.status, error: exchange.reason || exchange.error || exchange.code });
348
+ return finish(journey.status);
349
+ }
350
+ journey.steps.push({
351
+ step: stepIndex,
352
+ taskId: task.id,
353
+ status: "done",
354
+ workerStatus: exchange.agentStatus,
355
+ report: exchange.report,
356
+ });
357
+ dispatched.add(task.id);
358
+ }
359
+
360
+ journey.status = "completed";
361
+ return finish("completed");
362
+ // Unreachable in correct use: every step either dispatches one pending
363
+ // task, or the queue observation returns null and the journey ends with
364
+ // "exhausted". A task store that never drains hits maxSteps and lands here;
365
+ // "completed" then reflects the step bound, and callers inspect stepCount.
366
+ }
367
+
368
+ export function registerWorkerDispatchInterface(pi, options = {}) {
369
+ if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
370
+ REGISTRATIONS.add(pi);
371
+ pi.registerTool({
372
+ name: WORKER_DISPATCH_TOOL,
373
+ label: "Worker Dispatch And Pulse",
374
+ description: "Observe worker liveness (pulse) or run one bounded continuous worker journey over the existing task sequence. Continuous mode is the default; turn-by-turn is explicit opt-in. Journeys emit one collated marked report, never create task cards, never retry, and are bounded by step count, not wall-clock.",
375
+ promptSnippet: "Use agentic_worker_dispatch to pulse a worker or run one bounded continuous journey over the existing task sequence; it observes dispatchable tasks without creating cards and grants no authority.",
376
+ promptGuidelines: [
377
+ "agentic_worker_dispatch pulse observes liveness, state, and dispatch eligibility without granting authority.",
378
+ "agentic_worker_dispatch dispatch runs at most maxSteps single-exchange steps; any exchange failure ends the journey explicitly with no retry or resend.",
379
+ ],
380
+ parameters: WORKER_DISPATCH_PARAMETERS,
381
+ async execute(_id, params, signal, _update, context) {
382
+ let value;
383
+ if (params?.action === "pulse") {
384
+ try {
385
+ const pulse = await workerPulse(params.role, context, options, signal);
386
+ value = { schema: WORKER_DISPATCH_SCHEMA, ok: true, action: "pulse", ...pulse, nonAuthorizing: true, persisted: false };
387
+ } catch (error) {
388
+ value = failure("pulse", error);
389
+ }
390
+ } else {
391
+ value = await runWorkerJourney(params ?? {}, context, options, signal);
392
+ }
393
+ return {
394
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
395
+ details: value,
396
+ };
397
+ },
398
+ });
399
+ }
400
+
401
+ export { HERDR_REPORT_MARKERS, HERDR_COMMUNICATION_SCHEMA };
402
+ export default registerWorkerDispatchInterface;
@@ -2,20 +2,46 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { readFileSync } from "node:fs";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnSync } from "node:child_process";
9
+ import { homedir } from "node:os";
9
10
  import { isNativeTuiContext } from "./native_tui_context.js";
10
11
 
11
12
  export const LINUX_MICROVM_CUTOVER_TOOL = "agentic_linux_microvm_cutover";
12
13
  export const LINUX_MICROVM_CUTOVER_SCHEMA = "agentic-driver.linux-microvm-cutover.v1";
13
14
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
14
15
  const REMOTE_FIXTURE = join(SCRIPT_DIR, "linux_microvm_remote_fixture.sh");
16
+ const TARGET_EXAMPLE = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.example.json");
17
+ const TARGET_PACKAGE_CONFIG = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.json");
18
+ // The saved target config lives in the Pi coding-agent config directory
19
+ // (PI_CODING_AGENT_DIR when set, otherwise ~/.pi/agent), matching how Pi
20
+ // resolves its own config.
21
+ function resolveTargetUserConfigPath(env = process.env) {
22
+ const agentDir = env.PI_CODING_AGENT_DIR?.trim()
23
+ ? join(env.PI_CODING_AGENT_DIR.trim(), "config")
24
+ : join(homedir(), ".pi", "agent", "config");
25
+ return join(agentDir, "microvm-target.v1.json");
26
+ }
27
+ const TARGET_USER_CONFIG = resolveTargetUserConfigPath();
28
+ const TARGET_SCHEMA = "agentic-driver.microvm-target.v1";
15
29
  const REGISTRATIONS = new WeakSet();
16
- const PLANNED_ISOLATION_MODES = new Set(["planned", "planned-interactive", "planned-autonomous"]);
30
+ const SWITCH_REGISTRATIONS = new WeakSet();
17
31
  const HASH = /^[0-9a-f]{64}$/;
18
32
  const MAX_DETAIL = 512;
33
+ // Isolation switch state is per-registration: each registerIsolationSwitchCommands
34
+ // call creates a fresh flag in the registration closure, so a new extension
35
+ // registration (session) starts disabled. It is held in memory only, never
36
+ // read from or written to settings, and never settable by the model (no tool
37
+ // exposes it; only the native TUI enable/disable commands mutate it).
38
+ export function createIsolationSwitch() {
39
+ let enabled = false;
40
+ return {
41
+ get() { return enabled === true; },
42
+ set(value) { enabled = value === true; },
43
+ };
44
+ }
19
45
  let inFlight = false;
20
46
 
21
47
  function boundedText(value, fallback = "unknown failure") {
@@ -61,7 +87,84 @@ function fixtureDomainForId(fixtureId) {
61
87
  }
62
88
  return `agentic-driver-${fixtureId}`;
63
89
  }
64
- function parseFacts(stdout, fixtureId) {
90
+ // --- User-configured trusted target (deny-by-default) -----------------------
91
+ // The user makes one decision: where the microVM runs. Either
92
+ // { "sshTarget": "user@host-or-ip" } for a remote machine, or { "local": true }
93
+ // when this session runs directly on a Linux machine. Nothing else is
94
+ // user-supplied: arch, libvirt URI, and kernel are auto-discovered from the
95
+ // target at probe time (informational, not configured). The model cannot
96
+ // choose or change the target. Read order: the user's own config
97
+ // (~/.pi/pi/config/microvm-target.v1.json) first, then the package-local
98
+ // config/microvm-target.v1.json (shipped as a REPLACE-WITH template).
99
+ const TARGET_FIELDS = new Set(["schema", "sshTarget", "local", "_comment"]);
100
+ export function loadMicroVMTarget(options = {}) {
101
+ if (options.target && typeof options.target === "object") {
102
+ return normalizeTarget(options.target);
103
+ }
104
+ const paths = [
105
+ ...(typeof options.targetPath === "string" ? [options.targetPath] : []),
106
+ ...(typeof options.targetUserConfigPath === "string" ? [options.targetUserConfigPath] : []),
107
+ // A test/override seam can replace the default user-config location so
108
+ // tests stay hermetic regardless of the developer's own machine.
109
+ ...(typeof options.userConfigPath === "string" ? [options.userConfigPath] : [TARGET_USER_CONFIG]),
110
+ TARGET_PACKAGE_CONFIG,
111
+ ];
112
+ for (const path of paths) {
113
+ try {
114
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
115
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
116
+ if (Object.keys(parsed).some((key) => !TARGET_FIELDS.has(key))) continue;
117
+ if (parsed.schema !== TARGET_SCHEMA) continue;
118
+ const normalized = normalizeTarget(parsed);
119
+ if (!normalized) continue;
120
+ return Object.freeze({ ...normalized, __path: path });
121
+ } catch {
122
+ // Missing or unreadable candidate: fall through to the next path.
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // Exactly one user decision: sshTarget XOR local:true. Returns the frozen
129
+ // normalized target or null when the decision is absent, ambiguous, or a
130
+ // REPLACE-WITH template placeholder.
131
+ function normalizeTarget(parsed) {
132
+ const hasSshTarget = typeof parsed.sshTarget === "string" && parsed.sshTarget.trim()
133
+ && !parsed.sshTarget.includes("REPLACE-WITH-");
134
+ const hasLocal = parsed.local === true;
135
+ if (hasSshTarget === hasLocal) return null;
136
+ return Object.freeze(hasSshTarget
137
+ ? { mode: "ssh", sshTarget: parsed.sshTarget.trim() }
138
+ : { mode: "local" });
139
+ }
140
+
141
+ // Shape validation for the user-relayed target parameter (untrusted input).
142
+ // Accepts user@host, an ip (optionally :port), a plain hostname, or "local".
143
+ export function targetArgumentError(value) {
144
+ const text = typeof value === "string" ? value.trim() : "";
145
+ if (!text) {
146
+ return { code: "target-argument-required", detail: "Provide the target: user@host, an ip, or local." };
147
+ }
148
+ if (text.includes("REPLACE-WITH-")) {
149
+ return { code: "target-argument-placeholder", detail: "Placeholder values are not valid targets." };
150
+ }
151
+ if (text === "local") return null;
152
+ // Accepts a bare hostname/ssh-config alias, user@host, user@ip, ip[:port].
153
+ // ssh config resolves aliases; the string is passed verbatim in fixed argv.
154
+ const pattern = /^(?:[a-zA-Z0-9._-]+@)?(?:\d{1,3}(?:\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
155
+ const bareToken = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
156
+ if (/\s/.test(text) || (!pattern.test(text) && !bareToken.test(text))) {
157
+ return { code: "target-argument-invalid", detail: `${JSON.stringify(text)} is not a plausible ssh target (alias, user@host, or ip) or the literal 'local'.` };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function targetNotConfigured() {
163
+ return denied("blocked", reason("policy", "target-not-configured",
164
+ "No trusted microVM target is configured. Copy config/microvm-target.v1.example.json to ~/.pi/pi/config/microvm-target.v1.json and set either sshTarget (user@host or ip of the machine that runs the microVM) or local:true (this session runs on a Linux machine). The model cannot choose the target."));
165
+ }
166
+
167
+ function parseFacts(stdout, fixtureId, target) {
65
168
  const values = {};
66
169
  for (const line of String(stdout || "").split("\n")) {
67
170
  if (!line) continue;
@@ -75,37 +178,76 @@ function parseFacts(stdout, fixtureId) {
75
178
  kernel: values.kernel,
76
179
  libvirt: values.libvirt,
77
180
  qemu: values.qemu,
181
+ qemuBinaryPath: values.qemu_binary_path,
78
182
  fixtureDomain: values.fixture_domain_name,
79
183
  fixtureDomainState: values.fixture_domain_state,
184
+ kvmAccessible: values.kvm_accessible === "yes",
80
185
  };
81
186
  const domain = fixtureDomainForId(fixtureId);
82
- if (facts.host !== "ubuntu-backend" || facts.arch !== "x86_64" || facts.libvirt !== "qemu:///system"
83
- || facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent"
84
- || typeof facts.kernel !== "string" || !facts.kernel
85
- || typeof facts.qemu !== "string" || !facts.qemu) {
86
- throw phaseError("preflight", "facts-unexpected", "fixed host facts or the exact fixture-domain absence check failed");
187
+ if (facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent") {
188
+ throw phaseError("preflight", "facts-unexpected", "the exact fixture-domain absence check failed");
189
+ }
190
+ return validateFacts(facts, fixtureId);
191
+ }
192
+ // Honest safety checks over auto-discovered facts: the target must expose
193
+ // KVM, a system-level libvirt connection, and a qemu-system binary matching
194
+ // the discovered architecture. No user-predicted values are involved.
195
+ function validateFacts(facts, _fixtureId) {
196
+ if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
197
+ throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
198
+ }
199
+ if (typeof facts.host !== "string" || !facts.host || typeof facts.arch !== "string" || !facts.arch
200
+ || typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu
201
+ || typeof facts.libvirt !== "string" || !facts.libvirt) {
202
+ throw phaseError("preflight", "facts-unexpected", "the target did not report a complete set of discoverable facts");
203
+ }
204
+ if (facts.kvmAccessible !== true) {
205
+ throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
206
+ }
207
+ if (!facts.libvirt.startsWith("qemu:///system")) {
208
+ throw phaseError("preflight", "libvirt-user-level",
209
+ `the target libvirt connection is '${facts.libvirt}', not the system driver (qemu:///system); the microVM proof requires the system-level libvirt driver`);
210
+ }
211
+ // The decisive evidence is WHICH binary resolved, not an arch token inside
212
+ // the version string (Debian builds omit it there).
213
+ if (typeof facts.qemuBinaryPath !== "string" || !facts.qemuBinaryPath.endsWith(`qemu-system-${facts.arch}`)
214
+ || !/^QEMU/.test(facts.qemu)) {
215
+ throw phaseError("preflight", "qemu-binary-missing",
216
+ `the target did not prove a working qemu-system-${facts.arch}: resolved binary '${facts.qemuBinaryPath || "(none)"}', version output '${facts.qemu.slice(0, 80)}'`);
87
217
  }
88
218
  return facts;
89
219
  }
90
- function sshProbe(execute = run, fixtureId) {
220
+ function sshProbe(execute = run, fixtureId, target) {
91
221
  const domain = fixtureDomainForId(fixtureId);
92
222
  const quotedDomain = shellQuote(domain);
223
+ // All technical expectations are auto-discovered from the target itself;
224
+ // validation checks honest safety properties (KVM, system libvirt driver,
225
+ // qemu binary for the discovered arch) with no user-predicted values.
93
226
  const command = [
94
- "set -eu", "test \"$(uname -m)\" = x86_64", "test -r /dev/kvm -a -w /dev/kvm",
95
- "test -x /usr/bin/qemu-system-x86_64", "test -x /usr/bin/busybox",
96
- "test -x /usr/bin/cpio", "test -x /usr/bin/gzip", "test -x /usr/bin/setfacl",
97
- "test -x /usr/bin/getfacl", "test -n \"$(virsh uri)\"",
227
+ "set -eu",
228
+ "test -r /dev/kvm -a -w /dev/kvm",
229
+ "test -x /usr/bin/qemu-system-$(uname -m)",
230
+ "test -x /usr/bin/busybox", "test -x /usr/bin/cpio", "test -x /usr/bin/gzip",
231
+ "test -x /usr/bin/setfacl", "test -x /usr/bin/getfacl", "test -n \"$(virsh uri)\"",
98
232
  "printf 'host=%s\\n' \"$(hostname)\"", "printf 'arch=%s\\n' \"$(uname -m)\"",
99
233
  "printf 'kernel=%s\\n' \"$(uname -r)\"", "printf 'libvirt=%s\\n' \"$(virsh uri)\"",
100
- "printf 'qemu=%s\\n' \"$(qemu-system-x86_64 --version | head -1)\"",
234
+ `printf 'qemu_binary_path=%s\\n' "$(command -v qemu-system-$(uname -m))"`,
235
+ `printf 'qemu=%s\\n' "$(qemu-system-$(uname -m) --version | head -1)"`,
236
+ "printf 'kvm_accessible=%s\\n' \"$( test -r /dev/kvm -a -w /dev/kvm && echo yes || echo no )\"",
101
237
  `printf 'fixture_domain_name=%s\\n' ${quotedDomain}`,
102
238
  `if virsh dominfo ${quotedDomain} >/dev/null 2>&1; then printf 'fixture_domain_state=present\\n'; else names=$(virsh list --all --name); if printf '%s\\n' \"$names\" | grep -F -x -- ${quotedDomain} >/dev/null; then printf 'fixture_domain_state=present\\n'; else match_status=$?; if [ \"$match_status\" -eq 1 ]; then printf 'fixture_domain_state=absent\\n'; else exit 1; fi; fi; fi`,
103
239
  ].join("; ");
104
- const result = execute("ssh", ["linux-backend", command], { timeout: 30000 });
240
+ const result = target.mode === "local"
241
+ ? execute("bash", ["-c", command], { timeout: 30000 })
242
+ : execute("ssh", [target.sshTarget, command], { timeout: 30000 });
105
243
  if (result.code !== 0) {
106
- throw phaseError("preflight", "probe-failed", result.stderr || result.error || "fixed linux-backend capability probe failed");
244
+ const text = result.stderr || result.error || "";
245
+ if (/\/dev\/kvm/i.test(text)) {
246
+ throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
247
+ }
248
+ throw phaseError("preflight", "probe-failed", text || "configured microVM target capability probe failed");
107
249
  }
108
- return parseFacts(result.stdout, fixtureId);
250
+ return parseFacts(result.stdout, fixtureId, target);
109
251
  }
110
252
  function exactKeys(value, keys, label) {
111
253
  if (!value || typeof value !== "object" || Array.isArray(value)
@@ -121,18 +263,6 @@ function requireHash(value, label) {
121
263
  function requireBoolean(value, label) {
122
264
  if (typeof value !== "boolean") throw phaseError("evidence", "receipt-invalid", `${label} is not boolean evidence`);
123
265
  }
124
- function validateFacts(facts, fixtureId) {
125
- const domain = fixtureDomainForId(fixtureId);
126
- if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
127
- throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
128
- }
129
- if (facts.host !== "ubuntu-backend" || facts.arch !== "x86_64" || facts.libvirt !== "qemu:///system"
130
- || facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent"
131
- || typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu) {
132
- throw phaseError("preflight", "facts-unexpected", "fixed host facts or the exact fixture-domain absence check failed");
133
- }
134
- return facts;
135
- }
136
266
  function parseReceipt(stdout) {
137
267
  const candidates = [];
138
268
  const unexpected = [];
@@ -236,15 +366,62 @@ function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallback
236
366
  }
237
367
 
238
368
  export async function runLinuxMicroVMCutover(context, options = {}) {
239
- if (!PLANNED_ISOLATION_MODES.has(options.workMode)) {
240
- return denied("blocked", reason("policy", "planned-isolation-required", "Linux microVM execution is reserved for planned or automated work."));
369
+ // Session-scoped user switch: only the explicit enable command can set this
370
+ // flag in memory; it never persists to settings and the model cannot set it.
371
+ if (options.isolationSwitch?.get() !== true) {
372
+ return denied("blocked", reason("policy", "isolation-not-enabled",
373
+ "Isolation activation is not enabled in this session. Run the agentic-isolation-enable command in the Pi TUI."));
374
+ }
375
+ // Target selection: the only way user intent reaches setup is the optional
376
+ // `target` parameter, relayed by the agent from the user's words. It is
377
+ // untrusted: nothing is written or run without the user's native
378
+ // confirmation dialog naming the target explicitly.
379
+ const targetParam = typeof options.target === "string" ? options.target.trim() : "";
380
+ if (targetParam) {
381
+ const shapeError = targetArgumentError(targetParam);
382
+ if (shapeError) return denied("denied", reason("input", shapeError.code, shapeError.detail));
383
+ // Headless setups are refused: only pre-configured targets run.
384
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
385
+ return denied("blocked", reason("policy", "native-tui-required",
386
+ "Configuring a new microVM target requires the interactive Pi TUI; headless sessions may only use a pre-configured target."));
387
+ }
388
+ const writePath = options.targetUserConfigPath?.trim() || TARGET_USER_CONFIG;
389
+ let confirmed;
390
+ try {
391
+ confirmed = await context.ui.confirm("Use this microVM host?", [
392
+ `Use ${targetParam === "local" ? "THIS machine (local)" : targetParam} as the microVM host? This saves it to your Pi config.`,
393
+ `Config file: ${writePath}`,
394
+ "The model relayed your words; this confirmation is what authorizes the choice.",
395
+ ].join("\n"));
396
+ } catch (error) {
397
+ return denied("blocked", reason("confirmation", "confirmation-failed", `Native confirmation failed: ${error.message}`));
398
+ }
399
+ if (confirmed !== true) {
400
+ return denied("stopped", reason("confirmation", "not-granted", "No target was saved; native confirmation was not granted."));
401
+ }
402
+ const saved = targetParam === "local"
403
+ ? { schema: TARGET_SCHEMA, local: true }
404
+ : { schema: TARGET_SCHEMA, sshTarget: targetParam };
405
+ try {
406
+ mkdirSync(dirname(writePath), { recursive: true });
407
+ writeFileSync(writePath, `${JSON.stringify(saved, null, 2)}\n`);
408
+ } catch (error) {
409
+ return denied("blocked", reason("policy", "target-write-failed", `Could not write ${writePath}: ${error.message}`));
410
+ }
241
411
  }
242
- const isolationEnabled = options.isolationEnabled ?? options.runtime?.isolationEnabled;
243
- if (isolationEnabled !== true) {
244
- return denied("blocked", reason("policy", "planned-isolation-not-enabled", "The planned isolation execution path is not enabled yet."));
412
+ // Saved config (possibly just written above) drives the run; deny-by-default
413
+ // when neither a target param nor saved config exists.
414
+ const target = loadMicroVMTarget({
415
+ ...options,
416
+ ...(options.userConfigPath ? {} : { userConfigPath: options.userConfigPath }),
417
+ });
418
+ if (!target) {
419
+ return denied("blocked", reason("policy", "target-not-configured",
420
+ "No microVM target is configured. Ask the user which machine the microVM should run on and pass it as the `target` parameter (user@host, ip, or local)."));
245
421
  }
246
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
247
- return denied("blocked", reason("policy", "native-tui-required", "Open the Linux microVM cutover in the interactive Pi TUI."));
422
+ if (!isNativeTuiContext(context)) {
423
+ return denied("blocked", reason("policy", "native-tui-required",
424
+ "Open the Linux microVM cutover in the interactive Pi TUI; headless runs are denied."));
248
425
  }
249
426
  if (inFlight) return denied("denied", reason("execution", "already-active", "another Linux microVM cutover is active in this host session"));
250
427
 
@@ -259,13 +436,13 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
259
436
  return denied("denied", reasonFromError(error, "payload", "payload-read-failed"));
260
437
  }
261
438
  const execute = options.execute || run;
262
- const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id));
439
+ const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id, target));
263
440
  let facts;
264
441
  try { facts = validateFacts(observe(execute, fixtureId), fixtureId); }
265
442
  catch (error) { return denied("denied", reasonFromError(error, "preflight", "facts-observation-failed")); }
266
443
  const body = [
267
- "Run one live transient QEMU/KVM microVM proof on linux-backend?",
268
- `Remote host: ${facts.host} (${facts.arch}, kernel ${facts.kernel})`,
444
+ "Run one live transient QEMU/KVM microVM proof on the configured trusted target?",
445
+ `Target: ${target.mode === "local" ? "this machine (local)" : target.sshTarget} — discovered host: ${facts.host} (${facts.arch}, kernel ${facts.kernel})`,
269
446
  `Backend: ${facts.libvirt}; ${facts.qemu}`,
270
447
  `Fixture: ${fixtureId}; domain: ${fixtureDomain} (preflight absent)`,
271
448
  `Versioned fixture SHA-256: ${scriptHash}`,
@@ -288,7 +465,9 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
288
465
  }
289
466
  inFlight = true;
290
467
  try {
291
- const result = execute("ssh", ["linux-backend", "bash", "-s", "--", fixtureId, scriptHash], { input: script, timeout: 180000 });
468
+ const result = target.mode === "local"
469
+ ? execute("bash", ["-c", "bash -s -- " + shellQuote(fixtureId) + " " + shellQuote(scriptHash)], { input: script, timeout: 180000 })
470
+ : execute("ssh", [target.sshTarget, "bash", "-s", "--", fixtureId, scriptHash], { input: script, timeout: 180000 });
292
471
  if (!result || result.code !== 0) {
293
472
  return denied("blocked", normalizedForwardedStderr(result, "fixture", "execution-failed", "fixed microVM fixture failed"));
294
473
  }
@@ -307,22 +486,132 @@ function commandArgumentsPresent(args) {
307
486
  export function registerLinuxMicroVMCutoverInterface(pi, options = {}) {
308
487
  if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
309
488
  REGISTRATIONS.add(pi);
489
+ // Prominent outcome presentation. The first line of the tool output is a
490
+ // concise final status (VERIFIED / DENIED / STOPPED / BLOCKED) with fixture
491
+ // id and one-line evidence or reason summary; the full JSON receipt follows
492
+ // unchanged. When the interactive TUI exposes the notify surface, the same
493
+ // status is raised as a fire-and-forget notification.
494
+ const outcomeLine = (value) => {
495
+ if (value?.ok === true && value?.status === "VERIFIED") {
496
+ return `MICROVM CUTOVER: VERIFIED — fixture ${value.identity?.fixtureId ?? "unknown"} on ${value.identity?.remoteHost ?? "unknown host"}; domain ${value.identity?.domain ?? "?"} transient+gone, teardown proofed, isolation context closed.`;
497
+ }
498
+ const status = String(value?.status ?? "DENIED").toUpperCase();
499
+ const code = value?.reason?.code ?? value?.code ?? "unknown";
500
+ const detail = value?.reason?.detail ?? value?.error ?? "";
501
+ return `MICROVM CUTOVER: ${status} — reason ${code}${detail ? `: ${detail}` : ""}`;
502
+ };
503
+ const notifyOutcome = (context, value) => {
504
+ const notify = context?.ui?.notify;
505
+ if (typeof notify !== "function") return;
506
+ const line = outcomeLine(value);
507
+ notify(line, value?.ok === true ? "info" : value?.status === "stopped" ? "warning" : "error");
508
+ };
310
509
  const execute = async (_id, params, _signal, _update, context) => {
311
- const value = params && Object.keys(params).length
312
- ? denied("denied", reason("input", "model-parameters-not-allowed", "Linux microVM cutover accepts no model parameters"))
510
+ const closedParams = params && typeof params === "object" && !Array.isArray(params)
511
+ ? Object.keys(params).filter((key) => key !== "target")
512
+ : [];
513
+ const value = closedParams.length
514
+ ? denied("denied", reason("input", "model-parameters-not-allowed", "Linux microVM cutover accepts only the optional target parameter"))
313
515
  : await runLinuxMicroVMCutover(context, {
314
516
  ...options,
315
- workMode: options.runtime?.workMode,
316
- isolationEnabled: options.runtime?.isolationEnabled,
517
+ ...(typeof params?.target === "string" ? { target: params.target } : {}),
317
518
  });
318
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
519
+ notifyOutcome(context, value);
520
+ return { content: [{ type: "text", text: `${outcomeLine(value)}\n${JSON.stringify(value, null, 2)}` }], details: value };
319
521
  };
320
522
  pi.registerTool({ name: LINUX_MICROVM_CUTOVER_TOOL, label: "Verify Linux microVM",
321
- description: "Run one native-confirmed transient QEMU/KVM microVM proof on linux-backend.",
322
- parameters: { type: "object", additionalProperties: false, properties: {} }, execute });
523
+ description: "Run one native-confirmed transient QEMU/KVM microVM proof on the trusted target. The optional target parameter (user@host, ip, or local) relays the user's machine choice; saving a new target requires the user's native confirmation. Requires the session isolation switch.",
524
+ parameters: {
525
+ type: "object",
526
+ additionalProperties: false,
527
+ properties: {
528
+ target: { type: "string", maxLength: 255, description: "Where the microVM runs: user@host, an ip, or the literal local. Relays the user's explicit choice; a new target is saved only after the user's native confirmation." },
529
+ },
530
+ }, execute });
323
531
  pi.registerCommand?.("agentic-linux-microvm-cutover", { description: "Run the native-confirmed Linux microVM proof",
324
532
  handler: async (args, context) => commandArgumentsPresent(args)
325
533
  ? denied("denied", reason("input", "command-arguments-not-allowed", "Linux microVM cutover accepts no command arguments"))
326
534
  : (await execute("command", {}, undefined, undefined, context)).details });
327
535
  }
536
+
537
+ // Session-scoped isolation switch commands. The flag lives only in this
538
+ // module's memory: the model has no tool to set or read it, it is never
539
+ // written to settings, and each command itself requires a native TUI
540
+ // confirmation. Disabling always succeeds once confirmed; enabling requires
541
+ // the interactive Pi TUI.
542
+ const ISOLATION_ENABLE_COMMAND = "agentic-isolation-enable";
543
+ const ISOLATION_DISABLE_COMMAND = "agentic-isolation-disable";
544
+ export const ISOLATION_COMMANDS = { enable: ISOLATION_ENABLE_COMMAND, disable: ISOLATION_DISABLE_COMMAND };
545
+
546
+ export function registerIsolationSwitchCommands(pi, options = {}) {
547
+ if (typeof pi?.registerCommand !== "function" || SWITCH_REGISTRATIONS.has(pi)) return;
548
+ SWITCH_REGISTRATIONS.add(pi);
549
+ // Fresh, registration-scoped switch state: a new registration starts disabled.
550
+ const isolationSwitch = options.isolationSwitch ?? createIsolationSwitch();
551
+ const switchNotice = () => isolationSwitch.get()
552
+ ? "Isolation activation is ENABLED for this session only. It does not persist to settings and resets when the session ends."
553
+ : "Isolation activation is DISABLED. No microVM proof can run in this session until it is enabled.";
554
+ const switchResult = (ok, status, code, detail) => ({
555
+ schema: LINUX_MICROVM_CUTOVER_SCHEMA, ok, status,
556
+ reason: ok ? undefined : reason("policy", code, detail),
557
+ isolationEnabled: isolationSwitch.get(),
558
+ persisted: false,
559
+ });
560
+ const rejectArguments = () => switchResult(false, "denied", "command-arguments-not-allowed",
561
+ "The isolation switch commands accept no command arguments.");
562
+ pi.registerCommand(ISOLATION_ENABLE_COMMAND, {
563
+ description: "Enable the Linux microVM isolation switch for this session (native confirmation required)",
564
+ handler: async (args, context) => {
565
+ if (commandArgumentsPresent(args)) return rejectArguments();
566
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
567
+ return switchResult(false, "blocked", "native-tui-required",
568
+ "Open the interactive Pi TUI to enable isolation; headless sessions cannot enable it.");
569
+ }
570
+ if (isolationSwitch.get()) {
571
+ return switchResult(true, "ALREADY_ENABLED", "", "");
572
+ }
573
+ let confirmed;
574
+ try {
575
+ confirmed = await context.ui.confirm("Enable Linux microVM isolation", [
576
+ "Enable the isolation-activation switch for this session?",
577
+ switchNotice(),
578
+ "Effect: the agentic_linux_microvm_cutover tool may run one native-confirmed transient QEMU/KVM microVM proof on the configured trusted target per invocation.",
579
+ "The switch is session-scoped: it never persists to settings and the model cannot change it.",
580
+ ].join("\n"));
581
+ } catch (error) {
582
+ return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
583
+ }
584
+ if (confirmed !== true) {
585
+ return switchResult(false, "stopped", "not-granted", "Isolation activation was not enabled; native confirmation was not granted.");
586
+ }
587
+ isolationSwitch.set(true);
588
+ return switchResult(true, "ENABLED", "", "");
589
+ },
590
+ });
591
+ pi.registerCommand(ISOLATION_DISABLE_COMMAND, {
592
+ description: "Disable the Linux microVM isolation switch for this session (native confirmation required)",
593
+ handler: async (args, context) => {
594
+ if (commandArgumentsPresent(args)) return rejectArguments();
595
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
596
+ return switchResult(false, "blocked", "native-tui-required",
597
+ "Open the interactive Pi TUI to disable isolation; headless sessions cannot change the switch.");
598
+ }
599
+ let confirmed;
600
+ try {
601
+ confirmed = await context.ui.confirm("Disable Linux microVM isolation", [
602
+ "Disable the isolation-activation switch for this session?",
603
+ switchNotice(),
604
+ ].join("\n"));
605
+ } catch (error) {
606
+ return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
607
+ }
608
+ if (confirmed !== true) {
609
+ return switchResult(false, "stopped", "not-granted", "Isolation activation remains enabled; native confirmation was not granted.");
610
+ }
611
+ isolationSwitch.set(false);
612
+ return switchResult(true, "DISABLED", "", "");
613
+ },
614
+ });
615
+ return isolationSwitch;
616
+ }
328
617
  export default registerLinuxMicroVMCutoverInterface;