@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2

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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +68 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +30 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +14 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1650 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +143 -0
  14. package/dist/app/init.js +1111 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +11 -0
  22. package/dist/cli/commands/docs.js +133 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +13 -0
  26. package/dist/cli/commands/host.js +125 -0
  27. package/dist/cli/commands/init.d.ts +51 -0
  28. package/dist/cli/commands/init.js +668 -0
  29. package/dist/cli/main.d.ts +2 -0
  30. package/dist/cli/main.js +10 -0
  31. package/dist/cli/output.d.ts +4 -0
  32. package/dist/cli/output.js +1 -0
  33. package/dist/cli/run.d.ts +2 -0
  34. package/dist/cli/run.js +597 -0
  35. package/dist/code/budget.d.ts +14 -0
  36. package/dist/code/budget.js +39 -0
  37. package/dist/code/exec.d.ts +23 -0
  38. package/dist/code/exec.js +133 -0
  39. package/dist/code/identity.d.ts +13 -0
  40. package/dist/code/identity.js +214 -0
  41. package/dist/code/index.d.ts +6 -0
  42. package/dist/code/index.js +3 -0
  43. package/dist/code/providers/codegraph.d.ts +59 -0
  44. package/dist/code/providers/codegraph.js +567 -0
  45. package/dist/code/registration.d.ts +9 -0
  46. package/dist/code/registration.js +293 -0
  47. package/dist/code/service.d.ts +53 -0
  48. package/dist/code/service.js +633 -0
  49. package/dist/code/text.d.ts +3 -0
  50. package/dist/code/text.js +202 -0
  51. package/dist/collab/admission.d.ts +450 -0
  52. package/dist/collab/admission.js +797 -0
  53. package/dist/collab/canonical.d.ts +26 -0
  54. package/dist/collab/canonical.js +136 -0
  55. package/dist/collab/contracts.d.ts +73 -0
  56. package/dist/collab/contracts.js +76 -0
  57. package/dist/collab/envelope.d.ts +52 -0
  58. package/dist/collab/envelope.js +241 -0
  59. package/dist/collab/fingerprint.d.ts +7 -0
  60. package/dist/collab/fingerprint.js +58 -0
  61. package/dist/collab/handoff.d.ts +350 -0
  62. package/dist/collab/handoff.js +513 -0
  63. package/dist/collab/index.d.ts +9 -0
  64. package/dist/collab/index.js +8 -0
  65. package/dist/collab/lifecycle.d.ts +449 -0
  66. package/dist/collab/lifecycle.js +534 -0
  67. package/dist/collab/messages.d.ts +148 -0
  68. package/dist/collab/messages.js +583 -0
  69. package/dist/collab/notify.d.ts +47 -0
  70. package/dist/collab/notify.js +140 -0
  71. package/dist/collab/preflight.d.ts +31 -0
  72. package/dist/collab/preflight.js +271 -0
  73. package/dist/collab/resources.d.ts +119 -0
  74. package/dist/collab/resources.js +451 -0
  75. package/dist/collab/store.d.ts +143 -0
  76. package/dist/collab/store.js +606 -0
  77. package/dist/collab/types.d.ts +354 -0
  78. package/dist/collab/types.js +1 -0
  79. package/dist/collab/validation.d.ts +10 -0
  80. package/dist/collab/validation.js +303 -0
  81. package/dist/continuity/context.d.ts +24 -0
  82. package/dist/continuity/context.js +179 -0
  83. package/dist/continuity/index.d.ts +3 -0
  84. package/dist/continuity/index.js +1 -0
  85. package/dist/continuity/paths.d.ts +37 -0
  86. package/dist/continuity/paths.js +258 -0
  87. package/dist/continuity/service.d.ts +59 -0
  88. package/dist/continuity/service.js +1258 -0
  89. package/dist/hosts/claude/native.d.ts +79 -0
  90. package/dist/hosts/claude/native.js +853 -0
  91. package/dist/hosts/codex/native.d.ts +345 -0
  92. package/dist/hosts/codex/native.js +1528 -0
  93. package/dist/hosts/codex/protocol.d.ts +51 -0
  94. package/dist/hosts/codex/protocol.js +42 -0
  95. package/dist/hosts/detect.d.ts +28 -0
  96. package/dist/hosts/detect.js +73 -0
  97. package/dist/hosts/index.d.ts +10 -0
  98. package/dist/hosts/index.js +10 -0
  99. package/dist/hosts/integration/bundle.d.ts +16 -0
  100. package/dist/hosts/integration/bundle.js +81 -0
  101. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  102. package/dist/hosts/integration/hook-entry.js +68 -0
  103. package/dist/hosts/integration/hook.d.ts +32 -0
  104. package/dist/hosts/integration/hook.js +781 -0
  105. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  106. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  107. package/dist/hosts/integration/pi-template.d.ts +3 -0
  108. package/dist/hosts/integration/pi-template.js +5 -0
  109. package/dist/hosts/integration/service.d.ts +41 -0
  110. package/dist/hosts/integration/service.js +1381 -0
  111. package/dist/hosts/integration/support.d.ts +36 -0
  112. package/dist/hosts/integration/support.js +41 -0
  113. package/dist/hosts/integration/templates.d.ts +22 -0
  114. package/dist/hosts/integration/templates.js +70 -0
  115. package/dist/hosts/pi/protocol.d.ts +22 -0
  116. package/dist/hosts/pi/protocol.js +28 -0
  117. package/dist/hosts/pi/query-result.d.ts +22 -0
  118. package/dist/hosts/pi/query-result.js +710 -0
  119. package/dist/hosts/pi/rpc.d.ts +937 -0
  120. package/dist/hosts/pi/rpc.js +1621 -0
  121. package/dist/hosts/pilot.d.ts +485 -0
  122. package/dist/hosts/pilot.js +593 -0
  123. package/dist/index.d.ts +5 -0
  124. package/dist/index.js +4 -0
  125. package/dist/infra/fs.d.ts +27 -0
  126. package/dist/infra/fs.js +130 -0
  127. package/dist/infra/git.d.ts +60 -0
  128. package/dist/infra/git.js +358 -0
  129. package/dist/infra/owned-process-group.d.ts +173 -0
  130. package/dist/infra/owned-process-group.js +571 -0
  131. package/dist/install/apply.d.ts +65 -0
  132. package/dist/install/apply.js +663 -0
  133. package/dist/install/index.d.ts +5 -0
  134. package/dist/install/index.js +5 -0
  135. package/dist/install/ownership.d.ts +85 -0
  136. package/dist/install/ownership.js +430 -0
  137. package/dist/install/plan.d.ts +39 -0
  138. package/dist/install/plan.js +97 -0
  139. package/dist/install/rollback.d.ts +18 -0
  140. package/dist/install/rollback.js +544 -0
  141. package/dist/install/runtime.d.ts +76 -0
  142. package/dist/install/runtime.js +1145 -0
  143. package/dist/knowledge/catalog.d.ts +47 -0
  144. package/dist/knowledge/catalog.js +308 -0
  145. package/dist/knowledge/context.d.ts +28 -0
  146. package/dist/knowledge/context.js +61 -0
  147. package/dist/knowledge/index.d.ts +3 -0
  148. package/dist/knowledge/index.js +1 -0
  149. package/dist/knowledge/journal.d.ts +16 -0
  150. package/dist/knowledge/journal.js +109 -0
  151. package/dist/knowledge/lifecycle.d.ts +8 -0
  152. package/dist/knowledge/lifecycle.js +1001 -0
  153. package/dist/knowledge/paths.d.ts +37 -0
  154. package/dist/knowledge/paths.js +236 -0
  155. package/dist/knowledge/refs.d.ts +36 -0
  156. package/dist/knowledge/refs.js +267 -0
  157. package/dist/knowledge/service.d.ts +21 -0
  158. package/dist/knowledge/service.js +393 -0
  159. package/dist/knowledge/types.d.ts +171 -0
  160. package/dist/knowledge/types.js +1 -0
  161. package/dist/knowledge/write.d.ts +8 -0
  162. package/dist/knowledge/write.js +1223 -0
  163. package/dist/package-info.d.ts +6 -0
  164. package/dist/package-info.js +19 -0
  165. package/dist/schemas/code.d.ts +377 -0
  166. package/dist/schemas/code.js +145 -0
  167. package/dist/schemas/collaboration.d.ts +115 -0
  168. package/dist/schemas/collaboration.js +106 -0
  169. package/dist/schemas/continuity.d.ts +315 -0
  170. package/dist/schemas/continuity.js +199 -0
  171. package/dist/schemas/diagnostics.d.ts +28 -0
  172. package/dist/schemas/diagnostics.js +27 -0
  173. package/dist/schemas/doctor.d.ts +157 -0
  174. package/dist/schemas/doctor.js +75 -0
  175. package/dist/schemas/document.d.ts +644 -0
  176. package/dist/schemas/document.js +296 -0
  177. package/dist/schemas/errors.d.ts +177 -0
  178. package/dist/schemas/errors.js +176 -0
  179. package/dist/schemas/evidence.d.ts +24 -0
  180. package/dist/schemas/evidence.js +16 -0
  181. package/dist/schemas/host.d.ts +436 -0
  182. package/dist/schemas/host.js +201 -0
  183. package/dist/schemas/index.d.ts +15 -0
  184. package/dist/schemas/index.js +15 -0
  185. package/dist/schemas/init.d.ts +121 -0
  186. package/dist/schemas/init.js +94 -0
  187. package/dist/schemas/install.d.ts +213 -0
  188. package/dist/schemas/install.js +139 -0
  189. package/dist/schemas/machine.d.ts +43 -0
  190. package/dist/schemas/machine.js +96 -0
  191. package/dist/schemas/outcome.d.ts +23 -0
  192. package/dist/schemas/outcome.js +17 -0
  193. package/dist/schemas/paths.d.ts +19 -0
  194. package/dist/schemas/paths.js +63 -0
  195. package/dist/schemas/workspace.d.ts +120 -0
  196. package/dist/schemas/workspace.js +201 -0
  197. package/dist/workspace/config-file.d.ts +36 -0
  198. package/dist/workspace/config-file.js +237 -0
  199. package/dist/workspace/config.d.ts +28 -0
  200. package/dist/workspace/config.js +79 -0
  201. package/dist/workspace/discover.d.ts +34 -0
  202. package/dist/workspace/discover.js +195 -0
  203. package/dist/workspace/documents.d.ts +21 -0
  204. package/dist/workspace/documents.js +395 -0
  205. package/dist/workspace/ignore.d.ts +10 -0
  206. package/dist/workspace/ignore.js +48 -0
  207. package/dist/workspace/index.d.ts +7 -0
  208. package/dist/workspace/index.js +3 -0
  209. package/dist/workspace/repositories.d.ts +24 -0
  210. package/dist/workspace/repositories.js +411 -0
  211. package/dist/workspace/resolve.d.ts +24 -0
  212. package/dist/workspace/resolve.js +268 -0
  213. package/dist/workspace/types.d.ts +71 -0
  214. package/dist/workspace/types.js +1 -0
  215. package/dist/workspace/validation.d.ts +18 -0
  216. package/dist/workspace/validation.js +197 -0
  217. package/package.json +105 -4
  218. package/skills/pappra/SKILL.md +68 -0
  219. package/skills/pappra/references/hosts.md +30 -0
  220. package/skills/pappra/references/lifecycle.md +37 -0
  221. package/skills/pappra/references/operations.md +14 -0
