@evoclock/pi-agentic-driver 0.4.3
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/LICENSE +736 -0
- package/PROVENANCE.md +69 -0
- package/README.md +325 -0
- package/config/herdr-worker-repositories.v1.json +9 -0
- package/extensions/aidr.ts +5 -0
- package/extensions/code-phage.js +144 -0
- package/extensions/herdr-communication.ts +7 -0
- package/extensions/herdr-lifecycle.ts +7 -0
- package/extensions/linux-microvm.ts +10 -0
- package/lib/adapters/diff-scope.mjs +148 -0
- package/lib/adapters/evidence.mjs +151 -0
- package/lib/adapters/narrative.mjs +171 -0
- package/lib/adapters/review-feedback.mjs +77 -0
- package/lib/adapters/visualization.mjs +176 -0
- package/lib/code-phage-core.mjs +882 -0
- package/lib/python_ast_metrics.py +378 -0
- package/lib/typescript_ast_metrics.mjs +441 -0
- package/package.json +50 -0
- package/scripts/aidr_writing_review.js +468 -0
- package/scripts/enforcement/herdr_communication_pi.js +1198 -0
- package/scripts/enforcement/herdr_lifecycle_pi.js +902 -0
- package/scripts/enforcement/linux_microvm_cutover_pi.js +328 -0
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +366 -0
- package/scripts/enforcement/native_tui_context.js +11 -0
- package/templates/AGENTS.md +72 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Guarded spawn_worker composite capability (Tranche 07). One tool, one closed
|
|
5
|
+
// request, one documented Herdr 0.8.2 layout/start sequence, bounded read-back,
|
|
6
|
+
// and a bounded receipt. This is deliberately NOT a general Herdr management
|
|
7
|
+
// surface: no raw management verbs, no terminal remote control, no model
|
|
8
|
+
// allowlist constant. Pane placement uses pane split directly; tab placement
|
|
9
|
+
// uses tab create. Both capture the returned pane identity and start once.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
HERDR_ROLE_PATTERN,
|
|
17
|
+
HERDR_ROLE_POLICY,
|
|
18
|
+
resolveTrustedHerdrExecutable,
|
|
19
|
+
} from "./herdr_communication_pi.js";
|
|
20
|
+
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
21
|
+
|
|
22
|
+
export const HERDR_SPAWN_WORKER_TOOL = "agentic_herdr_spawn_worker";
|
|
23
|
+
export const HERDR_LIFECYCLE_SCHEMA = "agentic-driver.herdr-lifecycle.v1";
|
|
24
|
+
export const HERDR_LIFECYCLE_VERSION = "spawn-worker.v1";
|
|
25
|
+
export const SPAWN_PLACEMENTS = Object.freeze(["tab", "right", "below"]);
|
|
26
|
+
// Herdr accepts only `right` and `down` split directions;
|
|
27
|
+
// the user-facing `below` is mapped, never forwarded.
|
|
28
|
+
const HERDR_DIRECTIONS = Object.freeze({ right: "right", below: "down" });
|
|
29
|
+
// Applied to the pane-move --split value (user placement right|below → Herdr
|
|
30
|
+
// split right|down); never forwarded as a placement.
|
|
31
|
+
const WORKER_REPOSITORY_REGISTRY = "config/herdr-worker-repositories.v1.json";
|
|
32
|
+
const WORKER_REPOSITORY_SCHEMA = "agentic-driver.herdr-worker-repositories.v1";
|
|
33
|
+
const ROLE_REGEXP = new RegExp(HERDR_ROLE_PATTERN);
|
|
34
|
+
const SAFE_NAME_REGEXP = ROLE_REGEXP;
|
|
35
|
+
const MAX_FIELD_BYTES = 4 * 1024;
|
|
36
|
+
const MAX_PROCESS_OUTPUT_BYTES = 128 * 1024;
|
|
37
|
+
const COMMAND_TIMEOUT_MS = 135_000;
|
|
38
|
+
const AGENT_READY_TIMEOUT_MS = 120_000;
|
|
39
|
+
// The active Pi profile's model roll is user configuration, read read-only.
|
|
40
|
+
// It resolves from PI_CODING_AGENT_DIR (the active profile directory); only
|
|
41
|
+
// when that env var is unset does it fall back to the default per-user path
|
|
42
|
+
// ~/.pi/agent/models.json (recorded in docs/package-0.1.3-design-record.md).
|
|
43
|
+
function resolvePiModelsPath() {
|
|
44
|
+
const profileDir = process.env.PI_CODING_AGENT_DIR;
|
|
45
|
+
if (typeof profileDir === "string" && profileDir.trim()) {
|
|
46
|
+
return join(profileDir.trim(), "models.json");
|
|
47
|
+
}
|
|
48
|
+
return join(homedir(), ".pi", "agent", "models.json");
|
|
49
|
+
}
|
|
50
|
+
const MODEL_ID_REGEXP = /^[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._-]{0,127})*$/;
|
|
51
|
+
// Closed response keys. Shapes beyond `.result.move_result.pane.pane_id` are
|
|
52
|
+
// provisional from tagged-source evidence (Tranche 07) and fail closed on
|
|
53
|
+
// anything extra.
|
|
54
|
+
const WRAPPER_FIELDS = new Set(["id", "result", "error"]);
|
|
55
|
+
const START_RESULT_FIELDS = new Set(["type", "agent", "argv"]);
|
|
56
|
+
// `pane get` shape is unverified live (help evidence only); this closed set is
|
|
57
|
+
// deliberately generous over Herdr pane metadata but still bounded.
|
|
58
|
+
// Real live pane_id format is workspace-scoped, e.g. `w5:p46` (Herdr 0.7.5
|
|
59
|
+
// pane get live evidence); colons and dots are part of identity.
|
|
60
|
+
const PANE_ID_REGEXP = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
61
|
+
const PANE_RESULT_FIELDS = new Set(["type", "pane", "pane_id", "cwd", "foreground_cwd"]);
|
|
62
|
+
const REGISTRATIONS = new WeakSet();
|
|
63
|
+
|
|
64
|
+
export const HERDR_SPAWN_WORKER_PARAMETERS = Object.freeze({
|
|
65
|
+
type: "object",
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
placement: { type: "string", enum: SPAWN_PLACEMENTS },
|
|
69
|
+
role: { type: "string", pattern: HERDR_ROLE_PATTERN, maxLength: 64 },
|
|
70
|
+
model: { type: "string", pattern: MODEL_ID_REGEXP.source, maxLength: 192 },
|
|
71
|
+
repository: { type: "string", pattern: SAFE_NAME_REGEXP.source, maxLength: 64 },
|
|
72
|
+
},
|
|
73
|
+
required: ["placement", "role", "model", "repository"],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function lifecycleError(code, message, status = "blocked") {
|
|
77
|
+
const error = new Error(message);
|
|
78
|
+
error.name = "HerdrLifecycleError";
|
|
79
|
+
error.code = code;
|
|
80
|
+
error.status = status;
|
|
81
|
+
return error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorResult(error, extra = {}) {
|
|
85
|
+
const known = error?.code
|
|
86
|
+
? error
|
|
87
|
+
: lifecycleError("unexpected_adapter_failure", "the Herdr lifecycle adapter failed");
|
|
88
|
+
return {
|
|
89
|
+
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
90
|
+
ok: false,
|
|
91
|
+
status: known.status || "blocked",
|
|
92
|
+
code: known.code,
|
|
93
|
+
reason: known.message,
|
|
94
|
+
nonAuthorizing: true,
|
|
95
|
+
authorityCreated: false,
|
|
96
|
+
...extra,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function layoutCleanupGuidance(partial, paneId, tabId) {
|
|
101
|
+
if (partial === "pane_created") {
|
|
102
|
+
return paneId
|
|
103
|
+
? `Run \`herdr pane close ${paneId}\` manually to remove the empty pane; no automatic close was performed.`
|
|
104
|
+
: "Run `herdr pane list` to identify the created pane and `herdr pane close <pane_id>` manually; no automatic close was performed.";
|
|
105
|
+
}
|
|
106
|
+
if (partial === "tab_created") {
|
|
107
|
+
return tabId
|
|
108
|
+
? `Run \`herdr tab close ${tabId}\` manually to remove the empty tab; no automatic close was performed.`
|
|
109
|
+
: "Run `herdr tab list` to identify the created tab and `herdr tab close <tab_id>` manually; no automatic close was performed.";
|
|
110
|
+
}
|
|
111
|
+
return `The worker agent is live and usable${paneId ? ` in pane ${paneId}` : ""}; use it as-is or close it manually. No automatic retry or cleanup was performed.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function partialResult(error, { partial = "tab_created", paneId, tabId, role, modelArgv, rawResponse } = {}) {
|
|
115
|
+
const code = typeof error?.code === "string" ? error.code : "unexpected_adapter_failure";
|
|
116
|
+
const message = typeof error?.message === "string" && error.message
|
|
117
|
+
? error.message
|
|
118
|
+
: "the Herdr lifecycle adapter failed";
|
|
119
|
+
const cleanup = layoutCleanupGuidance(partial, paneId, tabId);
|
|
120
|
+
const boundedRaw = typeof rawResponse === "string" && rawResponse
|
|
121
|
+
&& Buffer.byteLength(rawResponse, "utf8") <= MAX_PROCESS_OUTPUT_BYTES
|
|
122
|
+
? rawResponse
|
|
123
|
+
: undefined;
|
|
124
|
+
return errorResult(lifecycleError(code, `${message}; ${cleanup}`, "partial"), {
|
|
125
|
+
status: "partial",
|
|
126
|
+
partial,
|
|
127
|
+
paneId: paneId ?? null,
|
|
128
|
+
tabId: tabId ?? null,
|
|
129
|
+
role,
|
|
130
|
+
...(Array.isArray(modelArgv) ? { modelArgv: [...modelArgv] } : {}),
|
|
131
|
+
cleanup,
|
|
132
|
+
...(boundedRaw !== undefined ? { rawResponse: boundedRaw } : {}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isPlainObject(value) {
|
|
137
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertBoundedString(value, label) {
|
|
141
|
+
if (typeof value !== "string" || !value.trim()
|
|
142
|
+
|| Buffer.byteLength(value, "utf8") > MAX_FIELD_BYTES) {
|
|
143
|
+
throw lifecycleError("unexpected_result", `Herdr returned a malformed ${label}`);
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isCoordinatorRole(value) {
|
|
149
|
+
return value === HERDR_ROLE_POLICY.coordinatorPrefix
|
|
150
|
+
|| value.startsWith(`${HERDR_ROLE_POLICY.coordinatorPrefix}-`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function reportMarkersForRole(role) {
|
|
154
|
+
const label = role.toUpperCase().replaceAll("-", "_");
|
|
155
|
+
return Object.freeze({
|
|
156
|
+
open: `[${label}_REPORT_BEGIN]`,
|
|
157
|
+
close: `[${label}_REPORT_END]`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function validateSpawnParams(params) {
|
|
162
|
+
if (!isPlainObject(params)) {
|
|
163
|
+
throw lifecycleError("invalid_parameters", "spawn parameters must be an object", "denied");
|
|
164
|
+
}
|
|
165
|
+
const keys = Object.keys(params);
|
|
166
|
+
if (keys.length !== 4 || ["placement", "role", "model", "repository"].some((k) => !keys.includes(k))) {
|
|
167
|
+
throw lifecycleError("closed_parameters", "spawn_worker accepts exactly placement, role, model, and repository", "denied");
|
|
168
|
+
}
|
|
169
|
+
const { placement, role, model, repository } = params;
|
|
170
|
+
if (!SPAWN_PLACEMENTS.includes(placement)) {
|
|
171
|
+
throw lifecycleError("unsupported_placement", "placement must be tab, right, or below", "denied");
|
|
172
|
+
}
|
|
173
|
+
if (typeof role !== "string" || role.length > 64 || !ROLE_REGEXP.test(role) || isCoordinatorRole(role)) {
|
|
174
|
+
throw lifecycleError("target_role_denied", "the role must be a valid non-coordinator safe name", "denied");
|
|
175
|
+
}
|
|
176
|
+
if (typeof repository !== "string" || repository.length > 64 || !SAFE_NAME_REGEXP.test(repository)
|
|
177
|
+
|| repository.includes("/") || repository.includes("..")) {
|
|
178
|
+
throw lifecycleError("repository_name_denied", "the repository must be a registry safe name", "denied");
|
|
179
|
+
}
|
|
180
|
+
if (typeof model !== "string" || model.length > 192 || !MODEL_ID_REGEXP.test(model)) {
|
|
181
|
+
throw lifecycleError("model_denied", "the model must be an installed Pi provider/id or unique model id", "denied");
|
|
182
|
+
}
|
|
183
|
+
return { placement, role, model, repository };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseJsonEnvelope(raw, label, { requireResult = false } = {}) {
|
|
187
|
+
if (typeof raw !== "string" || !raw.trim()
|
|
188
|
+
|| Buffer.byteLength(raw, "utf8") > MAX_PROCESS_OUTPUT_BYTES) {
|
|
189
|
+
throw lifecycleError("unexpected_result", `Herdr ${label} returned unusable output`);
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch {
|
|
195
|
+
throw lifecycleError("malformed_json", `Herdr ${label} returned malformed JSON`);
|
|
196
|
+
}
|
|
197
|
+
if (!isPlainObject(parsed)) {
|
|
198
|
+
throw lifecycleError("malformed_json", `Herdr ${label} returned a malformed response envelope`);
|
|
199
|
+
}
|
|
200
|
+
if (isPlainObject(parsed.error)) {
|
|
201
|
+
throw lifecycleError("herdr_process_failed", `Herdr ${label} returned an error envelope`);
|
|
202
|
+
}
|
|
203
|
+
// Read-back calls may receive a bare result in test seams, but mutations
|
|
204
|
+
// that create identity must use the complete socket result envelope.
|
|
205
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "result")) {
|
|
206
|
+
const allowed = requireResult ? new Set(["id", "result"]) : WRAPPER_FIELDS;
|
|
207
|
+
if (Object.keys(parsed).some((key) => !allowed.has(key)) || !isPlainObject(parsed.result)) {
|
|
208
|
+
throw lifecycleError("malformed_json", `Herdr ${label} returned a malformed response envelope`);
|
|
209
|
+
}
|
|
210
|
+
return parsed.result;
|
|
211
|
+
}
|
|
212
|
+
if (requireResult) {
|
|
213
|
+
throw lifecycleError("malformed_json", `Herdr ${label} returned a bare result instead of a response envelope`);
|
|
214
|
+
}
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function closedShape(value, allowed, code, message) {
|
|
219
|
+
if (!isPlainObject(value) || Object.keys(value).some((key) => !allowed.has(key))) {
|
|
220
|
+
throw lifecycleError(code, message);
|
|
221
|
+
}
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function paneIdentity(value, label) {
|
|
226
|
+
// Live 0.7.5 pane_info shape: identity fields required, every other
|
|
227
|
+
// observed (optional) pane field tolerated (agent may be absent,
|
|
228
|
+
// agent_status "unknown", scroll an object, terminal_title* absent).
|
|
229
|
+
const pane = isPlainObject(value) ? value : null;
|
|
230
|
+
if (!pane) {
|
|
231
|
+
throw lifecycleError("unexpected_result", `${label} returned no pane object`);
|
|
232
|
+
}
|
|
233
|
+
const paneId = pane.pane_id;
|
|
234
|
+
if (typeof paneId !== "string" || !PANE_ID_REGEXP.test(paneId)) {
|
|
235
|
+
throw lifecycleError("unexpected_result", `${label} returned a malformed pane identifier`);
|
|
236
|
+
}
|
|
237
|
+
return pane;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseCurrentPane(raw) {
|
|
241
|
+
// Step 1 (read-only): capture the coordinator pane identity. The response
|
|
242
|
+
// is the pane_info shape; pane_id and tab_id are both required.
|
|
243
|
+
const result = parseJsonEnvelope(raw, "pane current");
|
|
244
|
+
const pane = paneIdentity(isPlainObject(result.pane) ? result.pane : result, "pane current");
|
|
245
|
+
const tabId = pane.tab_id;
|
|
246
|
+
if (typeof tabId !== "string" || !PANE_ID_REGEXP.test(tabId)) {
|
|
247
|
+
throw lifecycleError("unexpected_result", "pane current returned a malformed tab identifier");
|
|
248
|
+
}
|
|
249
|
+
return { paneId: pane.pane_id, tabId };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseTabCreate(raw) {
|
|
253
|
+
// Step 2 (first mutation): parse ONLY .result.root_pane.pane_id and
|
|
254
|
+
// .result.tab.tab_id; nothing else in the tab-create response is trusted.
|
|
255
|
+
const result = parseJsonEnvelope(raw, "tab create", { requireResult: true });
|
|
256
|
+
const rootPaneId = isPlainObject(result.root_pane) ? result.root_pane.pane_id : undefined;
|
|
257
|
+
const tabId = isPlainObject(result.tab) ? result.tab.tab_id : undefined;
|
|
258
|
+
if (typeof rootPaneId !== "string" || !PANE_ID_REGEXP.test(rootPaneId)
|
|
259
|
+
|| typeof tabId !== "string" || !PANE_ID_REGEXP.test(tabId)) {
|
|
260
|
+
throw lifecycleError("unexpected_result", "tab create returned a malformed tab or root pane identifier");
|
|
261
|
+
}
|
|
262
|
+
return { rootPaneId, tabId };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parsePaneSplit(raw, coordinatorTabId) {
|
|
266
|
+
const result = parseJsonEnvelope(raw, "pane split", { requireResult: true });
|
|
267
|
+
const pane = paneIdentity(result.pane, "pane split");
|
|
268
|
+
const tabId = typeof pane.tab_id === "string" ? pane.tab_id : coordinatorTabId;
|
|
269
|
+
if (!PANE_ID_REGEXP.test(tabId)) {
|
|
270
|
+
throw lifecycleError("unexpected_result", "pane split returned a malformed tab identifier");
|
|
271
|
+
}
|
|
272
|
+
return { paneId: pane.pane_id, tabId };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function collectString(values, label) {
|
|
276
|
+
const unique = [...new Set(values.filter((v) => v !== undefined && v !== null))];
|
|
277
|
+
if (unique.length !== 1) throw lifecycleError("unexpected_result", `Herdr returned conflicting or missing ${label}`);
|
|
278
|
+
return assertBoundedString(unique[0], label);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sameRealPath(value, canonicalRoot) {
|
|
282
|
+
// Fail closed: an unresolvable observed path is never the trusted root.
|
|
283
|
+
try {
|
|
284
|
+
return realpathSync(value) === canonicalRoot;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function observedAgent(value, operation) {
|
|
291
|
+
if (!isPlainObject(value)
|
|
292
|
+
|| Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_PROCESS_OUTPUT_BYTES) {
|
|
293
|
+
throw lifecycleError("unexpected_result", `${operation} returned a malformed agent object`);
|
|
294
|
+
}
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readBackAgent(raw, expectedRole, canonicalRoot, modelArgvTail) {
|
|
299
|
+
const result = closedShape(parseJsonEnvelope(raw, "agent get"), new Set(["type", "agent"]), "malformed_json", "agent get returned fields outside the closed result shape");
|
|
300
|
+
// Herdr adds observational metadata over time (for example
|
|
301
|
+
// state_change_seq and terminal_title). Keep the envelope closed, but
|
|
302
|
+
// validate the bounded agent object by required identity facts rather than
|
|
303
|
+
// rejecting legitimate metadata.
|
|
304
|
+
const agent = observedAgent(result.agent, "agent get");
|
|
305
|
+
const role = collectString([agent.name, agent.role], "agent name");
|
|
306
|
+
// The live alias could be renamed post-hoc; coordinator-class read-back is
|
|
307
|
+
// denied here as well, not only at input validation.
|
|
308
|
+
if (isCoordinatorRole(role)) {
|
|
309
|
+
throw lifecycleError("target_role_denied", "the agent get read-back returned a coordinator-class role", "denied");
|
|
310
|
+
}
|
|
311
|
+
if (role !== expectedRole) throw lifecycleError("stale_role_mapping", "the spawned role is no longer mapped to the expected live agent");
|
|
312
|
+
const kind = collectString([agent.agent, agent.agent_kind, agent.kind], "agent kind");
|
|
313
|
+
if (kind !== "pi") throw lifecycleError("agent_mismatch", "the spawned agent is not the expected Pi kind");
|
|
314
|
+
const paneId = collectString([agent.pane_id], "agent pane id");
|
|
315
|
+
const cwd = collectString([agent.cwd, agent.foreground_cwd], "agent cwd");
|
|
316
|
+
if (!sameRealPath(cwd, canonicalRoot)) {
|
|
317
|
+
throw lifecycleError("repository_mismatch", "the spawned agent is not in the canonical trusted repository");
|
|
318
|
+
}
|
|
319
|
+
if (agent.launch_pending === true || agent.interactive_ready === false) {
|
|
320
|
+
throw lifecycleError("role_not_ready", "the spawned agent is not observed ready");
|
|
321
|
+
}
|
|
322
|
+
return { paneId };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function readBackStart(raw, expectedRole, canonicalRoot, modelArgvTail) {
|
|
326
|
+
const result = closedShape(parseJsonEnvelope(raw, "agent start"), START_RESULT_FIELDS, "malformed_json", "agent start returned fields outside the closed result shape");
|
|
327
|
+
if (result.type !== "agent_started") {
|
|
328
|
+
throw lifecycleError("unexpected_result", "agent start did not return agent_started");
|
|
329
|
+
}
|
|
330
|
+
const argv = result.argv;
|
|
331
|
+
if (!Array.isArray(argv) || argv.length < 2 || argv.some((item) => typeof item !== "string")
|
|
332
|
+
|| Buffer.byteLength(JSON.stringify(argv), "utf8") > MAX_FIELD_BYTES) {
|
|
333
|
+
throw lifecycleError("unexpected_result", "agent start returned a malformed launch argv");
|
|
334
|
+
}
|
|
335
|
+
const tail = argv.slice(-modelArgvTail.length);
|
|
336
|
+
if (JSON.stringify(tail) !== JSON.stringify(modelArgvTail)) {
|
|
337
|
+
throw lifecycleError("model_argv_mismatch", "the launch argv does not end with the validated model selection");
|
|
338
|
+
}
|
|
339
|
+
const agent = observedAgent(result.agent, "agent start");
|
|
340
|
+
const role = collectString([agent.name, agent.role], "agent name");
|
|
341
|
+
if (isCoordinatorRole(role)) {
|
|
342
|
+
throw lifecycleError("target_role_denied", "agent start returned a coordinator-class role", "denied");
|
|
343
|
+
}
|
|
344
|
+
if (role !== expectedRole) throw lifecycleError("stale_role_mapping", "agent start returned a different role");
|
|
345
|
+
const kind = collectString([agent.agent, agent.agent_kind, agent.kind], "agent kind");
|
|
346
|
+
if (kind !== "pi") throw lifecycleError("agent_mismatch", "the started agent is not the expected Pi kind");
|
|
347
|
+
if (agent.launch_pending === true || agent.interactive_ready === false) {
|
|
348
|
+
throw lifecycleError("role_not_ready", "the started agent is not observed ready");
|
|
349
|
+
}
|
|
350
|
+
const paneId = collectString([agent.pane_id], "agent pane id");
|
|
351
|
+
const cwd = collectString([agent.cwd, agent.foreground_cwd], "agent cwd");
|
|
352
|
+
if (!sameRealPath(cwd, canonicalRoot)) {
|
|
353
|
+
throw lifecycleError("repository_mismatch", "the started agent is not in the canonical trusted repository");
|
|
354
|
+
}
|
|
355
|
+
return paneId;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function readBackPane(raw, expectedPaneId, canonicalRoot) {
|
|
359
|
+
const result = closedShape(parseJsonEnvelope(raw, "pane get"), PANE_RESULT_FIELDS, "malformed_json", "pane get returned fields outside the closed result shape");
|
|
360
|
+
// Live pane_info carries many optional fields (scroll, agent_status,
|
|
361
|
+
// agent, terminal_title*, state_change_seq, ...); require identity + cwd
|
|
362
|
+
// and tolerate the observed shape instead of a closed field set.
|
|
363
|
+
const pane = isPlainObject(result.pane) ? result.pane : result;
|
|
364
|
+
if (!isPlainObject(pane)) {
|
|
365
|
+
throw lifecycleError("unexpected_result", "pane get returned no pane object");
|
|
366
|
+
}
|
|
367
|
+
const paneId = collectString([pane.pane_id], "pane id");
|
|
368
|
+
if (!PANE_ID_REGEXP.test(paneId)) throw lifecycleError("unexpected_result", "pane get returned a malformed pane identifier");
|
|
369
|
+
if (paneId !== expectedPaneId) throw lifecycleError("pane_mismatch", "pane get returned a different pane identity");
|
|
370
|
+
const cwd = collectString([pane.cwd, pane.foreground_cwd], "pane cwd");
|
|
371
|
+
if (!sameRealPath(cwd, canonicalRoot)) {
|
|
372
|
+
throw lifecycleError("repository_mismatch", "the created pane is not in the canonical trusted repository");
|
|
373
|
+
}
|
|
374
|
+
return paneId;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- Trusted repository resolution (registry + realpath + Git root) ---------
|
|
378
|
+
function validateRegistry(registryPath) {
|
|
379
|
+
let raw;
|
|
380
|
+
try {
|
|
381
|
+
raw = readFileSync(registryPath, "utf8");
|
|
382
|
+
} catch {
|
|
383
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry could not be read");
|
|
384
|
+
}
|
|
385
|
+
let registry;
|
|
386
|
+
try {
|
|
387
|
+
registry = JSON.parse(raw);
|
|
388
|
+
} catch {
|
|
389
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry is invalid");
|
|
390
|
+
}
|
|
391
|
+
const keys = isPlainObject(registry) ? Object.keys(registry) : [];
|
|
392
|
+
if (!isPlainObject(registry)
|
|
393
|
+
|| keys.length !== 2
|
|
394
|
+
|| !keys.includes("schema") || !keys.includes("repositories")
|
|
395
|
+
|| registry.schema !== WORKER_REPOSITORY_SCHEMA
|
|
396
|
+
|| !Array.isArray(registry.repositories)
|
|
397
|
+
|| registry.repositories.length < 1
|
|
398
|
+
|| registry.repositories.length > 32
|
|
399
|
+
|| new Set(registry.repositories).size !== registry.repositories.length
|
|
400
|
+
|| registry.repositories.some((name) => typeof name !== "string"
|
|
401
|
+
|| name.length < 1 || name.length > 64 || !SAFE_NAME_REGEXP.test(name))) {
|
|
402
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry is not trusted");
|
|
403
|
+
}
|
|
404
|
+
return registry.repositories;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function gitRoot(path) {
|
|
408
|
+
const value = spawnSync("git", ["-C", path, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
409
|
+
if (value.status !== 0 || !value.stdout.trim()) {
|
|
410
|
+
throw lifecycleError("repository_not_git_root", "the requested repository is not a Git work tree root");
|
|
411
|
+
}
|
|
412
|
+
return resolve(value.stdout.trim());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Resolves the requested registry name BEFORE any mutation and re-checks it
|
|
416
|
+
// between the two mutations. Physical identity (realpath + Git root) decides;
|
|
417
|
+
// lexical names never do.
|
|
418
|
+
// Registry lookup order (coordinator decision): the session repository's
|
|
419
|
+
// own config/herdr-worker-repositories.v1.json first; when missing (ENOENT
|
|
420
|
+
// only) the active Pi profile's shared config under
|
|
421
|
+
// {PI_CODING_AGENT_DIR}/config/ (fallback ~/.pi/agent/config/). A malformed
|
|
422
|
+
// registry at either level is a stop, never a silent fallthrough; only a
|
|
423
|
+
// missing one falls back.
|
|
424
|
+
function resolveProfileConfigPath(relative) {
|
|
425
|
+
const profileDir = process.env.PI_CODING_AGENT_DIR;
|
|
426
|
+
const base = typeof profileDir === "string" && profileDir.trim()
|
|
427
|
+
? resolve(profileDir.trim())
|
|
428
|
+
: resolve(homedir(), ".pi", "agent");
|
|
429
|
+
return { base, path: resolve(base, relative) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function resolveRegistryPathChecked(coordinatorReal) {
|
|
433
|
+
const localPath = resolve(coordinatorReal, WORKER_REPOSITORY_REGISTRY);
|
|
434
|
+
try {
|
|
435
|
+
if (realpathSync(localPath) !== localPath) {
|
|
436
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry escapes the configured repository");
|
|
437
|
+
}
|
|
438
|
+
return localPath;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error && error.name === "HerdrLifecycleError") throw error;
|
|
441
|
+
if (error?.code !== "ENOENT") {
|
|
442
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry could not be canonicalized");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const shared = resolveProfileConfigPath(WORKER_REPOSITORY_REGISTRY);
|
|
446
|
+
try {
|
|
447
|
+
const baseReal = realpathSync(shared.base);
|
|
448
|
+
if (realpathSync(shared.path) !== resolve(baseReal, WORKER_REPOSITORY_REGISTRY)) {
|
|
449
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry escapes the profile configuration");
|
|
450
|
+
}
|
|
451
|
+
return shared.path;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (error && error.name === "HerdrLifecycleError") throw error;
|
|
454
|
+
if (error?.code === "ENOENT") {
|
|
455
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry is unavailable");
|
|
456
|
+
}
|
|
457
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry could not be canonicalized");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function resolveTrustedSpawnRepository(coordinatorCwd, requestedName) {
|
|
462
|
+
const coordinator = resolve(coordinatorCwd);
|
|
463
|
+
let coordinatorReal;
|
|
464
|
+
try {
|
|
465
|
+
coordinatorReal = realpathSync(coordinator);
|
|
466
|
+
} catch {
|
|
467
|
+
throw lifecycleError("worker_registry_invalid", "the Herdr worker repository registry is unavailable");
|
|
468
|
+
}
|
|
469
|
+
const registryPath = resolveRegistryPathChecked(coordinatorReal);
|
|
470
|
+
const listed = validateRegistry(registryPath);
|
|
471
|
+
if (!listed.includes(requestedName)) {
|
|
472
|
+
throw lifecycleError("repository_untrusted", "the requested repository is not in the trusted registry", "denied");
|
|
473
|
+
}
|
|
474
|
+
const candidate = resolve(coordinatorReal, "..", requestedName);
|
|
475
|
+
let candidateReal;
|
|
476
|
+
try {
|
|
477
|
+
candidateReal = realpathSync(candidate);
|
|
478
|
+
} catch {
|
|
479
|
+
throw lifecycleError("repository_unavailable", "the requested repository is unavailable");
|
|
480
|
+
}
|
|
481
|
+
if (candidateReal !== resolve(coordinatorReal, "..", requestedName)) {
|
|
482
|
+
throw lifecycleError("repository_untrusted", "the requested repository entry is not a canonical sibling", "denied");
|
|
483
|
+
}
|
|
484
|
+
const root = gitRoot(candidateReal);
|
|
485
|
+
if (root !== candidateReal) {
|
|
486
|
+
throw lifecycleError("repository_not_git_root", "the requested repository resolved outside its Git root", "denied");
|
|
487
|
+
}
|
|
488
|
+
return candidateReal;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- Installed Pi model roll (read-only; no allowlist constant) -------------
|
|
492
|
+
// The authoritative installed roll is the merged Pi catalog: built-in provider
|
|
493
|
+
// models plus the profile's custom models.json entries. It resolves read-only
|
|
494
|
+
// via the documented fixed argv `pi --list-models` table; custom models.json
|
|
495
|
+
// entries remain an additional accepted source. The roll is parsed once per
|
|
496
|
+
// resolveInstalledModel call and is never cached across calls.
|
|
497
|
+
const PROVIDER_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
498
|
+
const MODEL_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
499
|
+
|
|
500
|
+
// Parses the fixed-width provider/model table emitted by `pi --list-models`.
|
|
501
|
+
// Only the first two whitespace tokens of each data row are read; the header
|
|
502
|
+
// row and anything unparseable are skipped. Returns null for unusable output.
|
|
503
|
+
function parseListModelsTable(raw) {
|
|
504
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw, "utf8") > MAX_PROCESS_OUTPUT_BYTES) return null;
|
|
505
|
+
const pairs = new Set();
|
|
506
|
+
for (const line of raw.split("\n")) {
|
|
507
|
+
const trimmed = line.trim();
|
|
508
|
+
if (!trimmed || trimmed.startsWith("provider")) continue;
|
|
509
|
+
const tokens = trimmed.split(/\s+/);
|
|
510
|
+
if (tokens.length < 2) continue;
|
|
511
|
+
const [provider, id] = tokens;
|
|
512
|
+
if (!PROVIDER_TOKEN_REGEXP.test(provider) || !MODEL_TOKEN_REGEXP.test(id)) continue;
|
|
513
|
+
pairs.add(`${provider}/${id}`);
|
|
514
|
+
}
|
|
515
|
+
return pairs;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Custom profile entries are an additional accepted source; an unreadable or
|
|
519
|
+
// malformed custom file contributes nothing and never blocks roll resolution.
|
|
520
|
+
function readCustomModelPairs(modelsPath) {
|
|
521
|
+
let roll;
|
|
522
|
+
try {
|
|
523
|
+
roll = JSON.parse(readFileSync(modelsPath, "utf8"));
|
|
524
|
+
} catch {
|
|
525
|
+
return new Set();
|
|
526
|
+
}
|
|
527
|
+
const pairs = new Set();
|
|
528
|
+
const providers = isPlainObject(roll) && isPlainObject(roll.providers) ? roll.providers : {};
|
|
529
|
+
for (const [provider, config] of Object.entries(providers)) {
|
|
530
|
+
const models = isPlainObject(config) && Array.isArray(config.models) ? config.models : [];
|
|
531
|
+
for (const model of models) {
|
|
532
|
+
if (isPlainObject(model) && typeof model.id === "string"
|
|
533
|
+
&& PROVIDER_TOKEN_REGEXP.test(provider)
|
|
534
|
+
&& MODEL_TOKEN_REGEXP.test(model.id)) {
|
|
535
|
+
pairs.add(`${provider}/${model.id}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return pairs;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Production roll source: fixed argv, read-only, bounded, no shell.
|
|
543
|
+
function runListModels() {
|
|
544
|
+
let result;
|
|
545
|
+
try {
|
|
546
|
+
result = spawnSync("pi", ["--list-models"], {
|
|
547
|
+
encoding: "utf8",
|
|
548
|
+
shell: false,
|
|
549
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
555
|
+
return result.stdout;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function resolveInstalledModel(requested, options = {}) {
|
|
559
|
+
let rollOutput = null;
|
|
560
|
+
if (typeof options.listModels === "string") {
|
|
561
|
+
rollOutput = options.listModels;
|
|
562
|
+
} else if (typeof options.listModels === "function") {
|
|
563
|
+
rollOutput = options.listModels();
|
|
564
|
+
} else {
|
|
565
|
+
rollOutput = runListModels();
|
|
566
|
+
}
|
|
567
|
+
const pairs = parseListModelsTable(rollOutput);
|
|
568
|
+
if (pairs === null) {
|
|
569
|
+
throw lifecycleError("model_roll_unavailable", "the installed Pi model roll could not be read");
|
|
570
|
+
}
|
|
571
|
+
const modelsPath = typeof options.modelsPath === "string" && options.modelsPath.trim()
|
|
572
|
+
? options.modelsPath
|
|
573
|
+
: resolvePiModelsPath();
|
|
574
|
+
for (const pair of readCustomModelPairs(modelsPath)) pairs.add(pair);
|
|
575
|
+
const slash = requested.indexOf("/");
|
|
576
|
+
const matches = slash < 0
|
|
577
|
+
? [...pairs].filter((pair) => pair.slice(pair.indexOf("/") + 1) === requested)
|
|
578
|
+
: (pairs.has(requested) ? [requested] : []);
|
|
579
|
+
if (matches.length === 0) {
|
|
580
|
+
throw lifecycleError("model_unknown", "the requested model is not in the installed Pi model roll", "denied");
|
|
581
|
+
}
|
|
582
|
+
if (matches.length > 1) {
|
|
583
|
+
throw lifecycleError("model_ambiguous", "the requested model id matches more than one installed provider model", "denied");
|
|
584
|
+
}
|
|
585
|
+
// Documented Pi selection flag; no thinking suffix, no extra arguments.
|
|
586
|
+
return Object.freeze(["--model", matches[0]]);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// --- Fixed argv builders ----------------------------------------------------
|
|
590
|
+
export function currentPaneArgv() {
|
|
591
|
+
return Object.freeze(["pane", "current", "--current"]);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function tabCreateArgv(canonicalRoot, role) {
|
|
595
|
+
return Object.freeze([
|
|
596
|
+
"tab", "create", "--cwd", canonicalRoot, "--no-focus", "--label", role,
|
|
597
|
+
]);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function splitArgv(coordinatorPaneId, canonicalRoot, placement) {
|
|
601
|
+
return Object.freeze([
|
|
602
|
+
"pane", "split", coordinatorPaneId,
|
|
603
|
+
"--direction", HERDR_DIRECTIONS[placement],
|
|
604
|
+
"--cwd", canonicalRoot,
|
|
605
|
+
"--no-focus",
|
|
606
|
+
]);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function startArgv(role, paneId, modelArgv) {
|
|
610
|
+
return Object.freeze([
|
|
611
|
+
"agent", "start", role, "--kind", "pi", "--pane", paneId,
|
|
612
|
+
"--timeout", String(AGENT_READY_TIMEOUT_MS), "--", ...modelArgv,
|
|
613
|
+
]);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- Herdr invocation -------------------------------------------------------
|
|
617
|
+
function normalizeProcessResult(value) {
|
|
618
|
+
if (!isPlainObject(value)) throw lifecycleError("unexpected_process_result", "Herdr returned an invalid process result");
|
|
619
|
+
const stdout = typeof value.stdout === "string" ? value.stdout : "";
|
|
620
|
+
const stderr = typeof value.stderr === "string" ? value.stderr : "";
|
|
621
|
+
const code = value.code ?? value.exitCode ?? value.status ?? 0;
|
|
622
|
+
if (!Number.isInteger(code) || code < 0) {
|
|
623
|
+
throw lifecycleError("unexpected_process_result", "Herdr returned an invalid process status");
|
|
624
|
+
}
|
|
625
|
+
if (Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8") > MAX_PROCESS_OUTPUT_BYTES) {
|
|
626
|
+
throw lifecycleError("oversized_process_output", "Herdr returned oversized output");
|
|
627
|
+
}
|
|
628
|
+
return { code, stdout, stderr };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function invokeHerdr(argv, canonicalRoot, options, signal) {
|
|
632
|
+
if (signal?.aborted) throw lifecycleError("aborted", "the spawn operation was aborted");
|
|
633
|
+
const executable = resolveTrustedHerdrExecutable(
|
|
634
|
+
typeof options.runProcess === "function" ? { runProcess: options.runProcess } : {},
|
|
635
|
+
);
|
|
636
|
+
const spec = {
|
|
637
|
+
executable,
|
|
638
|
+
argv: Object.freeze([...argv]),
|
|
639
|
+
spawnOptions: Object.freeze({
|
|
640
|
+
cwd: canonicalRoot,
|
|
641
|
+
env: process.env,
|
|
642
|
+
shell: false,
|
|
643
|
+
}),
|
|
644
|
+
shell: false,
|
|
645
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
646
|
+
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
|
|
647
|
+
};
|
|
648
|
+
let raw;
|
|
649
|
+
if (typeof options.runProcess === "function") {
|
|
650
|
+
raw = await Promise.resolve(options.runProcess(spec));
|
|
651
|
+
} else {
|
|
652
|
+
// Production path: the shared bounded spawner in the communication adapter
|
|
653
|
+
// is process-level; spawn here is reserved to the fixed argv builders via
|
|
654
|
+
// the same trusted executable pin, so the native spawn seam stays closed.
|
|
655
|
+
const { spawn } = await import("node:child_process");
|
|
656
|
+
raw = await new Promise((resolveResult) => {
|
|
657
|
+
const child = spawn(executable, spec.argv, {
|
|
658
|
+
...spec.spawnOptions,
|
|
659
|
+
shell: false,
|
|
660
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
661
|
+
});
|
|
662
|
+
let stdout = "";
|
|
663
|
+
let stderr = "";
|
|
664
|
+
const timer = setTimeout(() => {
|
|
665
|
+
try { child.kill("SIGTERM"); } catch { /* terminal result wins */ }
|
|
666
|
+
resolveResult({ code: -1, stdout, stderr });
|
|
667
|
+
}, COMMAND_TIMEOUT_MS);
|
|
668
|
+
child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
|
|
669
|
+
child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
|
|
670
|
+
child.on("error", () => { clearTimeout(timer); resolveResult({ code: -1, stdout, stderr }); });
|
|
671
|
+
child.on("close", (code) => { clearTimeout(timer); resolveResult({ code: code ?? -1, stdout, stderr }); });
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
const normalized = normalizeProcessResult(raw);
|
|
675
|
+
if (normalized.code !== 0) {
|
|
676
|
+
// Herdr emits bounded structured errors. Preserve only its code/message so
|
|
677
|
+
// a failed start is actionable without exposing arbitrary terminal output.
|
|
678
|
+
let detail = "";
|
|
679
|
+
for (const candidate of [normalized.stdout, normalized.stderr]) {
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(candidate);
|
|
682
|
+
const code = typeof parsed?.error?.code === "string" ? parsed.error.code : "";
|
|
683
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
684
|
+
if (code || message) {
|
|
685
|
+
detail = [code, message].filter(Boolean).join(": ");
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
} catch { /* generic bounded process failure below */ }
|
|
689
|
+
}
|
|
690
|
+
throw lifecycleError(
|
|
691
|
+
"herdr_process_failed",
|
|
692
|
+
detail ? `Herdr returned a process failure (${detail})` : "Herdr returned a process failure",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return normalized.stdout;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function agentListArgv() {
|
|
699
|
+
return Object.freeze(["agent", "list"]);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function duplicateRoleExists(raw, role) {
|
|
703
|
+
const result = parseJsonEnvelope(raw, "agent list");
|
|
704
|
+
const agents = result.agents;
|
|
705
|
+
if (!Array.isArray(agents) || agents.length > 64) {
|
|
706
|
+
throw lifecycleError("unexpected_result", "agent list returned an invalid bounded agent list");
|
|
707
|
+
}
|
|
708
|
+
return agents.some((agent) => {
|
|
709
|
+
if (!isPlainObject(agent)) return false;
|
|
710
|
+
return [agent.name, agent.role].some((value) => value === role);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function sha256(value) {
|
|
715
|
+
return createHash("sha256").update(value).digest("hex");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export async function executeHerdrSpawnWorker(params, context, options = {}, signal) {
|
|
719
|
+
let request;
|
|
720
|
+
try {
|
|
721
|
+
request = validateSpawnParams(params);
|
|
722
|
+
const { placement, role, model, repository } = request;
|
|
723
|
+
|
|
724
|
+
// Resolve BEFORE any mutation: registry + realpath + Git root.
|
|
725
|
+
let canonicalRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
|
|
726
|
+
const modelArgv = resolveInstalledModel(model, options);
|
|
727
|
+
|
|
728
|
+
// The calling pane must be a Herdr session pane; `pane current --current`
|
|
729
|
+
// resolves the coordinator pane identity for the final move step.
|
|
730
|
+
const currentPane = process.env.HERDR_PANE_ID;
|
|
731
|
+
if (typeof currentPane !== "string" || !currentPane.trim()) {
|
|
732
|
+
throw lifecycleError("current_pane_unavailable", "HERDR_PANE_ID is not set; no current pane to resolve", "denied");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Native confirmation is required before any mutation; the cheap local
|
|
736
|
+
// gate comes before any Herdr observation call.
|
|
737
|
+
if (!isNativeTuiContext(context)) {
|
|
738
|
+
throw lifecycleError("native_confirmation_required", "spawn_worker requires the interactive Pi TUI", "blocked");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Duplicate live names are denied at spawn (read-only observation).
|
|
742
|
+
const listed = await invokeHerdr(agentListArgv(), canonicalRoot, options, signal);
|
|
743
|
+
if (duplicateRoleExists(listed, role)) {
|
|
744
|
+
throw lifecycleError("duplicate_worker_name", "a live agent already holds this name", "denied");
|
|
745
|
+
}
|
|
746
|
+
const confirmed = await context.ui.confirm(
|
|
747
|
+
"Spawn Herdr worker",
|
|
748
|
+
[
|
|
749
|
+
"One guarded composite spawn: create the requested layout, then start and verify one Pi agent.",
|
|
750
|
+
`Role: ${role}`,
|
|
751
|
+
placement === "tab"
|
|
752
|
+
? "Placement: individual tab"
|
|
753
|
+
: `Placement: ${placement} (Herdr split: ${HERDR_DIRECTIONS[placement]})`,
|
|
754
|
+
`Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
|
|
755
|
+
`Repository: ${canonicalRoot}`,
|
|
756
|
+
"No rollback is automatic; a failed step leaves the created state for human cleanup.",
|
|
757
|
+
].join("\n"),
|
|
758
|
+
);
|
|
759
|
+
if (confirmed !== true) {
|
|
760
|
+
return errorResult(lifecycleError("confirmation_denied", "native confirmation was not granted", "stopped"));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Revalidation point after confirmation, before the first mutation.
|
|
764
|
+
const reconfirmedRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
|
|
765
|
+
if (reconfirmedRoot !== canonicalRoot) {
|
|
766
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed after confirmation");
|
|
767
|
+
}
|
|
768
|
+
canonicalRoot = reconfirmedRoot;
|
|
769
|
+
|
|
770
|
+
// Herdr 0.8.2 owns new-shell readiness. Follow its documented topology:
|
|
771
|
+
// split directly for pane placement, or create a tab for tab placement.
|
|
772
|
+
let coordinator = null;
|
|
773
|
+
if (placement !== "tab") {
|
|
774
|
+
const currentRaw = await invokeHerdr(currentPaneArgv(), canonicalRoot, options, signal);
|
|
775
|
+
coordinator = parseCurrentPane(currentRaw);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const layoutPartial = placement === "tab" ? "tab_created" : "pane_created";
|
|
779
|
+
let layoutRaw;
|
|
780
|
+
let rootPaneId;
|
|
781
|
+
let tabId;
|
|
782
|
+
try {
|
|
783
|
+
if (placement === "tab") {
|
|
784
|
+
layoutRaw = await invokeHerdr(tabCreateArgv(canonicalRoot, role), canonicalRoot, options, signal);
|
|
785
|
+
({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
|
|
786
|
+
} else {
|
|
787
|
+
layoutRaw = await invokeHerdr(
|
|
788
|
+
splitArgv(coordinator.paneId, canonicalRoot, placement),
|
|
789
|
+
canonicalRoot, options, signal,
|
|
790
|
+
);
|
|
791
|
+
const split = parsePaneSplit(layoutRaw, coordinator.tabId);
|
|
792
|
+
rootPaneId = split.paneId;
|
|
793
|
+
tabId = split.tabId;
|
|
794
|
+
}
|
|
795
|
+
} catch (error) {
|
|
796
|
+
return partialResult(error, {
|
|
797
|
+
partial: layoutPartial, paneId: rootPaneId, tabId, role, modelArgv,
|
|
798
|
+
...(typeof layoutRaw === "string" ? { rawResponse: layoutRaw } : {}),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let agentStartSucceeded = false;
|
|
803
|
+
try {
|
|
804
|
+
// Re-check the registry between the mutations.
|
|
805
|
+
const betweenRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
|
|
806
|
+
if (betweenRoot !== canonicalRoot) {
|
|
807
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed between mutations");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Start exactly once in the shell pane returned by the layout command.
|
|
811
|
+
const startCall = startArgv(role, rootPaneId, modelArgv);
|
|
812
|
+
let startRaw;
|
|
813
|
+
try {
|
|
814
|
+
startRaw = await invokeHerdr(startCall, canonicalRoot, options, signal);
|
|
815
|
+
agentStartSucceeded = true;
|
|
816
|
+
} catch (error) {
|
|
817
|
+
throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Agent start is a mutation, so authorization must be resolved again
|
|
821
|
+
// before accepting any start/read-back facts.
|
|
822
|
+
const postStartRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
|
|
823
|
+
if (postStartRoot !== canonicalRoot) {
|
|
824
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed after agent start");
|
|
825
|
+
}
|
|
826
|
+
canonicalRoot = postStartRoot;
|
|
827
|
+
|
|
828
|
+
const startedPaneId = readBackStart(startRaw, role, canonicalRoot, [...modelArgv]);
|
|
829
|
+
if (startedPaneId !== rootPaneId) {
|
|
830
|
+
throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
|
|
831
|
+
}
|
|
832
|
+
const agentRaw = await invokeHerdr(["agent", "get", role], canonicalRoot, options, signal);
|
|
833
|
+
const agentReadBack = readBackAgent(agentRaw, role, canonicalRoot, [...modelArgv]);
|
|
834
|
+
if (agentReadBack.paneId !== rootPaneId) {
|
|
835
|
+
throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
|
|
836
|
+
}
|
|
837
|
+
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], canonicalRoot, options, signal);
|
|
838
|
+
readBackPane(paneRaw, rootPaneId, canonicalRoot);
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
842
|
+
ok: true,
|
|
843
|
+
status: "spawned",
|
|
844
|
+
role,
|
|
845
|
+
reportMarkers: reportMarkersForRole(role),
|
|
846
|
+
placement,
|
|
847
|
+
direction: placement === "tab" ? null : HERDR_DIRECTIONS[placement],
|
|
848
|
+
paneId: rootPaneId,
|
|
849
|
+
rootPaneId,
|
|
850
|
+
tabId,
|
|
851
|
+
modelArgv: [...modelArgv],
|
|
852
|
+
repository: canonicalRoot,
|
|
853
|
+
hashes: Object.freeze({
|
|
854
|
+
request: sha256(JSON.stringify(request)),
|
|
855
|
+
layoutCreateResponse: sha256(layoutRaw),
|
|
856
|
+
startResponse: sha256(startRaw),
|
|
857
|
+
}),
|
|
858
|
+
nonAuthorizing: true,
|
|
859
|
+
authorityCreated: false,
|
|
860
|
+
};
|
|
861
|
+
} catch (error) {
|
|
862
|
+
// Once agent start exits successfully, parsing or read-back failures
|
|
863
|
+
// cannot erase that mutation: report a live-agent partial so the user is
|
|
864
|
+
// never told to treat it as merely an empty orphan tab.
|
|
865
|
+
return partialResult(error, {
|
|
866
|
+
partial: agentStartSucceeded ? "agent_started" : layoutPartial,
|
|
867
|
+
paneId: rootPaneId, tabId, role, modelArgv,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
} catch (error) {
|
|
871
|
+
return errorResult(error);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function toolResult(details) {
|
|
876
|
+
return {
|
|
877
|
+
content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
|
|
878
|
+
details,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function registerHerdrLifecycleInterface(pi, options = {}) {
|
|
883
|
+
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
884
|
+
REGISTRATIONS.add(pi);
|
|
885
|
+
pi.registerTool({
|
|
886
|
+
name: HERDR_SPAWN_WORKER_TOOL,
|
|
887
|
+
label: "Herdr Spawn Worker",
|
|
888
|
+
description: "One guarded composite spawn into the caller's Herdr session: create either a right/below split pane or an individual tab in a trusted repository, then start and verify one Pi agent there. Includes native confirmation and bounded read-back.",
|
|
889
|
+
promptSnippet: "Use agentic_herdr_spawn_worker only to spawn one confirmed worker as its own tab or as a right/below pane in a trusted registry repository; it exposes no raw Herdr management verbs and no terminal remote control.",
|
|
890
|
+
promptGuidelines: [
|
|
891
|
+
"Roles are free-form safe names except the coordinator class; models must resolve against the installed Pi model roll.",
|
|
892
|
+
"A failed start leaves the created tab for human cleanup; no automatic close is performed.",
|
|
893
|
+
"The receipt is observed state only: names, argv, and self-reports are never authority.",
|
|
894
|
+
],
|
|
895
|
+
parameters: HERDR_SPAWN_WORKER_PARAMETERS,
|
|
896
|
+
async execute(_id, params, signal, _update, context) {
|
|
897
|
+
return toolResult(await executeHerdrSpawnWorker(params, context, options, signal));
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export default registerHerdrLifecycleInterface;
|