@@ -0,0 +1,1621 @@
1
+ import { errorCode, object, parseJson } from '../../collab/validation.js';
2
+ import { isAssignment } from '../../collab/contracts.js';
3
+ import { isPiRecord } from './protocol.js';
4
+ function errorDetail(error) {
5
+ if (object(error) && typeof error['detail'] === 'string')
6
+ return error['detail'];
7
+ return error instanceof Error ? error.message : null;
8
+ }
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { spawn } from 'node:child_process';
13
+ import { StringDecoder } from 'node:string_decoder';
14
+ import { admitLaunch, claimCovers, classifyIdentity, hasPendingCancellation, reserveLaunch, settleLaunch, TOOL_PROFILES, } from '../../collab/admission.js';
15
+ import { describeResource } from '../../collab/canonical.js';
16
+ import { buildMessage, sha256File } from '../../collab/envelope.js';
17
+ import { submitMessage } from '../../collab/messages.js';
18
+ import { findAttempt } from '../../collab/lifecycle.js';
19
+ import { createNonce, nowIso, readRecord } from '../../collab/store.js';
20
+ // Gated, owned Pi RPC lifecycle. One launch, one frozen prompt, repeated native queries and an
21
+ // explicit cancellation, all on the child this process started.
22
+ //
23
+ // Public surface:
24
+ // startOwnedPiRpc(options) admission -> atomic reservation -> spawn -> identity/header binding
25
+ // runOwnedPiRpc(options) one bounded operation: `query` (default) or `prompt`, then finish
26
+ // PiRpcRefusal a classified refusal (`code`, `status`, `detail`)
27
+ //
28
+ // Deliberate reductions and limits (see docs/pi-rpc.md):
29
+ // * No ungated transport export, no raw request on the handle, no arbitrary prompt text, no
30
+ // caller outcome/result overrides, no protocol identity override or bypass.
31
+ // * Query/cancel always use the authoritative protocol identities read from the registered
32
+ // task; STATUS_QUERY is recorded before the observation it asks for.
33
+ // * A cancellation never starts work and never sends CANCELLED; claims stay held.
34
+ // * The caller's registered programmatic invocation is the authorization point.
35
+ // * Shutdown is per-child: EOF, then a bounded signal of this exact child, then a bounded wait
36
+ // for our own stdio handles. Inheriting descendants are never signalled.
37
+ const RPC_SCHEMA = 'harness-collab/pi-rpc/1';
38
+ const EXECUTION_SCHEMA = 'harness-collab/native-execution/1';
39
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
40
+ const DEFAULT_OPERATION_TIMEOUT_MS = 120000;
41
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
42
+ const DEFAULT_SIGNAL_GRACE_MS = 2000;
43
+ const DEFAULT_STREAM_CLOSE_GRACE_MS = 500;
44
+ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
45
+ const DEFAULT_MAX_STDERR_BYTES = 64 * 1024;
46
+ const MAX_REQUEST_TIMEOUT_MS = 60000;
47
+ const MAX_OPERATION_TIMEOUT_MS = 600000;
48
+ const MAX_PROMPT_BYTES = 20000;
49
+ const HEADER_READ_BYTES = 64 * 1024;
50
+ const PHASES = new Set([
51
+ 'discovery',
52
+ 'implementation',
53
+ 'review',
54
+ 'handoff',
55
+ 'fix',
56
+ ]);
57
+ const ACTIONS = new Set(['query', 'prompt']);
58
+ /** A classified refusal: `code` is stable, `status` is `invalid` | `needs_input` | `failed`. */
59
+ export class PiRpcRefusal extends Error {
60
+ code;
61
+ status;
62
+ detail;
63
+ constructor(code, status = 'invalid', detail = null) {
64
+ super(code);
65
+ this.code = code;
66
+ this.status = status;
67
+ this.detail = detail;
68
+ this.name = 'PiRpcRefusal';
69
+ this.code = code;
70
+ this.status = status;
71
+ this.detail = detail;
72
+ }
73
+ }
74
+ const isObject = object;
75
+ /** Resolve `true` when `promise` settles, or `false` after `ms`; the timer is always cleared. */
76
+ function waitBounded(promise, ms) {
77
+ let timer;
78
+ const timeout = new Promise((resolve) => {
79
+ timer = setTimeout(() => resolve(false), ms);
80
+ if (typeof timer.unref === 'function')
81
+ timer.unref();
82
+ });
83
+ return Promise.race([promise.then(() => true), timeout]).finally(() => clearTimeout(timer));
84
+ }
85
+ function samePath(left, right) {
86
+ try {
87
+ return fs.realpathSync(left) === fs.realpathSync(right);
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Internal transport over one caller-owned RPC child
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Attach the JSONL protocol to a Pi child this process spawned and gated.
98
+ *
99
+ * `exit` is the process boundary and `close` is the stream boundary. They are tracked
100
+ * separately so a descendant that inherited stdout/stderr cannot extend shutdown: the owned
101
+ * pipe handles are destroyed after a bounded wait instead of waiting for the descendant.
102
+ */
103
+ function attachTransport(child, options = {}) {
104
+ if (!child ||
105
+ typeof child.once !== 'function' ||
106
+ !child.stdin ||
107
+ !child.stdout ||
108
+ !child.stderr) {
109
+ throw new PiRpcRefusal('child_required');
110
+ }
111
+ const requestTimeoutMs = boundedTimeout(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS, 'request_timeout_out_of_range');
112
+ const shutdownTimeoutMs = boundedTimeout(options.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS, 'shutdown_timeout_out_of_range');
113
+ const signalGraceMs = boundedTimeout(options.signalGraceMs, DEFAULT_SIGNAL_GRACE_MS, MAX_REQUEST_TIMEOUT_MS, 'signal_grace_out_of_range');
114
+ const streamCloseGraceMs = boundedTimeout(options.streamCloseGraceMs, DEFAULT_STREAM_CLOSE_GRACE_MS, MAX_REQUEST_TIMEOUT_MS, 'stream_close_grace_out_of_range');
115
+ const maxBytes = typeof options.maxBytes === 'number' &&
116
+ Number.isSafeInteger(options.maxBytes) &&
117
+ options.maxBytes > 0
118
+ ? options.maxBytes
119
+ : DEFAULT_MAX_BYTES;
120
+ const maxStderrBytes = typeof options.maxStderrBytes === 'number' &&
121
+ Number.isSafeInteger(options.maxStderrBytes) &&
122
+ options.maxStderrBytes >= 0
123
+ ? options.maxStderrBytes
124
+ : DEFAULT_MAX_STDERR_BYTES;
125
+ const onRecord = typeof options.onRecord === 'function' ? options.onRecord : null;
126
+ const nonce = crypto.randomBytes(6).toString('hex');
127
+ const decoder = new StringDecoder('utf8');
128
+ const records = [];
129
+ const pending = new Map();
130
+ const eventWaiters = [];
131
+ let drainQueue = [];
132
+ let text = '';
133
+ let bytes = 0;
134
+ let stderrBytes = 0;
135
+ const stderrChunks = [];
136
+ let sequence = 0;
137
+ let streamsClosed = false;
138
+ let parentExit = null;
139
+ let spawnError = null;
140
+ let spawnFailed = false;
141
+ let killedAfterEof = false;
142
+ let signaledKill = false;
143
+ let streamsDetached = false;
144
+ let fatal = null;
145
+ let resolveParentExit = () => { };
146
+ let resolveStreamClose = () => { };
147
+ const parentExitPromise = new Promise((resolve) => {
148
+ resolveParentExit = resolve;
149
+ });
150
+ const streamClosePromise = new Promise((resolve) => {
151
+ resolveStreamClose = resolve;
152
+ });
153
+ const declared = () => parentExit !== null;
154
+ function fail(status, code, detail = null) {
155
+ if (fatal)
156
+ return;
157
+ fatal = new PiRpcRefusal(code, status, detail);
158
+ for (const [id, waiter] of [...pending.entries()]) {
159
+ pending.delete(id);
160
+ clearTimeout(waiter.timer);
161
+ waiter.reject(fatal);
162
+ }
163
+ settleWaiters();
164
+ }
165
+ function flushDrains() {
166
+ const queue = drainQueue;
167
+ drainQueue = [];
168
+ for (const resolve of queue)
169
+ resolve();
170
+ }
171
+ function findRecord(predicate, fromOrdinal) {
172
+ for (let index = fromOrdinal; index < records.length; index += 1) {
173
+ if (predicate(records[index], index))
174
+ return { record: records[index], ordinal: index };
175
+ }
176
+ return null;
177
+ }
178
+ function settleWaiters() {
179
+ for (let index = eventWaiters.length - 1; index >= 0; index -= 1) {
180
+ const waiter = eventWaiters[index];
181
+ const found = findRecord(waiter.predicate, waiter.fromOrdinal);
182
+ if (found) {
183
+ eventWaiters.splice(index, 1);
184
+ clearTimeout(waiter.timer);
185
+ waiter.resolve(found);
186
+ }
187
+ else if (fatal) {
188
+ eventWaiters.splice(index, 1);
189
+ clearTimeout(waiter.timer);
190
+ waiter.reject(fatal);
191
+ }
192
+ else if (streamsClosed) {
193
+ eventWaiters.splice(index, 1);
194
+ clearTimeout(waiter.timer);
195
+ waiter.reject(new PiRpcRefusal('rpc_exited_early', 'needs_input'));
196
+ }
197
+ }
198
+ }
199
+ function handleLine(line) {
200
+ const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
201
+ if (!trimmed.length)
202
+ return;
203
+ if (onRecord) {
204
+ try {
205
+ onRecord(trimmed);
206
+ }
207
+ catch (error) {
208
+ fail('failed', 'evidence_write_failed', errorDetail(error));
209
+ return;
210
+ }
211
+ }
212
+ let record;
213
+ try {
214
+ record = parseJson(trimmed);
215
+ }
216
+ catch {
217
+ fail('invalid', 'malformed_record', 'a stdout record is not valid JSON');
218
+ return;
219
+ }
220
+ if (!isPiRecord(record)) {
221
+ fail('invalid', 'malformed_record', 'a stdout record is not a JSON object');
222
+ return;
223
+ }
224
+ records.push(record);
225
+ if (record.type === 'response') {
226
+ const waiter = record.id !== undefined ? pending.get(record.id) : null;
227
+ if (!waiter) {
228
+ fail('invalid', record.command === 'parse'
229
+ ? 'rpc_parse_error'
230
+ : 'unexpected_response_id', `a response arrived for ${record.id ?? 'no request id'}`);
231
+ return;
232
+ }
233
+ pending.delete(record.id);
234
+ clearTimeout(waiter.timer);
235
+ if (record.command !== waiter.type) {
236
+ waiter.reject(new PiRpcRefusal('response_command_mismatch', 'invalid', `expected ${waiter.type}, got ${record.command}`));
237
+ return;
238
+ }
239
+ if (record.success !== true) {
240
+ waiter.reject(new PiRpcRefusal('native_command_failed', 'invalid', record.error ?? null));
241
+ return;
242
+ }
243
+ waiter.resolve(record);
244
+ }
245
+ settleWaiters();
246
+ }
247
+ function finishStreams() {
248
+ if (streamsClosed)
249
+ return;
250
+ streamsClosed = true;
251
+ try {
252
+ text += decoder.end();
253
+ }
254
+ catch {
255
+ text = '';
256
+ }
257
+ if (text.trim().length) {
258
+ fail('invalid', 'incomplete_framing', 'the child closed with a partial record');
259
+ text = '';
260
+ }
261
+ for (const [id, waiter] of [...pending.entries()]) {
262
+ pending.delete(id);
263
+ clearTimeout(waiter.timer);
264
+ waiter.reject(fatal ?? new PiRpcRefusal('rpc_exited_early', 'needs_input'));
265
+ }
266
+ settleWaiters();
267
+ flushDrains();
268
+ resolveStreamClose();
269
+ }
270
+ child.once('exit', (code, signal) => {
271
+ parentExit = { code: code ?? null, signal: signal ?? null };
272
+ resolveParentExit();
273
+ });
274
+ child.once('close', (code, signal) => {
275
+ if (parentExit === null) {
276
+ parentExit = { code: code ?? null, signal: signal ?? null };
277
+ resolveParentExit();
278
+ }
279
+ finishStreams();
280
+ });
281
+ child.once('error', (error) => {
282
+ // A spawn error (for example ENOENT) is reported before close; a stdin error after spawn is
283
+ // a broken pipe that the stream-close path already explains.
284
+ spawnFailed = true;
285
+ spawnError = error;
286
+ fail('needs_input', 'spawn_failed', errorDetail(error));
287
+ if (child.pid === undefined) {
288
+ if (parentExit === null) {
289
+ parentExit = { code: null, signal: null };
290
+ resolveParentExit();
291
+ }
292
+ finishStreams();
293
+ }
294
+ });
295
+ child.stdin.on('error', () => {
296
+ if (!streamsClosed)
297
+ fail('needs_input', 'rpc_stdin_failed');
298
+ });
299
+ child.stdin.on('drain', flushDrains);
300
+ child.stdout.on('data', (chunk) => {
301
+ bytes += chunk.length;
302
+ if (bytes > maxBytes) {
303
+ fail('invalid', 'rpc_response_too_large', `${bytes} bytes of stdout exceeds ${maxBytes}`);
304
+ child.kill('SIGKILL');
305
+ return;
306
+ }
307
+ text += decoder.write(chunk);
308
+ let index = text.indexOf('\n');
309
+ while (index !== -1) {
310
+ const line = text.slice(0, index);
311
+ text = text.slice(index + 1);
312
+ handleLine(line);
313
+ index = text.indexOf('\n');
314
+ }
315
+ });
316
+ child.stderr.on('data', (chunk) => {
317
+ if (stderrBytes < maxStderrBytes)
318
+ stderrChunks.push(chunk.subarray(0, maxStderrBytes - stderrBytes));
319
+ stderrBytes += chunk.length;
320
+ });
321
+ // Destroying our own read ends must not surface as an unhandled stream error.
322
+ child.stdout.on('error', () => { });
323
+ child.stderr.on('error', () => { });
324
+ function writeLine(record) {
325
+ return new Promise((resolve, reject) => {
326
+ if (streamsClosed || !child.stdin.writable) {
327
+ reject(new PiRpcRefusal('rpc_stdin_closed', 'needs_input'));
328
+ return;
329
+ }
330
+ let flushed;
331
+ try {
332
+ flushed = child.stdin.write(`${JSON.stringify(record)}\n`);
333
+ }
334
+ catch {
335
+ reject(new PiRpcRefusal('rpc_stdin_failed', 'needs_input'));
336
+ return;
337
+ }
338
+ if (flushed)
339
+ resolve();
340
+ else
341
+ drainQueue.push(resolve);
342
+ });
343
+ }
344
+ function request(type, fields = {}, { timeoutMs = requestTimeoutMs } = {}) {
345
+ if (fatal)
346
+ return Promise.reject(fatal);
347
+ if (streamsClosed)
348
+ return Promise.reject(new PiRpcRefusal('rpc_exited_early', 'needs_input'));
349
+ const id = `pirpc-${nonce}-${sequence}`;
350
+ sequence += 1;
351
+ return new Promise((resolve, reject) => {
352
+ const timer = setTimeout(() => {
353
+ pending.delete(id);
354
+ reject(new PiRpcRefusal('rpc_response_deadline', 'needs_input', `no response to ${type} within ${timeoutMs}ms`));
355
+ }, timeoutMs);
356
+ pending.set(id, { type, resolve, reject, timer });
357
+ writeLine({ id, type, ...fields }).catch((error) => {
358
+ if (pending.delete(id)) {
359
+ clearTimeout(timer);
360
+ reject(error);
361
+ }
362
+ });
363
+ });
364
+ }
365
+ function waitForEvent(predicate, { timeoutMs = requestTimeoutMs, fromOrdinal = 0 } = {}) {
366
+ const existing = findRecord(predicate, fromOrdinal);
367
+ if (existing)
368
+ return Promise.resolve(existing);
369
+ if (fatal)
370
+ return Promise.reject(fatal);
371
+ if (streamsClosed)
372
+ return Promise.reject(new PiRpcRefusal('rpc_exited_early', 'needs_input'));
373
+ return new Promise((resolve, reject) => {
374
+ const timer = setTimeout(() => {
375
+ const index = eventWaiters.findIndex((waiter) => waiter.resolve === resolve);
376
+ if (index >= 0)
377
+ eventWaiters.splice(index, 1);
378
+ reject(new PiRpcRefusal('rpc_event_deadline', 'needs_input', `no matching event within ${timeoutMs}ms`));
379
+ }, timeoutMs);
380
+ eventWaiters.push({ predicate, fromOrdinal, resolve, reject, timer });
381
+ });
382
+ }
383
+ /** Orderly EOF, then a bounded signal of this exact child, then a bounded stream-close wait. */
384
+ async function shutdown() {
385
+ if (parentExit === null && !streamsClosed) {
386
+ try {
387
+ child.stdin.end();
388
+ }
389
+ catch {
390
+ // The pipe may already be gone; the exit path reports what happened.
391
+ }
392
+ let exited = await waitBounded(parentExitPromise, shutdownTimeoutMs);
393
+ if (!exited) {
394
+ killedAfterEof = true;
395
+ try {
396
+ child.kill('SIGTERM');
397
+ }
398
+ catch {
399
+ // Already gone.
400
+ }
401
+ exited = await waitBounded(parentExitPromise, signalGraceMs);
402
+ if (!exited) {
403
+ signaledKill = true;
404
+ try {
405
+ child.kill('SIGKILL');
406
+ }
407
+ catch {
408
+ // Already gone.
409
+ }
410
+ await waitBounded(parentExitPromise, signalGraceMs);
411
+ }
412
+ }
413
+ }
414
+ if (!streamsClosed) {
415
+ const closedInTime = await waitBounded(streamClosePromise, streamCloseGraceMs);
416
+ if (!closedInTime) {
417
+ // A descendant inherited our pipes and keeps them open. Detach only the handles this
418
+ // process owns; never signal a process this API did not spawn.
419
+ streamsDetached = true;
420
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
421
+ try {
422
+ stream?.destroy();
423
+ }
424
+ catch {
425
+ // Already destroyed.
426
+ }
427
+ }
428
+ // After destroying our own handles `close` is prompt; a short settle keeps the whole
429
+ // shutdown bounded even on a loaded machine.
430
+ await waitBounded(streamClosePromise, Math.min(streamCloseGraceMs, 250));
431
+ }
432
+ }
433
+ return {
434
+ exit_code: parentExit?.code ?? null,
435
+ signal: parentExit?.signal ?? null,
436
+ parent_exited: declared(),
437
+ stream_closed: streamsClosed,
438
+ streams_detached: streamsDetached,
439
+ killed_after_eof_timeout: killedAfterEof,
440
+ signaled_kill: signaledKill,
441
+ };
442
+ }
443
+ return {
444
+ records,
445
+ recordCount: () => records.length,
446
+ fatal: () => fatal,
447
+ parentExited: () => declared(),
448
+ streamsDetached: () => streamsDetached,
449
+ spawnFailed: () => spawnFailed,
450
+ spawnError: () => spawnError,
451
+ stderr: () => Buffer.concat(stderrChunks),
452
+ request,
453
+ waitForEvent,
454
+ shutdown,
455
+ };
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // Gated owned launch + query/prompt/cancel
459
+ // ---------------------------------------------------------------------------
460
+ /**
461
+ * Start exactly one owned Pi RPC child under the same admission/reservation gates as the native
462
+ * pilot launcher, bind the native session header, and return a handle.
463
+ *
464
+ * Returns `{ ok: true as const, session, reservation_id }` on success. Every pre-handle failure returns
465
+ * `{ ok: false as const, refused: true, stage, reason, ... }`; a failure after the reservation exists has
466
+ * already been settled honestly.
467
+ */
468
+ export async function startOwnedPiRpc(options) {
469
+ let opts;
470
+ try {
471
+ opts = normalizeOwnedOptions(options);
472
+ }
473
+ catch (error) {
474
+ return refused('validation', error);
475
+ }
476
+ try {
477
+ assertNamespaceGuard(opts);
478
+ }
479
+ catch (error) {
480
+ return refused('evidence', error);
481
+ }
482
+ const artifacts = artifactPaths(opts);
483
+ const createdDirs = [];
484
+ const cleanupEvidence = () => {
485
+ try {
486
+ assertNamespaceGuard(opts);
487
+ }
488
+ catch {
489
+ // Never remove or follow pathnames after the captured namespace changed.
490
+ return;
491
+ }
492
+ try {
493
+ fs.rmSync(artifacts.eventsFile, { force: true });
494
+ }
495
+ catch {
496
+ // Best effort: a file that cannot be removed is reported by the refuse reason.
497
+ }
498
+ for (const directory of [...createdDirs].reverse()) {
499
+ try {
500
+ fs.rmdirSync(directory);
501
+ }
502
+ catch {
503
+ // Only remove a directory this call created and that is still empty.
504
+ }
505
+ }
506
+ };
507
+ try {
508
+ for (const target of Object.values(artifacts)) {
509
+ if (fs.lstatSync(target, { throwIfNoEntry: false }))
510
+ throw new PiRpcRefusal('evidence_target_exists', 'invalid', target);
511
+ }
512
+ if (!fs.existsSync(opts.evidenceDir)) {
513
+ fs.mkdirSync(opts.evidenceDir, { recursive: true, mode: 0o700 });
514
+ createdDirs.push(opts.evidenceDir);
515
+ }
516
+ if (!fs.statSync(opts.evidenceDir).isDirectory())
517
+ throw new PiRpcRefusal('evidence_dir_not_a_directory');
518
+ fs.writeFileSync(artifacts.eventsFile, '', { flag: 'wx', mode: 0o600 });
519
+ }
520
+ catch (error) {
521
+ cleanupEvidence();
522
+ return refused('evidence', error);
523
+ }
524
+ // Protocol identities are always read from the registered task. A task without a coordinator
525
+ // cannot start a new RPC child, because a query or cancellation could not be recorded.
526
+ let protocol;
527
+ try {
528
+ protocol = resolveProtocol(opts);
529
+ }
530
+ catch (error) {
531
+ cleanupEvidence();
532
+ return refused('protocol', error);
533
+ }
534
+ const admission = admitLaunch({
535
+ assignmentFile: opts.assignmentFile,
536
+ capabilitiesFile: opts.capabilitiesFile,
537
+ taskRoot: opts.taskRoot,
538
+ cwd: opts.cwd,
539
+ sessionId: opts.resume ?? opts.sessionId,
540
+ harness: 'pi',
541
+ profile: opts.profile,
542
+ launchKind: opts.launchKind,
543
+ discovery: opts.discovery,
544
+ resumeSelector: opts.resume,
545
+ now: opts.clock,
546
+ maxAgeMinutes: opts.maxAgeMinutes,
547
+ });
548
+ if (!admission.ok) {
549
+ cleanupEvidence();
550
+ return {
551
+ ok: false,
552
+ refused: true,
553
+ stage: 'admission',
554
+ reason: admission.reason,
555
+ admission,
556
+ };
557
+ }
558
+ const reservation = reserveLaunch(opts.taskRoot, {
559
+ expectRevision: admission.state_revision,
560
+ clock: opts.clock,
561
+ reservation: {
562
+ reservation_id: `pirpc-${createNonce()}`,
563
+ subtask_id: opts.assignmentId,
564
+ attempt: opts.attempt,
565
+ kind: opts.launchKind,
566
+ mode: admission.mode,
567
+ phase: opts.phase,
568
+ holder: opts.assignment.assignee,
569
+ pid: process.pid,
570
+ requested_session_id: (opts.resume ?? opts.sessionId),
571
+ resume_selector: opts.resume,
572
+ transport: 'pi-rpc',
573
+ },
574
+ });
575
+ if (!reservation.ok) {
576
+ cleanupEvidence();
577
+ return {
578
+ ok: false,
579
+ refused: true,
580
+ stage: 'reservation',
581
+ reason: reservation.reason,
582
+ admission,
583
+ reservation,
584
+ };
585
+ }
586
+ try {
587
+ assertNamespaceGuard(opts);
588
+ if (!fs.existsSync(opts.sessionDir))
589
+ fs.mkdirSync(opts.sessionDir, { recursive: true, mode: 0o700 });
590
+ }
591
+ catch (error) {
592
+ if (error instanceof PiRpcRefusal &&
593
+ error.code === 'private_namespace_redirected') {
594
+ return refused('evidence', error);
595
+ }
596
+ settleLaunch(opts.taskRoot, {
597
+ reservationId: reservation.reservation_id,
598
+ outcome: 'not_started',
599
+ runner: 'pi-cli',
600
+ detail: errorDetail(error),
601
+ expectedIdentity: admission.expected_identity,
602
+ capabilityEvidence: admission.capability_evidence,
603
+ });
604
+ cleanupEvidence();
605
+ return refused('evidence', error);
606
+ }
607
+ const startedAt = nowIso(opts.clock);
608
+ const argv = buildArgv(opts);
609
+ let child;
610
+ try {
611
+ assertNamespaceGuard(opts);
612
+ child = spawn(opts.piBin, argv, {
613
+ cwd: opts.cwd,
614
+ stdio: ['pipe', 'pipe', 'pipe'],
615
+ env: process.env,
616
+ });
617
+ }
618
+ catch (error) {
619
+ if (error instanceof PiRpcRefusal &&
620
+ error.code === 'private_namespace_redirected') {
621
+ return refused('spawn', error);
622
+ }
623
+ settleLaunch(opts.taskRoot, {
624
+ reservationId: reservation.reservation_id,
625
+ outcome: 'not_started',
626
+ runner: 'pi-cli',
627
+ detail: errorDetail(error),
628
+ expectedIdentity: admission.expected_identity,
629
+ capabilityEvidence: admission.capability_evidence,
630
+ });
631
+ cleanupEvidence();
632
+ return refused('spawn', error);
633
+ }
634
+ const proof = {
635
+ schema: RPC_SCHEMA,
636
+ reservation_id: reservation.reservation_id,
637
+ child_pid: child.pid ?? null,
638
+ expected_session_id: admission.expected_identity,
639
+ requested_session_id: opts.resume ?? opts.sessionId,
640
+ resumed_from: opts.resume,
641
+ session_dir: opts.sessionDir,
642
+ admission: {
643
+ mode: admission.mode,
644
+ launch_kind: admission.launch_kind,
645
+ resume_selector: admission.resume_selector,
646
+ max_age_minutes: opts.maxAgeMinutes,
647
+ },
648
+ started_at: startedAt,
649
+ prompt: null,
650
+ queries: [],
651
+ cancel: null,
652
+ stop_scope: 'owned RPC child only; no arbitrary background or descendant stop guarantee',
653
+ };
654
+ let namespaceLost = false;
655
+ const rpc = attachTransport(child, {
656
+ requestTimeoutMs: opts.requestTimeoutMs,
657
+ shutdownTimeoutMs: opts.shutdownTimeoutMs,
658
+ signalGraceMs: opts.signalGraceMs,
659
+ streamCloseGraceMs: opts.streamCloseGraceMs,
660
+ maxBytes: opts.maxBytes,
661
+ onRecord: (line) => {
662
+ if (namespaceLost)
663
+ return;
664
+ try {
665
+ assertNamespaceGuard(opts);
666
+ }
667
+ catch {
668
+ namespaceLost = true;
669
+ return;
670
+ }
671
+ try {
672
+ fs.appendFileSync(artifacts.eventsFile, `${line}\n`);
673
+ }
674
+ catch {
675
+ namespaceLost = true;
676
+ }
677
+ },
678
+ });
679
+ let observedSessionId = null;
680
+ let reportedSessionFile = null;
681
+ let boundSessionFile = null;
682
+ let promptSent = false;
683
+ let lastPromptOrdinal = 0;
684
+ let operationError = null;
685
+ let finishPromise = null;
686
+ const setOperationError = (error) => {
687
+ operationError = operationError ?? error;
688
+ };
689
+ async function request(type, fields = {}) {
690
+ const remaining = opts.deadlineAt - Date.now();
691
+ if (remaining <= 0)
692
+ throw new PiRpcRefusal('operation_deadline', 'needs_input');
693
+ return rpc.request(type, fields, {
694
+ timeoutMs: Math.min(opts.requestTimeoutMs, remaining),
695
+ });
696
+ }
697
+ /** Bound an event wait by the remaining total operation budget as well as its own limit. */
698
+ function eventTimeout(fallback) {
699
+ const remaining = opts.deadlineAt - Date.now();
700
+ if (remaining <= 0)
701
+ throw new PiRpcRefusal('operation_deadline', 'needs_input');
702
+ return Math.min(fallback, remaining);
703
+ }
704
+ function protocolMessage(input) {
705
+ const message = buildMessage({
706
+ task_id: protocol.taskId,
707
+ subtask_id: protocol.subtaskId,
708
+ attempt: protocol.attempt,
709
+ from: input.from,
710
+ to: input.to,
711
+ reply_to: input.reply_to ?? null,
712
+ owner_epoch: input.owner_epoch ?? null,
713
+ type: input.type,
714
+ body: input.body ?? {},
715
+ });
716
+ const result = submitMessage(opts.taskRoot, message);
717
+ if (!result.ok) {
718
+ throw new PiRpcRefusal('protocol_message_refused', 'failed', result.reason ?? 'refused');
719
+ }
720
+ return { message_id: message.message_id, result };
721
+ }
722
+ /**
723
+ * Current-epoch authority check. Admission runs once, but a handoff, a freeze or a released
724
+ * claim can happen while this handle is alive; new work is only authorized while the original
725
+ * contract still owns the attempt.
726
+ */
727
+ function assertCurrentAuthority({ requireResources, }) {
728
+ const record = readRecord(opts.taskRoot);
729
+ if (!record)
730
+ throw new PiRpcRefusal('task_record_unavailable', 'needs_input');
731
+ if (requireResources && record.dispatch_frozen)
732
+ throw new PiRpcRefusal('dispatch_frozen', 'needs_input');
733
+ const currentEpoch = record.coordinator?.owner_epoch ?? null;
734
+ const contractEpoch = opts.assignment.coordinator?.owner_epoch ?? null;
735
+ if (currentEpoch !== contractEpoch)
736
+ throw new PiRpcRefusal('stale_epoch', 'needs_input', `record epoch ${currentEpoch}, contract epoch ${contractEpoch}`);
737
+ const attempt = findAttempt(record, opts.assignmentId, opts.attempt);
738
+ if (!attempt)
739
+ throw new PiRpcRefusal('attempt_not_registered');
740
+ if (requireResources &&
741
+ hasPendingCancellation(record, opts.assignmentId, opts.attempt)) {
742
+ throw new PiRpcRefusal('cancel_unconfirmed', 'needs_input');
743
+ }
744
+ const assignee = attempt.assignee ?? null;
745
+ if (!assignee ||
746
+ assignee.agent_id !== opts.assignment.assignee.agent_id ||
747
+ assignee.session_id !== opts.assignment.assignee.session_id) {
748
+ throw new PiRpcRefusal('assignee_mismatch');
749
+ }
750
+ if (['completed', 'failed', 'cancelled'].includes(attempt.state) ||
751
+ (requireResources && attempt.state === 'unknown')) {
752
+ throw new PiRpcRefusal('attempt_not_active', 'needs_input', attempt.state);
753
+ }
754
+ if (requireResources) {
755
+ // Issuing new work also requires the original reservation to still be live and every
756
+ // contracted write resource to still be held by this assignee at the contracted epoch.
757
+ const activeReservation = (record.launch_reservations ?? []).find((item) => item.reservation_id === reservation.reservation_id);
758
+ if (!activeReservation || activeReservation.released_at)
759
+ throw new PiRpcRefusal('reservation_not_active', 'needs_input');
760
+ const claims = (record.claims ?? []).filter((item) => !item.released_at);
761
+ for (const resource of opts.assignment.resources ?? []) {
762
+ if (resource.access !== 'write')
763
+ continue;
764
+ const descriptor = describeResource(resource);
765
+ const held = claims.find((claim) => claimCovers(claim, descriptor) &&
766
+ claim.access === 'write' &&
767
+ claim.holder?.agent_id === opts.assignment.assignee.agent_id &&
768
+ claim.holder?.session_id === opts.assignment.assignee.session_id &&
769
+ (claim.subtask_id ?? null) === opts.assignmentId &&
770
+ (claim.attempt ?? null) === opts.attempt);
771
+ if (!held)
772
+ throw new PiRpcRefusal('resource_not_held', 'needs_input', descriptor.key);
773
+ if (held.resource_epoch !== resource.resource_epoch)
774
+ throw new PiRpcRefusal('resource_epoch_mismatch', 'needs_input');
775
+ }
776
+ }
777
+ }
778
+ /**
779
+ * Read `get_state` and bind the exact native session file. The id alone is not enough: the
780
+ * reported file must exist, its header must carry the contracted id and cwd, and once bound it
781
+ * may not switch to a different path.
782
+ */
783
+ async function assertIdentity({ requireBinding = false } = {}) {
784
+ const response = await request('get_state');
785
+ const data = response.data;
786
+ if (!isObject(data) || typeof data.sessionId !== 'string')
787
+ throw new PiRpcRefusal('state_malformed');
788
+ if (data.sessionId !== admission.expected_identity) {
789
+ observedSessionId = data.sessionId;
790
+ throw new PiRpcRefusal('native_identity_mismatch', 'invalid', `native session ${data.sessionId} is not the contracted ${admission.expected_identity}`);
791
+ }
792
+ if (typeof data.isStreaming !== 'boolean' ||
793
+ typeof data.isCompacting !== 'boolean' ||
794
+ !Number.isSafeInteger(data.pendingMessageCount)) {
795
+ throw new PiRpcRefusal('state_incomplete');
796
+ }
797
+ if (typeof data.sessionFile !== 'string' ||
798
+ !path.isAbsolute(data.sessionFile)) {
799
+ throw new PiRpcRefusal('native_session_file_missing', 'needs_input');
800
+ }
801
+ const reported = path.resolve(data.sessionFile);
802
+ if (reportedSessionFile !== null && reported !== reportedSessionFile) {
803
+ throw new PiRpcRefusal('native_session_file_switched', 'needs_input', reported);
804
+ }
805
+ reportedSessionFile = reported;
806
+ const header = readPiSessionHeader(reported);
807
+ if (header) {
808
+ if (header.id !== admission.expected_identity)
809
+ throw new PiRpcRefusal('native_session_id_mismatch', 'invalid');
810
+ if (!samePath(header.cwd, opts.cwd))
811
+ throw new PiRpcRefusal('native_cwd_mismatch', 'invalid');
812
+ boundSessionFile = reported;
813
+ }
814
+ else if (requireBinding) {
815
+ throw new PiRpcRefusal('native_session_file_unavailable', 'needs_input', reported);
816
+ }
817
+ observedSessionId = data.sessionId;
818
+ return { response, data, headerBound: boundSessionFile !== null };
819
+ }
820
+ const session = {
821
+ schema: RPC_SCHEMA,
822
+ pid: child.pid ?? null,
823
+ expectedSessionId: admission.expected_identity,
824
+ sessionFile: () => boundSessionFile,
825
+ /** Read-only native query. Repeatable; always recorded through the protocol. */
826
+ async query() {
827
+ try {
828
+ assertNamespaceGuard(opts);
829
+ assertCurrentAuthority({ requireResources: false });
830
+ // The request is recorded before the observation it asks for.
831
+ const statusQuery = protocolMessage({
832
+ type: 'STATUS_QUERY',
833
+ from: protocol.coordinator,
834
+ to: protocol.assignee,
835
+ owner_epoch: protocol.ownerEpoch,
836
+ body: { native_rpc_child_pid: child.pid ?? null },
837
+ });
838
+ const { response, data } = await assertIdentity({
839
+ requireBinding: false,
840
+ });
841
+ const at = nowIso();
842
+ const queryFile = path.join(opts.evidenceDir, `${opts.outputName}.query-${proof.queries.length + 1}.json`);
843
+ assertNamespaceGuard(opts);
844
+ fs.writeFileSync(queryFile, `${JSON.stringify({
845
+ schema: RPC_SCHEMA,
846
+ kind: 'native-query',
847
+ at,
848
+ command: 'get_state',
849
+ response,
850
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
851
+ assertNamespaceGuard(opts);
852
+ const status = protocolMessage({
853
+ type: 'STATUS',
854
+ from: protocol.assignee,
855
+ to: protocol.coordinator,
856
+ owner_epoch: protocol.ownerEpoch,
857
+ reply_to: statusQuery.message_id,
858
+ body: {
859
+ status: data.isStreaming ? 'running' : 'unknown',
860
+ evidence_ref: queryFile,
861
+ },
862
+ });
863
+ assertNamespaceGuard(opts);
864
+ const queryDigest = sha256File(queryFile);
865
+ assertNamespaceGuard(opts);
866
+ const entry = {
867
+ at,
868
+ session_id: data.sessionId,
869
+ session_file: boundSessionFile,
870
+ header_bound: boundSessionFile !== null,
871
+ is_streaming: data.isStreaming,
872
+ is_compacting: data.isCompacting,
873
+ pending_message_count: data.pendingMessageCount,
874
+ message_count: Number.isSafeInteger(data.messageCount)
875
+ ? data.messageCount
876
+ : null,
877
+ evidence_ref: queryFile,
878
+ evidence_digest: queryDigest,
879
+ status_query_message_id: statusQuery.message_id,
880
+ status_message_id: status.message_id,
881
+ };
882
+ proof.queries.push(entry);
883
+ return entry;
884
+ }
885
+ catch (error) {
886
+ setOperationError(error);
887
+ throw error;
888
+ }
889
+ },
890
+ /** Send the one frozen prompt for this launch. A second call is refused. */
891
+ async prompt() {
892
+ try {
893
+ if (promptSent)
894
+ throw new PiRpcRefusal('prompt_already_sent', 'invalid', 'one frozen prompt per launch');
895
+ assertNamespaceGuard(opts);
896
+ assertCurrentAuthority({ requireResources: true });
897
+ await assertIdentity({ requireBinding: true });
898
+ const ordinalBefore = rpc.recordCount();
899
+ const response = await request('prompt', { message: opts.promptText });
900
+ const disposition = response.data?.disposition;
901
+ if (typeof disposition !== 'string' ||
902
+ !['started', 'queued', 'handled'].includes(disposition)) {
903
+ throw new PiRpcRefusal('prompt_not_accepted', 'needs_input', `disposition ${disposition}`);
904
+ }
905
+ promptSent = true;
906
+ lastPromptOrdinal = ordinalBefore;
907
+ proof.prompt = {
908
+ at: nowIso(),
909
+ disposition,
910
+ response_id: response.id,
911
+ event_ordinal_before_request: ordinalBefore,
912
+ };
913
+ return { disposition };
914
+ }
915
+ catch (error) {
916
+ setOperationError(error);
917
+ throw error;
918
+ }
919
+ },
920
+ /** Bounded wait for the run started by `prompt` to settle. Used by the run helper. */
921
+ async awaitSettled({ timeoutMs = opts.settleTimeoutMs } = {}) {
922
+ try {
923
+ if (proof.prompt?.disposition === 'handled')
924
+ return null;
925
+ const found = await rpc.waitForEvent((record) => record.type === 'agent_settled', {
926
+ fromOrdinal: lastPromptOrdinal,
927
+ timeoutMs: eventTimeout(timeoutMs),
928
+ });
929
+ return { ordinal: found.ordinal, at: nowIso() };
930
+ }
931
+ catch (error) {
932
+ setOperationError(error);
933
+ throw error;
934
+ }
935
+ },
936
+ /**
937
+ * Explicit cancellation of this owned runtime. It never sends a prompt, never sends
938
+ * CANCELLED and never releases claims: a caller must reconcile the stop externally.
939
+ */
940
+ async cancel({ reason = 'bounded RPC cancellation request' } = {}) {
941
+ try {
942
+ assertNamespaceGuard(opts);
943
+ assertCurrentAuthority({ requireResources: false });
944
+ const before = await assertIdentity({ requireBinding: true });
945
+ const ordinalBeforeAbort = rpc.recordCount();
946
+ const entry = {
947
+ at: nowIso(),
948
+ reason,
949
+ event_ordinal_before_request: ordinalBeforeAbort,
950
+ session_id: before.data.sessionId,
951
+ reconciliation_required: true,
952
+ stop_scope: 'owned RPC child only; a native abort response is not proof that arbitrary descendants stopped',
953
+ };
954
+ // Recorded immediately, so partial cancellation evidence survives a later failure.
955
+ proof.cancel = entry;
956
+ const cancel = protocolMessage({
957
+ type: 'CANCEL',
958
+ from: protocol.coordinator,
959
+ to: protocol.assignee,
960
+ owner_epoch: protocol.ownerEpoch,
961
+ body: { reason },
962
+ });
963
+ entry.cancel_message_id = cancel.message_id;
964
+ const clear = await request('clear_queue');
965
+ entry.clear_queue = {
966
+ id: clear.id,
967
+ steering: Array.isArray(clear.data?.steering)
968
+ ? clear.data.steering
969
+ : [],
970
+ follow_up: Array.isArray(clear.data?.followUp)
971
+ ? clear.data.followUp
972
+ : [],
973
+ };
974
+ const abort = await request('abort');
975
+ entry.abort = {
976
+ id: abort.id,
977
+ success: abort.success === true,
978
+ response_ordinal: rpc.recordCount() - 1,
979
+ };
980
+ let settledEvent = null;
981
+ try {
982
+ // The settled event may be emitted BEFORE the abort response; correlating from the
983
+ // ordinal captured before the request finds it either way. `agent_end` alone is not
984
+ // sufficient because retries/compaction/follow-ups can still follow it.
985
+ settledEvent = await rpc.waitForEvent((record) => record.type === 'agent_settled', {
986
+ fromOrdinal: ordinalBeforeAbort,
987
+ timeoutMs: eventTimeout(opts.settleTimeoutMs),
988
+ });
989
+ }
990
+ catch (error) {
991
+ entry.agent_settled = null;
992
+ entry.agent_settled_error =
993
+ errorCode(error) ?? 'agent_settled_not_observed';
994
+ }
995
+ if (settledEvent)
996
+ entry.agent_settled = { ordinal: settledEvent.ordinal, at: nowIso() };
997
+ try {
998
+ const after = await assertIdentity({ requireBinding: true });
999
+ entry.native_idle =
1000
+ after.data.isStreaming === false &&
1001
+ after.data.isCompacting === false &&
1002
+ after.data.pendingMessageCount === 0;
1003
+ entry.idle_state = {
1004
+ session_id: after.data.sessionId,
1005
+ is_streaming: after.data.isStreaming,
1006
+ is_compacting: after.data.isCompacting,
1007
+ pending_message_count: after.data.pendingMessageCount,
1008
+ };
1009
+ }
1010
+ catch (error) {
1011
+ entry.native_idle = false;
1012
+ entry.idle_error = errorCode(error) ?? 'idle_not_confirmed';
1013
+ }
1014
+ if (!entry.agent_settled)
1015
+ throw new PiRpcRefusal('agent_settled_not_observed', 'needs_input');
1016
+ if (!entry.native_idle)
1017
+ throw new PiRpcRefusal('native_not_idle_after_abort', 'needs_input');
1018
+ return entry;
1019
+ }
1020
+ catch (error) {
1021
+ setOperationError(error);
1022
+ throw error;
1023
+ }
1024
+ },
1025
+ shutdown: () => rpc.shutdown(),
1026
+ finalize: () => session.finish(),
1027
+ /**
1028
+ * Bounded shutdown, evidence persistence and one honest settlement. Serializable and safe to
1029
+ * call once on every path; no caller-supplied result or outcome can override the outcome.
1030
+ */
1031
+ finish() {
1032
+ if (finishPromise)
1033
+ return finishPromise;
1034
+ finishPromise = (async () => {
1035
+ let exit = null;
1036
+ let shutdownError = null;
1037
+ try {
1038
+ exit = await rpc.shutdown();
1039
+ }
1040
+ catch (error) {
1041
+ shutdownError = error;
1042
+ }
1043
+ const fatal = rpc.fatal();
1044
+ const parentExited = rpc.parentExited();
1045
+ const detached = exit?.streams_detached === true || rpc.streamsDetached();
1046
+ const exitCode = exit?.exit_code ?? null;
1047
+ const finishedAt = nowIso();
1048
+ const identity = classifyIdentity({
1049
+ ran: parentExited,
1050
+ observed: observedSessionId,
1051
+ expected: admission.expected_identity,
1052
+ capabilityEvidence: admission.capability_evidence,
1053
+ });
1054
+ const operationOk = !operationError;
1055
+ const transportOk = !fatal && !shutdownError;
1056
+ // A clean run also requires the native session file to be observed: without it the
1057
+ // execution is not attributable and the attempt must stay reconcile-first.
1058
+ const processClean = parentExited &&
1059
+ exitCode === 0 &&
1060
+ !detached &&
1061
+ boundSessionFile !== null;
1062
+ const cleanExit = operationOk && transportOk && processClean;
1063
+ let outcome;
1064
+ if (rpc.spawnFailed())
1065
+ outcome = 'spawn_failed';
1066
+ else if (!parentExited)
1067
+ outcome = 'timed_out';
1068
+ else
1069
+ outcome = exitCode === 0 ? 'exited' : 'exited_nonzero';
1070
+ const execution = {
1071
+ schema: EXECUTION_SCHEMA,
1072
+ harness: 'pi',
1073
+ transport: 'rpc',
1074
+ profile: opts.profile,
1075
+ phase: opts.phase,
1076
+ status: rpc.spawnFailed()
1077
+ ? 'spawn_failed'
1078
+ : parentExited
1079
+ ? 'exited'
1080
+ : 'timed_out',
1081
+ exit_code: exitCode,
1082
+ signal: exit?.signal ?? null,
1083
+ spawn_error: rpc.spawnFailed()
1084
+ ? (rpc.spawnError()?.message ?? 'spawn_failed')
1085
+ : null,
1086
+ started_at: startedAt,
1087
+ finished_at: finishedAt,
1088
+ cwd: opts.cwd,
1089
+ session_id: observedSessionId,
1090
+ requested_session_id: opts.resume ?? opts.sessionId,
1091
+ resumed_from: opts.resume,
1092
+ session_file: boundSessionFile,
1093
+ header_bound: boundSessionFile !== null,
1094
+ identity_status: identity.status,
1095
+ identity_confirmed: cleanExit &&
1096
+ identity.status === 'match' &&
1097
+ admission.capability_evidence === 'runtime-bound',
1098
+ argv_summary: argv,
1099
+ tool_allowlist: opts.profile === 'daily'
1100
+ ? 'host-settings'
1101
+ : TOOL_PROFILES.pi[opts.profile],
1102
+ limits: {
1103
+ request_timeout_ms: opts.requestTimeoutMs,
1104
+ operation_timeout_ms: opts.operationTimeoutMs,
1105
+ shutdown_timeout_ms: opts.shutdownTimeoutMs,
1106
+ stream_close_grace_ms: opts.streamCloseGraceMs,
1107
+ max_bytes: opts.maxBytes,
1108
+ model_override: null,
1109
+ effort_override: null,
1110
+ },
1111
+ project_trust: opts.trustProject
1112
+ ? 'approved-for-this-run'
1113
+ : 'not-approved',
1114
+ native_evidence: boundSessionFile,
1115
+ prompt_file: opts.promptFile,
1116
+ prompt_digest: opts.promptDigest,
1117
+ admission: {
1118
+ assignment_file: opts.assignmentFile,
1119
+ capabilities_file: opts.capabilitiesFile,
1120
+ task_root: opts.taskRoot,
1121
+ mode: admission.mode,
1122
+ launch_kind: admission.launch_kind,
1123
+ resume_selector: admission.resume_selector,
1124
+ reservation_id: reservation.reservation_id,
1125
+ checks: admission.checks,
1126
+ },
1127
+ planned_outcome: outcome,
1128
+ rpc_child_pid: child.pid ?? null,
1129
+ };
1130
+ let namespaceError = null;
1131
+ const checkNamespace = () => {
1132
+ if (namespaceError !== null)
1133
+ return false;
1134
+ try {
1135
+ assertNamespaceGuard(opts);
1136
+ return true;
1137
+ }
1138
+ catch (error) {
1139
+ namespaceError =
1140
+ errorDetail(error) ?? 'private_namespace_redirected';
1141
+ outcome = 'private_namespace_redirected';
1142
+ return false;
1143
+ }
1144
+ };
1145
+ let evidenceError = null;
1146
+ const writeFailure = (detail) => {
1147
+ if (!checkNamespace())
1148
+ return;
1149
+ try {
1150
+ fs.writeFileSync(artifacts.failureFile, `${JSON.stringify({
1151
+ schema: RPC_SCHEMA,
1152
+ at: finishedAt,
1153
+ reason: errorCode(operationError) ?? 'evidence_write_failed',
1154
+ detail,
1155
+ exit,
1156
+ claims_retained: true,
1157
+ }, null, 2)}\n`, { mode: 0o600 });
1158
+ }
1159
+ catch {
1160
+ // A failure record that cannot be written is reported in the return value.
1161
+ }
1162
+ };
1163
+ if (checkNamespace()) {
1164
+ try {
1165
+ fs.writeFileSync(artifacts.executionFile, `${JSON.stringify(execution, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
1166
+ }
1167
+ catch (error) {
1168
+ evidenceError = errorDetail(error) ?? 'execution_write_failed';
1169
+ outcome = 'evidence_write_failed';
1170
+ writeFailure(evidenceError);
1171
+ }
1172
+ }
1173
+ if (namespaceError === null &&
1174
+ evidenceError === null &&
1175
+ checkNamespace()) {
1176
+ try {
1177
+ fs.writeFileSync(artifacts.proofFile, `${JSON.stringify({ ...proof, finished_at: finishedAt, exit }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
1178
+ }
1179
+ catch (error) {
1180
+ evidenceError = errorDetail(error) ?? 'proof_write_failed';
1181
+ outcome = 'evidence_write_failed';
1182
+ writeFailure(evidenceError);
1183
+ }
1184
+ }
1185
+ if (namespaceError === null &&
1186
+ evidenceError === null &&
1187
+ checkNamespace()) {
1188
+ try {
1189
+ fs.writeFileSync(artifacts.stderrFile, rpc.stderr(), {
1190
+ flag: 'wx',
1191
+ mode: 0o600,
1192
+ });
1193
+ }
1194
+ catch (error) {
1195
+ evidenceError = errorDetail(error) ?? 'stderr_write_failed';
1196
+ outcome = 'evidence_write_failed';
1197
+ writeFailure(evidenceError);
1198
+ }
1199
+ }
1200
+ let settlement = null;
1201
+ let settlementError = null;
1202
+ if (!checkNamespace()) {
1203
+ settlementError = new PiRpcRefusal('private_namespace_redirected', 'needs_input', namespaceError);
1204
+ }
1205
+ else {
1206
+ try {
1207
+ settlement = settleLaunch(opts.taskRoot, {
1208
+ reservationId: reservation.reservation_id,
1209
+ outcome,
1210
+ exitCode,
1211
+ sessionId: observedSessionId,
1212
+ runId: reservation.reservation_id,
1213
+ evidenceRef: evidenceError ? null : artifacts.executionFile,
1214
+ runner: 'pi-cli',
1215
+ detail: operationError
1216
+ ? errorDetail(operationError)
1217
+ : evidenceError,
1218
+ expectedIdentity: admission.expected_identity,
1219
+ capabilityEvidence: admission.capability_evidence,
1220
+ cleanExit: cleanExit && !evidenceError,
1221
+ });
1222
+ }
1223
+ catch (error) {
1224
+ settlementError = error;
1225
+ }
1226
+ }
1227
+ const recordOk = settlement?.ok === true;
1228
+ const ok = operationOk &&
1229
+ transportOk &&
1230
+ processClean &&
1231
+ namespaceError === null &&
1232
+ !evidenceError &&
1233
+ recordOk;
1234
+ let reason = null;
1235
+ let detail = null;
1236
+ if (namespaceError !== null) {
1237
+ reason = 'private_namespace_redirected';
1238
+ detail = namespaceError;
1239
+ }
1240
+ else if (operationError) {
1241
+ reason = errorCode(operationError) ?? 'operation_failed';
1242
+ detail = errorDetail(operationError) ?? null;
1243
+ }
1244
+ else if (evidenceError) {
1245
+ reason = 'evidence_write_failed';
1246
+ detail = evidenceError;
1247
+ }
1248
+ else if (!transportOk) {
1249
+ reason = fatal?.code ?? 'shutdown_failed';
1250
+ detail = fatal?.detail ?? errorDetail(shutdownError) ?? null;
1251
+ }
1252
+ else if (!parentExited) {
1253
+ reason = 'process_not_exited';
1254
+ }
1255
+ else if (detached) {
1256
+ reason = 'stream_termination_unproven';
1257
+ }
1258
+ else if (boundSessionFile === null) {
1259
+ reason = 'native_session_unobserved';
1260
+ }
1261
+ else if (exitCode !== 0) {
1262
+ reason = 'unclean_exit';
1263
+ }
1264
+ else if (settlementError) {
1265
+ reason = 'settlement_exception';
1266
+ detail = errorDetail(settlementError);
1267
+ }
1268
+ else if (!recordOk) {
1269
+ reason =
1270
+ settlement && !settlement.ok
1271
+ ? settlement.reason
1272
+ : 'settlement_failed';
1273
+ }
1274
+ return {
1275
+ ok,
1276
+ schema: RPC_SCHEMA,
1277
+ reason,
1278
+ detail,
1279
+ session_id: observedSessionId,
1280
+ session_file: boundSessionFile,
1281
+ header_bound: boundSessionFile !== null,
1282
+ identity: identity.status,
1283
+ outcome,
1284
+ clean_exit: cleanExit && !evidenceError,
1285
+ exit: exit ?? {
1286
+ exit_code: null,
1287
+ signal: null,
1288
+ parent_exited: parentExited,
1289
+ stream_closed: null,
1290
+ streams_detached: detached,
1291
+ killed_after_eof_timeout: false,
1292
+ signaled_kill: false,
1293
+ },
1294
+ transport_fatal: fatal
1295
+ ? { code: fatal.code, detail: fatal.detail }
1296
+ : null,
1297
+ settlement,
1298
+ result: {
1299
+ ok: recordOk,
1300
+ reason: settlement && !settlement.ok
1301
+ ? settlement.reason
1302
+ : errorDetail(settlementError),
1303
+ // An honest marker: the reservation may still be unreleased when storage prevented
1304
+ // the settlement transaction from committing.
1305
+ unresolved_reservation: !recordOk,
1306
+ },
1307
+ evidence: {
1308
+ evidence_dir: opts.evidenceDir,
1309
+ events_file: artifacts.eventsFile,
1310
+ execution_file: namespaceError === null && !evidenceError
1311
+ ? artifacts.executionFile
1312
+ : null,
1313
+ proof_file: namespaceError === null && !evidenceError
1314
+ ? artifacts.proofFile
1315
+ : null,
1316
+ failure_file: namespaceError === null && evidenceError
1317
+ ? artifacts.failureFile
1318
+ : null,
1319
+ stderr_file: artifacts.stderrFile,
1320
+ written: namespaceError === null && !evidenceError,
1321
+ error: namespaceError ?? evidenceError,
1322
+ },
1323
+ operations: {
1324
+ prompt: proof.prompt,
1325
+ queries: proof.queries,
1326
+ cancel: proof.cancel,
1327
+ },
1328
+ };
1329
+ })();
1330
+ return finishPromise;
1331
+ },
1332
+ };
1333
+ // Accepted alias for callers of the earlier revision; like `finish` it takes no arguments,
1334
+ // so it cannot override the authoritative outcome.
1335
+ // Bind the contracted identity and native session header before returning a live handle.
1336
+ try {
1337
+ await assertIdentity({ requireBinding: false });
1338
+ }
1339
+ catch (error) {
1340
+ const failure = await session.finish();
1341
+ return {
1342
+ ok: false,
1343
+ refused: true,
1344
+ stage: 'identity',
1345
+ reason: errorCode(error),
1346
+ detail: errorDetail(error),
1347
+ outcome: failure.outcome,
1348
+ settlement: failure.settlement,
1349
+ result: failure.result,
1350
+ evidence: failure.evidence,
1351
+ };
1352
+ }
1353
+ return {
1354
+ ok: true,
1355
+ session,
1356
+ reservation_id: reservation.reservation_id,
1357
+ };
1358
+ }
1359
+ /**
1360
+ * One bounded gated operation. `action` is `query` (default) or `prompt`; unsupported actions
1361
+ * are refused before anything is spawned. `prompt` sends the single frozen prompt and waits for
1362
+ * it to settle. A cancellation is never implicit here; callers cancel explicitly on the handle.
1363
+ */
1364
+ export async function runOwnedPiRpc(options) {
1365
+ const action = options.action ?? 'query';
1366
+ if (!ACTIONS.has(action)) {
1367
+ return {
1368
+ ok: false,
1369
+ refused: true,
1370
+ stage: 'validation',
1371
+ reason: 'unsupported_action',
1372
+ detail: String(action),
1373
+ action,
1374
+ };
1375
+ }
1376
+ const started = await startOwnedPiRpc(options);
1377
+ if (!started.ok)
1378
+ return { ...started, action };
1379
+ const { session } = started;
1380
+ try {
1381
+ if (action === 'prompt') {
1382
+ await session.prompt();
1383
+ await session.awaitSettled();
1384
+ }
1385
+ await session.query();
1386
+ }
1387
+ catch {
1388
+ // The handle recorded the failure; finish() still runs and settles honestly.
1389
+ }
1390
+ const result = await session.finish();
1391
+ return { ...result, action };
1392
+ }
1393
+ // ---------------------------------------------------------------------------
1394
+ // Helpers
1395
+ // ---------------------------------------------------------------------------
1396
+ function refused(stage, error) {
1397
+ return {
1398
+ ok: false,
1399
+ refused: true,
1400
+ stage,
1401
+ reason: errorCode(error) ?? 'invalid_options',
1402
+ detail: errorDetail(error),
1403
+ };
1404
+ }
1405
+ function boundedTimeout(value, fallback, max, code) {
1406
+ if (value === undefined || value === null)
1407
+ return fallback;
1408
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
1409
+ throw new PiRpcRefusal(code, 'invalid', String(value));
1410
+ return value;
1411
+ }
1412
+ function absolute(value, code) {
1413
+ if (typeof value !== 'string' ||
1414
+ !path.isAbsolute(value) ||
1415
+ value.includes('\0'))
1416
+ throw new PiRpcRefusal(code);
1417
+ return value;
1418
+ }
1419
+ function assertNamespaceGuard(options) {
1420
+ if (options.namespaceGuard === undefined)
1421
+ return;
1422
+ try {
1423
+ options.namespaceGuard();
1424
+ }
1425
+ catch (error) {
1426
+ throw new PiRpcRefusal('private_namespace_redirected', 'needs_input', errorDetail(error));
1427
+ }
1428
+ }
1429
+ function normalizeOwnedOptions(options) {
1430
+ assertNamespaceGuard(options);
1431
+ const taskRoot = absolute(options.taskRoot, 'task_root_must_be_absolute');
1432
+ const cwd = absolute(options.cwd, 'cwd_must_be_absolute');
1433
+ if (!fs.statSync(cwd, { throwIfNoEntry: false })?.isDirectory())
1434
+ throw new PiRpcRefusal('cwd_missing');
1435
+ const assignmentFile = absolute(options.assignmentFile, 'assignment_must_be_absolute');
1436
+ const capabilitiesFile = absolute(options.capabilitiesFile, 'capabilities_must_be_absolute');
1437
+ const promptFile = absolute(options.promptFile, 'prompt_file_must_be_absolute');
1438
+ const promptStat = fs.statSync(promptFile, { throwIfNoEntry: false });
1439
+ if (!promptStat?.isFile())
1440
+ throw new PiRpcRefusal('prompt_file_missing');
1441
+ if (promptStat.size > MAX_PROMPT_BYTES)
1442
+ throw new PiRpcRefusal('prompt_too_large');
1443
+ const evidenceDir = absolute(options.evidenceDir, 'evidence_dir_must_be_absolute');
1444
+ const sessionDir = options.sessionDir === undefined
1445
+ ? path.join(evidenceDir, 'pi-sessions')
1446
+ : absolute(options.sessionDir, 'session_dir_must_be_absolute');
1447
+ const outputName = options.outputName ?? 'pi-rpc';
1448
+ if (typeof outputName !== 'string' ||
1449
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(outputName))
1450
+ throw new PiRpcRefusal('invalid_output_name');
1451
+ const assignment = parseJson(fs.readFileSync(assignmentFile, 'utf8'));
1452
+ if (!isAssignment(assignment))
1453
+ throw new PiRpcRefusal('invalid_assignment');
1454
+ const profile = options.profile ?? assignment.runner?.profile ?? 'writer';
1455
+ if (profile !== 'writer' && profile !== 'reviewer' && profile !== 'daily')
1456
+ throw new PiRpcRefusal('unsupported_profile');
1457
+ const phase = options.phase ?? (options.discovery ? 'discovery' : 'implementation');
1458
+ if (!PHASES.has(phase))
1459
+ throw new PiRpcRefusal('unsupported_phase');
1460
+ const resume = options.resume ?? null;
1461
+ if (resume !== null && (typeof resume !== 'string' || !resume.trim()))
1462
+ throw new PiRpcRefusal('invalid_resume_selector');
1463
+ const sessionId = options.sessionId ?? null;
1464
+ if (resume === null && (typeof sessionId !== 'string' || !sessionId.trim()))
1465
+ throw new PiRpcRefusal('session_id_required');
1466
+ const launchKind = options.launchKind ?? (resume ? 'resume' : 'first');
1467
+ if (!['first', 'resume'].includes(launchKind))
1468
+ throw new PiRpcRefusal('invalid_launch_kind');
1469
+ if (launchKind === 'resume' && !resume)
1470
+ throw new PiRpcRefusal('resume_selector_required');
1471
+ if (launchKind === 'first' && resume)
1472
+ throw new PiRpcRefusal('unexpected_resume_selector');
1473
+ const requestTimeoutMs = boundedTimeout(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS, 'request_timeout_out_of_range');
1474
+ const operationTimeoutMs = boundedTimeout(options.operationTimeoutMs, DEFAULT_OPERATION_TIMEOUT_MS, MAX_OPERATION_TIMEOUT_MS, 'operation_timeout_out_of_range');
1475
+ const shutdownTimeoutMs = boundedTimeout(options.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS, 'shutdown_timeout_out_of_range');
1476
+ const settleTimeoutMs = boundedTimeout(options.settleTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS, 'settle_timeout_out_of_range');
1477
+ const signalGraceMs = boundedTimeout(options.signalGraceMs, DEFAULT_SIGNAL_GRACE_MS, MAX_REQUEST_TIMEOUT_MS, 'signal_grace_out_of_range');
1478
+ const streamCloseGraceMs = boundedTimeout(options.streamCloseGraceMs, DEFAULT_STREAM_CLOSE_GRACE_MS, MAX_REQUEST_TIMEOUT_MS, 'stream_close_grace_out_of_range');
1479
+ const maxBytes = options.maxBytes === undefined
1480
+ ? DEFAULT_MAX_BYTES
1481
+ : Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0
1482
+ ? options.maxBytes
1483
+ : (() => {
1484
+ throw new PiRpcRefusal('max_bytes_out_of_range');
1485
+ })();
1486
+ const maxAgeMinutes = options.maxAgeMinutes === undefined ? 30 : options.maxAgeMinutes;
1487
+ if (!Number.isFinite(maxAgeMinutes) || maxAgeMinutes < 0)
1488
+ throw new PiRpcRefusal('invalid_max_age');
1489
+ const clock = options.clock ?? Date.now();
1490
+ return {
1491
+ taskRoot,
1492
+ cwd,
1493
+ assignmentFile,
1494
+ capabilitiesFile,
1495
+ promptFile,
1496
+ evidenceDir,
1497
+ sessionDir,
1498
+ outputName,
1499
+ assignmentId: assignment.assignment_id,
1500
+ attempt: assignment.attempt,
1501
+ assignment,
1502
+ profile,
1503
+ phase,
1504
+ resume,
1505
+ sessionId,
1506
+ launchKind,
1507
+ discovery: options.discovery === true,
1508
+ trustProject: options.trustProject === true,
1509
+ piBin: options.piBin ?? process.env.PAPPRA_PI_BIN ?? 'pi',
1510
+ promptText: fs.readFileSync(promptFile, 'utf8'),
1511
+ promptDigest: sha256File(promptFile),
1512
+ requestTimeoutMs,
1513
+ operationTimeoutMs,
1514
+ shutdownTimeoutMs,
1515
+ settleTimeoutMs,
1516
+ signalGraceMs,
1517
+ streamCloseGraceMs,
1518
+ maxBytes,
1519
+ maxAgeMinutes,
1520
+ namespaceGuard: options.namespaceGuard,
1521
+ deadlineAt: Date.now() + operationTimeoutMs,
1522
+ clock,
1523
+ };
1524
+ }
1525
+ function artifactPaths(opts) {
1526
+ const base = path.join(opts.evidenceDir, opts.outputName);
1527
+ return {
1528
+ eventsFile: `${base}.rpc.jsonl`,
1529
+ executionFile: `${base}.execution.json`,
1530
+ proofFile: `${base}.proof.json`,
1531
+ failureFile: `${base}.failure.json`,
1532
+ stderrFile: `${base}.stderr.log`,
1533
+ };
1534
+ }
1535
+ function buildArgv(opts) {
1536
+ const base = ['--mode', 'rpc', '--session-dir', opts.sessionDir];
1537
+ const capabilityFlags = opts.profile === 'daily'
1538
+ ? []
1539
+ : ['--no-mcp', '--tools', TOOL_PROFILES.pi[opts.profile]];
1540
+ return [
1541
+ ...base,
1542
+ ...capabilityFlags,
1543
+ ...(opts.trustProject ? ['--approve'] : []),
1544
+ opts.resume ? '--session' : '--session-id',
1545
+ (opts.resume ?? opts.sessionId),
1546
+ ];
1547
+ }
1548
+ /**
1549
+ * Read the first record of a native session file.
1550
+ *
1551
+ * Returns `null` when the file is not there yet (a new session may persist its header only when
1552
+ * the runtime is created), and refuses when the file exists but does not carry a usable header.
1553
+ */
1554
+ function readPiSessionHeader(file) {
1555
+ let head;
1556
+ try {
1557
+ const fd = fs.openSync(file, 'r');
1558
+ try {
1559
+ const buffer = Buffer.alloc(HEADER_READ_BYTES);
1560
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
1561
+ head = buffer.subarray(0, count);
1562
+ }
1563
+ finally {
1564
+ fs.closeSync(fd);
1565
+ }
1566
+ }
1567
+ catch (error) {
1568
+ if (errorCode(error) === 'ENOENT')
1569
+ return null;
1570
+ throw new PiRpcRefusal('native_session_file_unavailable', 'needs_input', errorDetail(error));
1571
+ }
1572
+ const end = head.indexOf(10);
1573
+ const line = head
1574
+ .subarray(0, end === -1 ? head.length : end)
1575
+ .toString('utf8');
1576
+ let header;
1577
+ try {
1578
+ header = parseJson(line);
1579
+ }
1580
+ catch {
1581
+ throw new PiRpcRefusal('native_session_header_invalid');
1582
+ }
1583
+ if (!isObject(header) ||
1584
+ header.type !== 'session' ||
1585
+ typeof header.id !== 'string' ||
1586
+ typeof header.cwd !== 'string') {
1587
+ throw new PiRpcRefusal('native_session_header_invalid');
1588
+ }
1589
+ return { ...header, id: header.id, cwd: header.cwd };
1590
+ }
1591
+ /**
1592
+ * Resolve the sender/recipient identities for STATUS_QUERY, STATUS and CANCEL from the
1593
+ * collaboration record. There is no override and no bypass: a task whose identities cannot be
1594
+ * resolved does not start a new RPC child.
1595
+ */
1596
+ function resolveProtocol(opts) {
1597
+ const record = readRecord(opts.taskRoot);
1598
+ if (!record?.coordinator)
1599
+ throw new PiRpcRefusal('protocol_identity_unavailable', 'invalid', 'no coordinator on the task record');
1600
+ const attemptRecord = findAttempt(record, opts.assignmentId, opts.attempt);
1601
+ const assignee = attemptRecord?.assignee ?? opts.assignment.assignee;
1602
+ assertIdentityShape(record.coordinator);
1603
+ assertIdentityShape(assignee);
1604
+ return {
1605
+ taskId: record.task_id,
1606
+ subtaskId: opts.assignmentId,
1607
+ attempt: opts.attempt,
1608
+ ownerEpoch: record.coordinator.owner_epoch ?? null,
1609
+ coordinator: record.coordinator,
1610
+ assignee,
1611
+ };
1612
+ }
1613
+ function assertIdentityShape(identity) {
1614
+ if (!identity ||
1615
+ typeof identity.agent_id !== 'string' ||
1616
+ typeof identity.session_id !== 'string' ||
1617
+ typeof identity.harness !== 'string') {
1618
+ throw new PiRpcRefusal('protocol_identity_unavailable');
1619
+ }
1620
+ }
1621
+ export const PI_RPC_SCHEMA = RPC_SCHEMA;