@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,1528 @@
1
+ import { object, parseJson, errorCode } from '../../collab/validation.js';
2
+ import { isRecord, } from './protocol.js';
3
+ function errorDetail(error) {
4
+ if (object(error) && typeof error['detail'] === 'string')
5
+ return error['detail'];
6
+ return error instanceof Error ? error.message : null;
7
+ }
8
+ import { spawn } from 'node:child_process';
9
+ import { groupState, ownedChild, snapshotOwnedTree, spawnOwnedGroup, stopOwnedGroup, stopSnapshottedTree, } from '../../infra/owned-process-group.js';
10
+ import { randomBytes } from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { buildMessage } from '../../collab/envelope.js';
14
+ import { submitMessage } from '../../collab/messages.js';
15
+ import { readRecord } from '../../collab/store.js';
16
+ // Owned Codex app-server stdio transport and task-scoped query/cancel.
17
+ //
18
+ // Protocol facts come from the installed Codex CLI 0.162.1 v2 schemas and the official
19
+ // app-server documentation (https://learn.chatgpt.com/docs/app-server):
20
+ // * `thread/read` does not resume or load the thread; a fresh server reports
21
+ // `status.type: notLoaded`, which says nothing about the original execution server.
22
+ // * `turn/interrupt` only requests cancellation; terminal `turn/completed` with
23
+ // `turn.status: interrupted` is the lifecycle evidence, and it may arrive before the
24
+ // interrupt response.
25
+ // * `thread/shellCommand` creates a standalone native turn when the thread is idle and
26
+ // emits `turn/started` then `turn/completed`; its acknowledgement is an empty object.
27
+ // * Server-to-client requests (approvals, tool calls, token refresh) are refused by default.
28
+ //
29
+ // Boundaries stated rather than hidden:
30
+ // * Opening a transport is a local process start with NO model inference; the arguments are
31
+ // always exactly `app-server --stdio` (tests inject a fake executable, never arguments).
32
+ // * `startThread`/`startTurn`/`startShellTurn` are explicit low-level APIs for a coordinator
33
+ // to create work AFTER existing admission. Query/cancel/recover never call them.
34
+ // * Ownership lives in private closure state and the transport object is frozen, so caller
35
+ // mutation cannot forge original ownership. A thread/turn is owned only when its id came
36
+ // back from a create response or a correlated native notification on THIS transport.
37
+ // * Model/effort/reasoning overrides are refused at the raw request boundary as well as in
38
+ // the convenience wrappers.
39
+ // * Claims are retained; no CANCELLED is ever sent; every cancel returns
40
+ // `reconciliation_required` once an intent has been recorded.
41
+ const SCHEMA = 'harness-collab/codex-native/1';
42
+ const CLIENT_INFO = Object.freeze({
43
+ name: 'harness-collab-codex-native',
44
+ version: '1',
45
+ });
46
+ const SERVER_ARGV = Object.freeze(['app-server', '--stdio']);
47
+ const CONTROLLED_SCOPE = 'controlled-owned-group';
48
+ const DEFAULT_SCOPE = 'default-process';
49
+ const MAX_BYTES = 4 * 1024 * 1024;
50
+ const MAX_TIMEOUT_MS = 30000;
51
+ const DEFAULT_TIMEOUT_MS = 10000;
52
+ const DEFAULT_SHUTDOWN_MS = 3000;
53
+ const DEFAULT_SIGNAL_GRACE_MS = 1000;
54
+ const DEFAULT_STREAM_CLOSE_GRACE_MS = 500;
55
+ const OVERRIDE_KEYS = [
56
+ 'model',
57
+ 'effort',
58
+ 'reasoningEffort',
59
+ 'summary',
60
+ 'serviceTier',
61
+ 'personality',
62
+ ];
63
+ const ACCEPTED_HARNESSES = new Set(['codex']);
64
+ const EXPECTED_RUNNER = 'codex-app-server';
65
+ export class CodexNativeRefusal extends Error {
66
+ status;
67
+ code;
68
+ detail;
69
+ constructor(status, code, detail = null) {
70
+ super(code);
71
+ this.status = status;
72
+ this.code = code;
73
+ this.detail = detail;
74
+ this.status = status;
75
+ this.code = code;
76
+ this.detail = detail;
77
+ }
78
+ }
79
+ function refuse(code, status = 'invalid', detail = null) {
80
+ throw new CodexNativeRefusal(status, code, detail);
81
+ }
82
+ const nowIso = () => new Date().toISOString();
83
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
84
+ const realpath = (file) => {
85
+ try {
86
+ return fs.realpathSync(file);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ };
92
+ const resolvePath = (file) => realpath(file) ?? path.resolve(file);
93
+ const isObject = object;
94
+ function bounded(value, fallback, code, cap = MAX_TIMEOUT_MS) {
95
+ const chosen = value ?? fallback;
96
+ if (!Number.isSafeInteger(chosen) || chosen < 1 || chosen > cap)
97
+ refuse(code);
98
+ return chosen;
99
+ }
100
+ function absolute(value, code) {
101
+ if (typeof value !== 'string' ||
102
+ !path.isAbsolute(value) ||
103
+ value.includes('\0'))
104
+ refuse(code);
105
+ return value;
106
+ }
107
+ function rejectOverrides(params = {}) {
108
+ if (!isObject(params))
109
+ refuse('params_invalid');
110
+ for (const key of OVERRIDE_KEYS)
111
+ if (params[key] !== undefined && params[key] !== null)
112
+ refuse('override_not_supported', 'invalid', key);
113
+ return params;
114
+ }
115
+ async function raceDeadline(promise, timeoutMs, code) {
116
+ let timer;
117
+ try {
118
+ return await Promise.race([
119
+ promise,
120
+ new Promise((_, reject) => {
121
+ timer = setTimeout(() => reject(new CodexNativeRefusal('needs_input', code)), timeoutMs);
122
+ }),
123
+ ]);
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+ function tryWritePrivate(file, bytes) {
130
+ try {
131
+ fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
132
+ return { ref: file, bytes: bytes.length };
133
+ }
134
+ catch (error) {
135
+ return {
136
+ error: errorCode(error) === 'EEXIST'
137
+ ? 'evidence_exists'
138
+ : 'evidence_write_failed',
139
+ };
140
+ }
141
+ }
142
+ /**
143
+ * Own one `codex app-server --stdio` child and speak bounded JSONL with it. Opening is a local
144
+ * no-inference process start; `initialize` performs no model work.
145
+ */
146
+ export async function openCodexTransport(options) {
147
+ const codexBin = typeof options.codexBin === 'string' && options.codexBin
148
+ ? options.codexBin
149
+ : 'codex';
150
+ if (options.args !== undefined)
151
+ refuse('arguments_not_supported', 'invalid', 'the app-server stdio surface is fixed');
152
+ const cwd = absolute(options.cwd, 'cwd_must_be_absolute');
153
+ const deadlineMs = bounded(options.deadlineMs, DEFAULT_TIMEOUT_MS, 'deadline_out_of_range');
154
+ const maxBytes = bounded(options.maxBytes, MAX_BYTES, 'max_bytes_out_of_range', MAX_BYTES);
155
+ const nonce = randomBytes(6).toString('hex');
156
+ const controlled = options.controlled === true;
157
+ let groupHandle = null;
158
+ let child;
159
+ try {
160
+ if (controlled) {
161
+ // Opt-in controlled mode: the app-server is the leader of a genuine owned process group.
162
+ groupHandle = spawnOwnedGroup(codexBin, [...SERVER_ARGV], {
163
+ cwd,
164
+ env: options.env,
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ });
167
+ child = ownedChild(groupHandle);
168
+ }
169
+ else {
170
+ child = spawn(codexBin, [...SERVER_ARGV], {
171
+ cwd,
172
+ env: options.env,
173
+ stdio: ['pipe', 'pipe', 'pipe'],
174
+ });
175
+ }
176
+ }
177
+ catch (error) {
178
+ refuse('spawn_failed', 'needs_input', errorDetail(error));
179
+ }
180
+ const state = {
181
+ partial: Buffer.alloc(0),
182
+ bytes: 0,
183
+ events: [],
184
+ serverRequests: [],
185
+ pending: new Map(),
186
+ fatal: null,
187
+ exit: null,
188
+ spawnFailed: false,
189
+ requestLog: [],
190
+ streamClosed: false,
191
+ };
192
+ const createdThreads = new Map();
193
+ const createdTurns = new Map();
194
+ const openTurns = new Set();
195
+ const shellReservations = new Set();
196
+ const waiters = new Set();
197
+ const identity = Object.freeze({
198
+ kind: 'codex-app-server-stdio',
199
+ argv: [codexBin, ...SERVER_ARGV],
200
+ cwd,
201
+ pid: child.pid ?? null,
202
+ client_info: CLIENT_INFO,
203
+ opened_at: nowIso(),
204
+ model_inference: 'none',
205
+ admitted_launcher: false,
206
+ scope: controlled ? CONTROLLED_SCOPE : DEFAULT_SCOPE,
207
+ });
208
+ let sequence = 0;
209
+ let controlledThreadReserved = false;
210
+ let streamsDetached = false;
211
+ let killedAfterEof = false;
212
+ let signaledKill = false;
213
+ const wake = () => {
214
+ for (const waiter of [...waiters])
215
+ waiter();
216
+ };
217
+ const fail = (code, detail = null) => {
218
+ if (!state.fatal) {
219
+ state.fatal = new CodexNativeRefusal('needs_input', code, detail);
220
+ for (const pending of state.pending.values())
221
+ pending.reject(state.fatal);
222
+ state.pending.clear();
223
+ }
224
+ wake();
225
+ };
226
+ const closed = new Promise((resolve) => {
227
+ child.once('close', (code, signal) => {
228
+ state.exit = { code, signal };
229
+ state.streamClosed = true;
230
+ resolve();
231
+ wake();
232
+ });
233
+ child.once('error', (error) => {
234
+ state.spawnFailed = true;
235
+ fail('spawn_failed', errorDetail(error));
236
+ state.streamClosed = true;
237
+ resolve();
238
+ wake();
239
+ });
240
+ });
241
+ const exitRefusal = closed.then(() => {
242
+ throw new CodexNativeRefusal('needs_input', 'native_transport_exited');
243
+ });
244
+ exitRefusal.catch(() => {
245
+ /* handled by each racer below */
246
+ });
247
+ for (const stream of [child.stdin, child.stdout, child.stderr])
248
+ stream?.on('error', () => {
249
+ /* drained */
250
+ });
251
+ child.stderr.on('data', () => {
252
+ /* stderr is drained and discarded; never logged or returned */
253
+ });
254
+ const handleRecord = (record) => {
255
+ if (record.id !== undefined &&
256
+ (Object.hasOwn(record, 'result') || Object.hasOwn(record, 'error'))) {
257
+ const pending = state.pending.get(record.id);
258
+ if (!pending) {
259
+ fail('unexpected_response_id', String(record.id));
260
+ return;
261
+ }
262
+ state.pending.delete(record.id);
263
+ if (Object.hasOwn(record, 'error'))
264
+ pending.reject(new CodexNativeRefusal('needs_input', 'native_error', `${record.error?.message ?? 'error'}`));
265
+ else if (record.result)
266
+ pending.resolve(record.result);
267
+ else
268
+ pending.reject(new CodexNativeRefusal('needs_input', 'native_result_malformed'));
269
+ return;
270
+ }
271
+ if (record.method !== undefined && record.id !== undefined) {
272
+ // A server-to-client request. Refuse it explicitly; a caller may opt into a handler.
273
+ state.serverRequests.push({
274
+ id: record.id,
275
+ method: record.method,
276
+ at: nowIso(),
277
+ });
278
+ const answer = options.onServerRequest
279
+ ? options.onServerRequest({
280
+ id: record.id,
281
+ method: record.method,
282
+ params: record.params,
283
+ })
284
+ : {
285
+ error: {
286
+ code: -32000,
287
+ message: 'harness_collab_refuses_server_request',
288
+ data: { method: record.method },
289
+ },
290
+ };
291
+ try {
292
+ child.stdin.write(`${JSON.stringify({ id: record.id, ...answer })}\n`);
293
+ }
294
+ catch {
295
+ /* child gone */
296
+ }
297
+ return;
298
+ }
299
+ if (record.method !== undefined) {
300
+ if (record.method === 'turn/completed' &&
301
+ typeof record.params?.turn?.id === 'string')
302
+ openTurns.delete(record.params.turn.id);
303
+ state.events.push({
304
+ seq: state.events.length + 1,
305
+ method: record.method,
306
+ params: record.params ?? null,
307
+ at: nowIso(),
308
+ });
309
+ wake();
310
+ return;
311
+ }
312
+ fail('malformed_record');
313
+ };
314
+ child.stdout.on('data', (chunk) => {
315
+ state.bytes += chunk.length;
316
+ if (state.bytes > maxBytes) {
317
+ fail('response_too_large');
318
+ try {
319
+ child.kill('SIGKILL');
320
+ }
321
+ catch {
322
+ /* gone */
323
+ }
324
+ return;
325
+ }
326
+ state.partial = Buffer.concat([state.partial, chunk]);
327
+ let index = state.partial.indexOf(10);
328
+ while (index !== -1) {
329
+ const line = state.partial.subarray(0, index);
330
+ state.partial = state.partial.subarray(index + 1);
331
+ if (line.length > maxBytes) {
332
+ fail('record_too_large');
333
+ break;
334
+ }
335
+ if (line.length) {
336
+ let record = null;
337
+ try {
338
+ record = parseJson(line.toString('utf8'));
339
+ }
340
+ catch {
341
+ fail('malformed_record');
342
+ break;
343
+ }
344
+ if (!isRecord(record)) {
345
+ fail('malformed_record');
346
+ break;
347
+ }
348
+ handleRecord(record);
349
+ }
350
+ index = state.partial.indexOf(10);
351
+ }
352
+ });
353
+ async function request(method, params, opts = {}) {
354
+ if (typeof method !== 'string' || !method)
355
+ refuse('method_required');
356
+ rejectOverrides(params ?? {});
357
+ if (controlled &&
358
+ (method === 'thread/start' ||
359
+ method === 'thread/resume' ||
360
+ method === 'thread/fork')) {
361
+ // Controlled mode is single-thread: no sibling thread can be created, resumed or forked,
362
+ // so a runtime-wide group stop can never stop work this transport does not own. The
363
+ // reservation is taken synchronously before any await, so concurrent starts cannot both pass.
364
+ if (method !== 'thread/start')
365
+ refuse('controlled_single_thread_required', 'invalid', `${method} is not allowed in controlled mode`);
366
+ if (controlledThreadReserved)
367
+ refuse('controlled_single_thread_required', 'invalid', 'controlled mode admits one thread start');
368
+ controlledThreadReserved = true;
369
+ }
370
+ if (state.fatal)
371
+ throw state.fatal;
372
+ if (state.exit !== null || state.spawnFailed)
373
+ refuse('native_transport_exited', 'needs_input');
374
+ const timeoutMs = bounded(opts.timeoutMs, deadlineMs, 'request_timeout_out_of_range');
375
+ const id = `hcx-${nonce}-${sequence++}`;
376
+ const promise = new Promise((resolve, reject) => state.pending.set(id, { resolve, reject }));
377
+ try {
378
+ child.stdin.write(`${JSON.stringify({ id, method, params: params ?? {} })}\n`);
379
+ }
380
+ catch (error) {
381
+ state.pending.delete(id);
382
+ refuse('native_write_failed', 'needs_input', errorDetail(error));
383
+ }
384
+ try {
385
+ const result = await raceDeadline(Promise.race([promise, exitRefusal]), timeoutMs, 'native_request_deadline');
386
+ state.requestLog.push({ id, method, at: nowIso(), ok: true });
387
+ return result;
388
+ }
389
+ catch (error) {
390
+ state.pending.delete(id);
391
+ state.requestLog.push({
392
+ id,
393
+ method,
394
+ at: nowIso(),
395
+ ok: false,
396
+ code: errorCode(error) ?? null,
397
+ });
398
+ throw error;
399
+ }
400
+ }
401
+ function waitForNotification(predicate, opts = {}) {
402
+ const timeoutMs = bounded(opts.timeoutMs, deadlineMs, 'event_timeout_out_of_range');
403
+ const fromIndex = opts.fromIndex ?? 0;
404
+ const found = state.events.slice(fromIndex).find(predicate);
405
+ if (found)
406
+ return Promise.resolve(found);
407
+ return raceDeadline(new Promise((resolve, reject) => {
408
+ const waiter = () => {
409
+ if (state.fatal) {
410
+ waiters.delete(waiter);
411
+ reject(state.fatal);
412
+ return;
413
+ }
414
+ const event = state.events.slice(fromIndex).find(predicate);
415
+ if (event) {
416
+ waiters.delete(waiter);
417
+ resolve(event);
418
+ }
419
+ };
420
+ waiters.add(waiter);
421
+ waiter();
422
+ }), timeoutMs, 'native_event_deadline');
423
+ }
424
+ const transport = Object.freeze({
425
+ schema: SCHEMA,
426
+ identity,
427
+ pid: child.pid ?? null,
428
+ request,
429
+ notify: (method, params) => {
430
+ try {
431
+ child.stdin.write(`${JSON.stringify({ method, params: params ?? {} })}\n`);
432
+ }
433
+ catch {
434
+ /* gone */
435
+ }
436
+ },
437
+ events: () => state.events.slice(),
438
+ requestLog: () => state.requestLog.slice(),
439
+ serverRequestsRefused: () => state.serverRequests.slice(),
440
+ fatal: () => state.fatal,
441
+ requestIds: () => state.requestLog.map((entry) => entry.id),
442
+ ownedThread: (threadId) => createdThreads.has(threadId),
443
+ ownedTurn: (threadId, turnId) => createdTurns.get(turnId) === threadId,
444
+ owned: (threadId, turnId) => createdThreads.has(threadId) && createdTurns.get(turnId) === threadId,
445
+ createdThreads: () => [...createdThreads.keys()],
446
+ createdTurns: () => [...createdTurns.entries()].map(([turnId, threadId]) => ({
447
+ threadId,
448
+ turnId,
449
+ })),
450
+ waitForNotification,
451
+ initialize: async () => {
452
+ const result = await request('initialize', {
453
+ clientInfo: CLIENT_INFO,
454
+ capabilities: { experimentalApi: true },
455
+ });
456
+ try {
457
+ child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
458
+ }
459
+ catch {
460
+ /* gone */
461
+ }
462
+ return result;
463
+ },
464
+ /** Explicit low-level fixture creation. Never called by query/cancel/recover. */
465
+ startThread: async (params) => {
466
+ rejectOverrides(params);
467
+ const cwd = absolute(params.cwd, 'thread_cwd_must_be_absolute');
468
+ const result = await request('thread/start', { ...params, cwd });
469
+ const thread = result?.thread;
470
+ if (!isObject(thread) ||
471
+ typeof thread.id !== 'string' ||
472
+ typeof thread.cwd !== 'string')
473
+ refuse('thread_response_malformed', 'needs_input');
474
+ createdThreads.set(thread.id, { cwd: thread.cwd, created_at: nowIso() });
475
+ return { threadId: thread.id, thread };
476
+ },
477
+ /** Explicit low-level fixture creation. Never called by query/cancel/recover. */
478
+ startTurn: async (params) => {
479
+ rejectOverrides(params);
480
+ const threadId = params.threadId;
481
+ if (!createdThreads.has(threadId))
482
+ refuse('unowned_thread', 'needs_input');
483
+ const result = await request('turn/start', { ...params, threadId });
484
+ const turn = result?.turn;
485
+ if (!isObject(turn) || typeof turn.id !== 'string')
486
+ refuse('turn_response_malformed', 'needs_input');
487
+ createdTurns.set(turn.id, threadId);
488
+ openTurns.add(turn.id);
489
+ return { threadId, turnId: turn.id, turn };
490
+ },
491
+ /**
492
+ * Explicit native shell turn on an owned, idle thread. This performs no model request: it
493
+ * runs a local shell command through the app-server's own user-shell surface. The event
494
+ * wait is installed before the request, so a `turn/started` that precedes the empty
495
+ * acknowledgement is still correlated by thread and turn id.
496
+ */
497
+ startShellTurn: async (params) => {
498
+ rejectOverrides(params);
499
+ const threadId = params.threadId;
500
+ if (!createdThreads.has(threadId))
501
+ refuse('unowned_thread', 'needs_input');
502
+ if (typeof params.command !== 'string' || !params.command.trim())
503
+ refuse('command_required');
504
+ const waitMs = bounded(params.waitMs, deadlineMs, 'shell_timeout_out_of_range');
505
+ let nativeTimeout;
506
+ if (params.timeoutMs !== undefined && params.timeoutMs !== null) {
507
+ // Installed ThreadShellCommandParams exposes `timeoutMs` (non-negative int64) as the
508
+ // native shell execution bound; forward it, never as the client wait bound.
509
+ if (!Number.isSafeInteger(params.timeoutMs) || params.timeoutMs < 0)
510
+ refuse('shell_timeout_out_of_range');
511
+ nativeTimeout = params.timeoutMs;
512
+ }
513
+ // Synchronous per-thread reservation before the first await: two concurrent callers must
514
+ // not both observe idle and each send a shell command.
515
+ if (shellReservations.has(threadId))
516
+ refuse('thread_busy', 'needs_input', 'shell turn already starting');
517
+ shellReservations.add(threadId);
518
+ try {
519
+ const read = await request('thread/read', { threadId, includeTurns: false }, { timeoutMs: waitMs });
520
+ if (read?.thread?.status?.type !== 'idle')
521
+ refuse('thread_busy', 'needs_input', String(read?.thread?.status?.type ?? 'unknown'));
522
+ for (const turnId of openTurns)
523
+ if (createdTurns.get(turnId) === threadId)
524
+ refuse('thread_busy', 'needs_input', `active turn ${turnId}`);
525
+ const fromIndex = state.events.length;
526
+ const firstEvent = waitForNotification((event) => (event.method === 'turn/started' ||
527
+ event.method === 'turn/completed') &&
528
+ event.params?.threadId === threadId, { timeoutMs: waitMs, fromIndex });
529
+ // Consume the pending wait outcome immediately so a later event deadline, transport
530
+ // close or request rejection can never surface as an unhandled rejection.
531
+ firstEvent.catch(() => {
532
+ /* consumed below */
533
+ });
534
+ const shellParams = { threadId, command: params.command };
535
+ if (nativeTimeout !== undefined)
536
+ shellParams.timeoutMs = nativeTimeout;
537
+ let ack;
538
+ try {
539
+ ack = await request('thread/shellCommand', shellParams, {
540
+ timeoutMs: waitMs,
541
+ });
542
+ }
543
+ catch (error) {
544
+ await firstEvent.catch(() => {
545
+ /* drain the pending wait */
546
+ });
547
+ throw error;
548
+ }
549
+ const eventsSeenAtAck = state.events.length;
550
+ const event = await firstEvent;
551
+ const turnId = event.params?.turn?.id;
552
+ if (typeof turnId !== 'string')
553
+ refuse('turn_response_malformed', 'needs_input');
554
+ createdTurns.set(turnId, threadId);
555
+ const completedAlready = state.events.some((entry) => entry.method === 'turn/completed' &&
556
+ entry.params?.turn?.id === turnId);
557
+ if (event.method === 'turn/started' && !completedAlready)
558
+ openTurns.add(turnId);
559
+ else
560
+ openTurns.delete(turnId);
561
+ return {
562
+ threadId,
563
+ turnId,
564
+ ack,
565
+ event: { method: event.method, seq: event.seq, at: event.at },
566
+ event_preceded_ack: event.seq <= eventsSeenAtAck,
567
+ native_timeout_ms: nativeTimeout ?? null,
568
+ };
569
+ }
570
+ finally {
571
+ shellReservations.delete(threadId);
572
+ }
573
+ },
574
+ threadRead: (threadId, params = {}) => request('thread/read', {
575
+ threadId,
576
+ includeTurns: params.includeTurns === true,
577
+ }),
578
+ listTurns: (threadId, params = {}) => request('thread/turns/list', {
579
+ threadId,
580
+ itemsView: params.itemsView ?? 'full',
581
+ sortDirection: params.sortDirection ?? 'asc',
582
+ limit: params.limit ?? 50,
583
+ }),
584
+ listItems: (threadId, params = {}) => request('thread/items/list', {
585
+ threadId,
586
+ turnId: params.turnId ?? null,
587
+ sortDirection: params.sortDirection ?? 'asc',
588
+ limit: params.limit ?? 200,
589
+ }),
590
+ /**
591
+ * Frozen runtime capability. `stop` terminates ONLY the owned process group created for a
592
+ * controlled transport (bounded TERM then KILL) and positively re-observes the group. In
593
+ * default mode it refuses; it never releases resources and never signals a caller-supplied
594
+ * pid/pgid. Descendants that escaped the group via setsid are outside this guarantee.
595
+ */
596
+ runtime: Object.freeze({
597
+ scope: controlled ? CONTROLLED_SCOPE : DEFAULT_SCOPE,
598
+ controlled,
599
+ stop: async (stopOptions = {}) => {
600
+ if (!controlled || groupHandle === null)
601
+ refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
602
+ const proof = await stopOwnedGroup(groupHandle, {
603
+ termMs: stopOptions.termMs ?? 2000,
604
+ killMs: stopOptions.killMs ?? 2000,
605
+ });
606
+ const observed = groupState(groupHandle);
607
+ return {
608
+ scope: CONTROLLED_SCOPE,
609
+ child_pid: child.pid ?? null,
610
+ group_observed: observed.observable,
611
+ group_live: observed.group_live,
612
+ live_members: observed.live_members,
613
+ parent_exited: observed.parent_exited,
614
+ stopped: proof.stopped && observed.group_live === false,
615
+ term_sent: proof.term_sent,
616
+ kill_sent: proof.kill_sent,
617
+ remaining: proof.remaining,
618
+ note: 'original owned group only; use snapshotTree/stopTree to include captured descendant groups',
619
+ };
620
+ },
621
+ /**
622
+ * Read-only snapshot of the owned root and its actual descendant groups (they may live in
623
+ * their own process groups, as a real native user-shell does). Frozen opaque token backed
624
+ * by private state; only `stopTree` accepts it, and the snapshot is privately bound to the
625
+ * exact owned group handle that captured it.
626
+ */
627
+ snapshotTree: () => {
628
+ if (!controlled || groupHandle === null)
629
+ refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
630
+ return snapshotOwnedTree(groupHandle);
631
+ },
632
+ /**
633
+ * Stops only the groups captured in this exact snapshot and only when this transport's own
634
+ * owned group captured it. Members are re-verified against their captured PID + start
635
+ * identity and captured PGID before TERM, again before any KILL escalation, and once more
636
+ * before a positive `stopped: true` proof.
637
+ */
638
+ stopTree: async (snapshot, stopOptions = {}) => {
639
+ if (!controlled || groupHandle === null)
640
+ refuse('runtime_not_controlled', 'invalid', 'this transport has no owned process group');
641
+ return stopSnapshottedTree(snapshot, {
642
+ termMs: stopOptions.termMs ?? 2000,
643
+ killMs: stopOptions.killMs ?? 2000,
644
+ handle: groupHandle,
645
+ });
646
+ },
647
+ }),
648
+ close: async () => {
649
+ if (state.exit === null && !state.spawnFailed) {
650
+ try {
651
+ child.stdin.end();
652
+ }
653
+ catch {
654
+ /* already gone */
655
+ }
656
+ let exited = await raceDeadline(closed.then(() => true), DEFAULT_SHUTDOWN_MS, 'shutdown_deadline').catch(() => false);
657
+ if (!exited) {
658
+ killedAfterEof = true;
659
+ try {
660
+ child.kill('SIGTERM');
661
+ }
662
+ catch {
663
+ /* gone */
664
+ }
665
+ exited = await raceDeadline(closed.then(() => true), DEFAULT_SIGNAL_GRACE_MS, 'signal_grace_deadline').catch(() => false);
666
+ if (!exited) {
667
+ signaledKill = true;
668
+ try {
669
+ child.kill('SIGKILL');
670
+ }
671
+ catch {
672
+ /* gone */
673
+ }
674
+ await raceDeadline(closed.then(() => true), DEFAULT_SIGNAL_GRACE_MS, 'kill_grace_deadline').catch(() => false);
675
+ }
676
+ }
677
+ }
678
+ if (!state.streamClosed) {
679
+ await delay(Math.min(DEFAULT_STREAM_CLOSE_GRACE_MS, 250));
680
+ if (!state.streamClosed) {
681
+ streamsDetached = true;
682
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
683
+ try {
684
+ stream?.destroy();
685
+ }
686
+ catch {
687
+ /* gone */
688
+ }
689
+ }
690
+ await delay(50);
691
+ }
692
+ }
693
+ return {
694
+ exit_code: state.exit?.code ?? null,
695
+ signal: state.exit?.signal ?? null,
696
+ parent_exited: state.exit !== null || state.spawnFailed,
697
+ stream_closed: state.streamClosed,
698
+ streams_detached: streamsDetached,
699
+ killed_after_eof_timeout: killedAfterEof,
700
+ signaled_kill: signaledKill,
701
+ request_ids: state.requestLog.map((entry) => entry.id),
702
+ server_requests_refused: state.serverRequests.length,
703
+ };
704
+ },
705
+ });
706
+ if (options.initialize !== false) {
707
+ try {
708
+ await transport.initialize();
709
+ }
710
+ catch (error) {
711
+ await transport.close();
712
+ throw error;
713
+ }
714
+ }
715
+ return transport;
716
+ }
717
+ // ---------------------------------------------------------------------------
718
+ // Task-scoped query / cancel / recovery on an owned original transport
719
+ // ---------------------------------------------------------------------------
720
+ function normalizeAttemptOptions(options) {
721
+ if (!Number.isSafeInteger(options.attempt) || options.attempt < 1)
722
+ refuse('invalid_attempt');
723
+ if (!Number.isSafeInteger(options.ownerEpoch) || options.ownerEpoch < 1)
724
+ refuse('invalid_owner_epoch');
725
+ for (const key of [
726
+ 'taskRoot',
727
+ 'agentId',
728
+ 'sessionId',
729
+ 'threadId',
730
+ 'turnId',
731
+ ]) {
732
+ if (typeof options[key] !== 'string' || !options[key].trim())
733
+ refuse(`${key}_required`);
734
+ }
735
+ if (!options.transport ||
736
+ typeof options.transport.request !== 'function' ||
737
+ !options.transport.identity)
738
+ refuse('owned_transport_required', 'needs_input');
739
+ return {
740
+ taskRoot: absolute(options.taskRoot, 'task_root_must_be_absolute'),
741
+ subtaskId: typeof options.subtaskId === 'string' && options.subtaskId.trim()
742
+ ? options.subtaskId
743
+ : refuse('subtask_required'),
744
+ attempt: options.attempt,
745
+ ownerEpoch: options.ownerEpoch,
746
+ agentId: options.agentId,
747
+ sessionId: options.sessionId,
748
+ threadId: options.threadId,
749
+ turnId: options.turnId,
750
+ cwd: absolute(options.cwd, 'cwd_must_be_absolute'),
751
+ transport: options.transport,
752
+ timeoutMs: bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeout_out_of_range'),
753
+ reason: options.reason ?? null,
754
+ evidenceDir: options.evidenceDir === undefined || options.evidenceDir === null
755
+ ? null
756
+ : absolute(options.evidenceDir, 'evidence_dir_must_be_absolute'),
757
+ };
758
+ }
759
+ function requireAttemptRecord(opts) {
760
+ const record = readRecord(opts.taskRoot);
761
+ if (!record)
762
+ refuse('task_record_unavailable', 'needs_input');
763
+ if (record.coordinator?.owner_epoch !== opts.ownerEpoch)
764
+ refuse('stale_epoch', 'needs_input');
765
+ const target = (record.attempts ?? []).find((item) => item.subtask_id === opts.subtaskId && item.attempt === opts.attempt);
766
+ if (!target)
767
+ refuse('unknown_attempt', 'needs_input');
768
+ const assignee = target.assignee ?? {};
769
+ if (assignee.agent_id !== opts.agentId ||
770
+ assignee.session_id !== opts.sessionId)
771
+ refuse('wrong_assignee', 'needs_input');
772
+ if (!ACCEPTED_HARNESSES.has(assignee.harness ?? ''))
773
+ refuse('native_harness_mismatch', 'needs_input', `assignee harness ${assignee.harness ?? 'unknown'}`);
774
+ const native = target.native ?? {};
775
+ if (native.runner !== EXPECTED_RUNNER)
776
+ refuse('native_runner_mismatch', 'needs_input', `native runner ${native.runner ?? 'unknown'}`);
777
+ const recordedThread = native.session_id ?? null;
778
+ const recordedTurn = native.run_id ?? null;
779
+ if (recordedThread === null || recordedTurn === null)
780
+ refuse('native_refs_unrecorded', 'needs_input');
781
+ if (recordedThread !== opts.threadId)
782
+ refuse('native_thread_ref_mismatch', 'needs_input');
783
+ if (recordedTurn !== opts.turnId)
784
+ refuse('native_turn_ref_mismatch', 'needs_input');
785
+ const recordedCwd = target.cwd ?? assignee.cwd ?? null;
786
+ if (recordedCwd !== null &&
787
+ resolvePath(recordedCwd) !== resolvePath(opts.cwd))
788
+ refuse('native_cwd_mismatch', 'needs_input');
789
+ opts.record = record;
790
+ opts.assigneeAgent = assignee.agent_id;
791
+ opts.assigneeSession = assignee.session_id;
792
+ opts.assigneeHarness = assignee.harness;
793
+ return target;
794
+ }
795
+ function ids(opts) {
796
+ if (!opts.record?.coordinator || !opts.assigneeAgent || !opts.assigneeSession)
797
+ refuse('protocol_identity_unavailable');
798
+ const coordinator = opts.record.coordinator;
799
+ return {
800
+ taskId: opts.record.task_id,
801
+ from: {
802
+ agent_id: coordinator.agent_id,
803
+ session_id: coordinator.session_id,
804
+ harness: coordinator.harness ?? 'pi',
805
+ },
806
+ to: {
807
+ agent_id: opts.assigneeAgent,
808
+ session_id: opts.assigneeSession,
809
+ harness: opts.assigneeHarness ?? 'codex',
810
+ },
811
+ };
812
+ }
813
+ /** Registers the query intent before any native read; returns the message id. */
814
+ function registerStatusQuery(opts) {
815
+ const { taskId, from, to } = ids(opts);
816
+ const query = submitMessage(opts.taskRoot, buildMessage({
817
+ type: 'STATUS_QUERY',
818
+ task_id: taskId,
819
+ from,
820
+ to,
821
+ owner_epoch: opts.ownerEpoch,
822
+ body: { reason: 'native app-server observation' },
823
+ }));
824
+ if (query.ok !== true)
825
+ refuse('status_query_not_recorded', 'invalid', query.reason ?? null);
826
+ return query.registration.message_id;
827
+ }
828
+ /** Reports the factual native observation; terminal never becomes task completion here. */
829
+ function reportStatus(opts, queryId, observation, nativeStatus, nativeFacts) {
830
+ const { taskId, from, to } = ids(opts);
831
+ const bodyStatus = observation === 'active'
832
+ ? 'running'
833
+ : observation === 'interrupted'
834
+ ? 'cancelled'
835
+ : observation === 'failed'
836
+ ? 'failed'
837
+ : 'unknown';
838
+ const status = submitMessage(opts.taskRoot, buildMessage({
839
+ type: 'STATUS',
840
+ task_id: taskId,
841
+ from: to,
842
+ to: from,
843
+ owner_epoch: opts.ownerEpoch,
844
+ reply_to: queryId,
845
+ subtask_id: opts.subtaskId,
846
+ attempt: opts.attempt,
847
+ body: {
848
+ status: bodyStatus,
849
+ native_status: nativeStatus,
850
+ ...nativeFacts,
851
+ observation,
852
+ },
853
+ }));
854
+ if (status.ok !== true)
855
+ refuse('status_not_recorded', 'invalid', status.reason ?? null);
856
+ return status.registration.message_id;
857
+ }
858
+ /** Native read of the exact thread/turn, with no thread/resume, fork or prompt. */
859
+ async function readTurn(transport, threadId, turnId, timeoutMs) {
860
+ const read = await transport.request('thread/read', { threadId, includeTurns: false }, { timeoutMs });
861
+ const thread = read?.thread;
862
+ if (!isObject(thread) || thread.id !== threadId)
863
+ refuse('native_thread_identity_mismatch', 'needs_input');
864
+ if (typeof thread.cwd !== 'string')
865
+ refuse('native_thread_cwd_unsupported', 'needs_input');
866
+ const listed = await transport.request('thread/turns/list', { threadId, itemsView: 'full', sortDirection: 'asc', limit: 50 }, { timeoutMs });
867
+ const turns = Array.isArray(listed?.data)
868
+ ? listed.data
869
+ : refuse('native_turns_malformed', 'needs_input');
870
+ const turn = turns.find((item) => item?.id === turnId) ?? null;
871
+ if (!turn)
872
+ refuse('native_turn_not_found', 'needs_input');
873
+ if (typeof turn.status !== 'string')
874
+ refuse('native_turn_status_unsupported', 'needs_input');
875
+ return {
876
+ thread: { ...thread, cwd: thread.cwd },
877
+ turn: { ...turn, status: turn.status },
878
+ queried_at: nowIso(),
879
+ };
880
+ }
881
+ /**
882
+ * Classifies persisted turn items into a usable original native result. A model message and a
883
+ * successful native shell command are kept distinct; nothing is synthesized.
884
+ */
885
+ function classifyItems(entries) {
886
+ const items = entries.map((entry) => entry?.item);
887
+ const messages = items.filter((item) => item?.type === 'agentMessage' &&
888
+ typeof item.text === 'string' &&
889
+ item.text.trim());
890
+ if (messages.length) {
891
+ return {
892
+ result_kind: 'agent_message',
893
+ agent_message: messages.at(-1).text,
894
+ native_command_output: null,
895
+ native_command_exit_code: null,
896
+ item_count: entries.length,
897
+ };
898
+ }
899
+ const commands = items.filter((item) => item?.type === 'commandExecution' &&
900
+ item.status === 'completed' &&
901
+ item.exitCode === 0 &&
902
+ typeof item.aggregatedOutput === 'string');
903
+ if (commands.length) {
904
+ const last = commands.at(-1);
905
+ return {
906
+ result_kind: 'native_command_output',
907
+ agent_message: null,
908
+ native_command_output: last.aggregatedOutput,
909
+ native_command_exit_code: last.exitCode,
910
+ item_count: entries.length,
911
+ };
912
+ }
913
+ return {
914
+ result_kind: null,
915
+ agent_message: null,
916
+ native_command_output: null,
917
+ native_command_exit_code: null,
918
+ item_count: entries.length,
919
+ };
920
+ }
921
+ async function readItems(transport, threadId, turnId, timeoutMs) {
922
+ const listed = await transport.request('thread/items/list', { threadId, turnId, sortDirection: 'asc', limit: 200 }, { timeoutMs });
923
+ const entries = Array.isArray(listed?.data)
924
+ ? listed.data
925
+ : refuse('native_items_malformed', 'needs_input');
926
+ return classifyItems(entries);
927
+ }
928
+ function observationOf(status) {
929
+ if (status === 'completed')
930
+ return 'completed';
931
+ if (status === 'interrupted')
932
+ return 'interrupted';
933
+ if (status === 'failed')
934
+ return 'failed';
935
+ if (status === 'inProgress')
936
+ return 'active';
937
+ refuse('native_turn_status_unsupported', 'needs_input');
938
+ }
939
+ function refusalResult(error, extra = {}) {
940
+ const known = error instanceof CodexNativeRefusal;
941
+ return {
942
+ schema: SCHEMA,
943
+ status: known ? error.status : 'invalid',
944
+ blockers: [known ? error.code : 'internal_error'],
945
+ detail: known
946
+ ? error.detail
947
+ : String(error instanceof Error ? errorDetail(error) : error),
948
+ ...extra,
949
+ reconciliation_required: false,
950
+ claims_retained: true,
951
+ };
952
+ }
953
+ /** In-flight status of one exact recorded attempt on its owned original transport and turn. */
954
+ export async function queryCodexAttempt(options) {
955
+ let statusQueryId = null;
956
+ try {
957
+ const opts = normalizeAttemptOptions(options);
958
+ requireAttemptRecord(opts);
959
+ if (!opts.transport.owned(opts.threadId, opts.turnId))
960
+ refuse('unowned_native_turn', 'needs_input');
961
+ statusQueryId = registerStatusQuery(opts);
962
+ const native = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
963
+ if (resolvePath(native.thread.cwd) !== resolvePath(opts.cwd))
964
+ refuse('native_cwd_mismatch', 'needs_input');
965
+ const observation = observationOf(native.turn.status);
966
+ const result = {
967
+ schema: SCHEMA,
968
+ status: observation,
969
+ blockers: [],
970
+ observation,
971
+ native: {
972
+ thread_id: opts.threadId,
973
+ turn_id: opts.turnId,
974
+ thread_status: native.thread.status ?? null,
975
+ thread_cwd: native.thread.cwd,
976
+ turn_status: native.turn.status,
977
+ },
978
+ transport: {
979
+ identity: opts.transport.identity,
980
+ request_ids: opts.transport.requestIds(),
981
+ queried_at: native.queried_at,
982
+ },
983
+ reconciliation_required: false,
984
+ claims_retained: true,
985
+ evidence: {
986
+ observed_ids: { thread_id: opts.threadId, turn_id: opts.turnId },
987
+ queried_at: native.queried_at,
988
+ },
989
+ };
990
+ try {
991
+ const statusId = reportStatus(opts, statusQueryId, observation, native.turn.status, { native_thread_id: opts.threadId, native_turn_id: opts.turnId });
992
+ result.protocol = { status_query: statusQueryId, status: statusId };
993
+ }
994
+ catch (error) {
995
+ result.record_error =
996
+ error instanceof CodexNativeRefusal ? error.code : 'internal_error';
997
+ result.protocol = { status_query: statusQueryId, status: null };
998
+ }
999
+ return result;
1000
+ }
1001
+ catch (error) {
1002
+ return refusalResult(error, { status_query: statusQueryId });
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Requests cancellation of one exact recorded attempt. Requires a fresh active observation on
1007
+ * the owned original transport, records the CANCEL intent, invokes `turn/interrupt` with the
1008
+ * exact ids, and accepts only terminal `turn/completed` with `interrupted` as evidence - even
1009
+ * if it precedes the interrupt response. Claims stay held and no CANCELLED is sent; any failure
1010
+ * after the recorded intent still returns `reconciliation_required: true`.
1011
+ */
1012
+ export async function cancelCodexAttempt(options) {
1013
+ const ctx = {
1014
+ cancel_message_id: null,
1015
+ intent_recorded: false,
1016
+ intent_error: null,
1017
+ interrupt_request_ids: [],
1018
+ interrupt_response: null,
1019
+ interrupt_error: null,
1020
+ terminal_event: null,
1021
+ evidence_error: null,
1022
+ };
1023
+ try {
1024
+ const opts = normalizeAttemptOptions(options);
1025
+ requireAttemptRecord(opts);
1026
+ if (!opts.transport.owned(opts.threadId, opts.turnId))
1027
+ refuse('unowned_native_turn', 'needs_input');
1028
+ const before = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
1029
+ const beforeObservation = observationOf(before.turn.status);
1030
+ if (beforeObservation !== 'active') {
1031
+ return {
1032
+ schema: SCHEMA,
1033
+ status: 'not_cancellable',
1034
+ blockers: ['native_not_active'],
1035
+ observation: beforeObservation,
1036
+ native: {
1037
+ thread_id: opts.threadId,
1038
+ turn_id: opts.turnId,
1039
+ turn_status: before.turn.status,
1040
+ },
1041
+ reconciliation_required: false,
1042
+ claims_retained: true,
1043
+ cancelled_message_sent: false,
1044
+ };
1045
+ }
1046
+ const { taskId, from, to } = ids(opts);
1047
+ const intent = submitMessage(opts.taskRoot, buildMessage({
1048
+ type: 'CANCEL',
1049
+ task_id: taskId,
1050
+ from,
1051
+ to,
1052
+ subtask_id: opts.subtaskId,
1053
+ attempt: opts.attempt,
1054
+ owner_epoch: opts.ownerEpoch,
1055
+ body: {
1056
+ reason: opts.reason ?? 'native app-server cancellation',
1057
+ native_thread_id: opts.threadId,
1058
+ native_turn_id: opts.turnId,
1059
+ },
1060
+ }));
1061
+ if (intent.ok !== true) {
1062
+ ctx.intent_error = intent.reason ?? 'cancel_intent_not_recorded';
1063
+ return {
1064
+ schema: SCHEMA,
1065
+ status: 'invalid',
1066
+ blockers: ['cancel_intent_not_recorded'],
1067
+ intent_error: ctx.intent_error,
1068
+ native: {
1069
+ thread_id: opts.threadId,
1070
+ turn_id: opts.turnId,
1071
+ turn_status: before.turn.status,
1072
+ },
1073
+ reconciliation_required: false,
1074
+ claims_retained: true,
1075
+ cancelled_message_sent: false,
1076
+ };
1077
+ }
1078
+ ctx.cancel_message_id = intent.registration.message_id;
1079
+ ctx.intent_recorded = true;
1080
+ const fromIndex = opts.transport.events().length;
1081
+ const terminalPromise = opts.transport.waitForNotification((event) => event.method === 'turn/completed' &&
1082
+ event.params?.threadId === opts.threadId &&
1083
+ event.params?.turn?.id === opts.turnId, { timeoutMs: opts.timeoutMs, fromIndex });
1084
+ const interruptPromise = opts.transport
1085
+ .request('turn/interrupt', { threadId: opts.threadId, turnId: opts.turnId }, { timeoutMs: opts.timeoutMs })
1086
+ .then((result) => {
1087
+ ctx.interrupt_response = result;
1088
+ }, (error) => {
1089
+ ctx.interrupt_error = error;
1090
+ });
1091
+ const terminal = await terminalPromise.catch((error) => ({
1092
+ error: error instanceof CodexNativeRefusal
1093
+ ? error
1094
+ : new CodexNativeRefusal('needs_input', 'native_event_deadline', errorDetail(error)),
1095
+ }));
1096
+ await interruptPromise;
1097
+ ctx.interrupt_request_ids = opts.transport
1098
+ .requestLog()
1099
+ .filter((entry) => entry.method === 'turn/interrupt')
1100
+ .map((entry) => entry.id);
1101
+ ctx.terminal_event = terminal.error
1102
+ ? null
1103
+ : {
1104
+ method: terminal.method,
1105
+ at: terminal.at,
1106
+ turn_status: terminal.params?.turn?.status ?? null,
1107
+ };
1108
+ const evidence = {
1109
+ observed_ids: { thread_id: opts.threadId, turn_id: opts.turnId },
1110
+ cancel_message_id: ctx.cancel_message_id,
1111
+ interrupt_request_ids: ctx.interrupt_request_ids,
1112
+ interrupt_response: ctx.interrupt_response,
1113
+ interrupt_error: ctx.interrupt_error
1114
+ ? {
1115
+ code: errorCode(ctx.interrupt_error) ?? null,
1116
+ detail: errorDetail(ctx.interrupt_error),
1117
+ }
1118
+ : null,
1119
+ terminal_event: ctx.terminal_event,
1120
+ before: {
1121
+ turn_status: before.turn.status,
1122
+ queried_at: before.queried_at,
1123
+ },
1124
+ queried_at: nowIso(),
1125
+ };
1126
+ if (opts.evidenceDir) {
1127
+ try {
1128
+ fs.mkdirSync(opts.evidenceDir, { recursive: true, mode: 0o700 });
1129
+ }
1130
+ catch {
1131
+ /* reported below */
1132
+ }
1133
+ const written = tryWritePrivate(path.join(opts.evidenceDir, 'codex-native-cancel.json'), Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`));
1134
+ if (written.error)
1135
+ ctx.evidence_error = written.error;
1136
+ else
1137
+ evidence.evidence_ref = written;
1138
+ }
1139
+ const shared = {
1140
+ evidence,
1141
+ evidence_error: ctx.evidence_error,
1142
+ cancel_message_id: ctx.cancel_message_id,
1143
+ intent_recorded: ctx.intent_recorded,
1144
+ reconciliation_required: true,
1145
+ claims_retained: true,
1146
+ cancelled_message_sent: false,
1147
+ };
1148
+ if (terminal.error) {
1149
+ return {
1150
+ schema: SCHEMA,
1151
+ status: 'needs_input',
1152
+ blockers: [terminal.error.code ?? 'native_event_deadline'],
1153
+ native: {
1154
+ thread_id: opts.threadId,
1155
+ turn_id: opts.turnId,
1156
+ turn_status: before.turn.status,
1157
+ },
1158
+ interrupt_accepted: ctx.interrupt_response !== null,
1159
+ ...shared,
1160
+ };
1161
+ }
1162
+ const terminalStatus = terminal.params?.turn?.status ?? null;
1163
+ if (terminalStatus !== 'interrupted') {
1164
+ return {
1165
+ schema: SCHEMA,
1166
+ status: 'needs_input',
1167
+ blockers: ['terminal_state_unexpected'],
1168
+ native: {
1169
+ thread_id: opts.threadId,
1170
+ turn_id: opts.turnId,
1171
+ turn_status: terminalStatus,
1172
+ },
1173
+ ...shared,
1174
+ };
1175
+ }
1176
+ return {
1177
+ schema: SCHEMA,
1178
+ status: 'terminal',
1179
+ blockers: [],
1180
+ native: {
1181
+ thread_id: opts.threadId,
1182
+ turn_id: opts.turnId,
1183
+ turn_status: terminalStatus,
1184
+ },
1185
+ native_terminal: {
1186
+ turn_status: terminalStatus,
1187
+ observed_at: terminal.at,
1188
+ },
1189
+ ...shared,
1190
+ };
1191
+ }
1192
+ catch (error) {
1193
+ return refusalResult(error, {
1194
+ cancel_message_id: ctx.cancel_message_id,
1195
+ intent_recorded: ctx.intent_recorded,
1196
+ intent_error: ctx.intent_error,
1197
+ interrupt_request_ids: ctx.interrupt_request_ids,
1198
+ interrupt_response: ctx.interrupt_response,
1199
+ terminal_event: ctx.terminal_event,
1200
+ evidence_error: ctx.evidence_error,
1201
+ reconciliation_required: ctx.intent_recorded,
1202
+ cancelled_message_sent: false,
1203
+ });
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Recovery of a completed turn whose notification was lost. Only the exact requested terminal
1208
+ * turn is accepted, and it must carry a usable original native result: a model agent message or
1209
+ * an explicit successful native shell command output. Nothing is synthesized.
1210
+ */
1211
+ export async function recoverCodexCompleted(options) {
1212
+ try {
1213
+ const opts = normalizeAttemptOptions(options);
1214
+ requireAttemptRecord(opts);
1215
+ if (!opts.transport.owned(opts.threadId, opts.turnId))
1216
+ refuse('unowned_native_turn', 'needs_input');
1217
+ const native = await readTurn(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
1218
+ if (resolvePath(native.thread.cwd) !== resolvePath(opts.cwd))
1219
+ refuse('native_cwd_mismatch', 'needs_input');
1220
+ if (native.turn.status !== 'completed') {
1221
+ return {
1222
+ schema: SCHEMA,
1223
+ status: 'needs_input',
1224
+ blockers: ['terminal_turn_not_completed'],
1225
+ native: {
1226
+ thread_id: opts.threadId,
1227
+ turn_id: opts.turnId,
1228
+ turn_status: native.turn.status,
1229
+ },
1230
+ claims_retained: true,
1231
+ };
1232
+ }
1233
+ const classified = await readItems(opts.transport, opts.threadId, opts.turnId, opts.timeoutMs);
1234
+ if (classified.result_kind === null) {
1235
+ return {
1236
+ schema: SCHEMA,
1237
+ status: 'needs_input',
1238
+ blockers: ['native_result_unavailable'],
1239
+ native: {
1240
+ thread_id: opts.threadId,
1241
+ turn_id: opts.turnId,
1242
+ turn_status: native.turn.status,
1243
+ },
1244
+ item_count: classified.item_count,
1245
+ claims_retained: true,
1246
+ };
1247
+ }
1248
+ return {
1249
+ schema: SCHEMA,
1250
+ status: 'recovered',
1251
+ blockers: [],
1252
+ requested: {
1253
+ thread_id: opts.threadId,
1254
+ turn_id: opts.turnId,
1255
+ turn_type: 'recorded',
1256
+ },
1257
+ native: {
1258
+ thread_id: opts.threadId,
1259
+ turn_id: opts.turnId,
1260
+ turn_status: native.turn.status,
1261
+ thread_cwd: native.thread.cwd,
1262
+ },
1263
+ result_kind: classified.result_kind,
1264
+ agent_message: classified.agent_message,
1265
+ native_command_output: classified.native_command_output,
1266
+ native_command_exit_code: classified.native_command_exit_code,
1267
+ item_count: classified.item_count,
1268
+ transport: {
1269
+ identity: opts.transport.identity,
1270
+ request_ids: opts.transport.requestIds(),
1271
+ queried_at: native.queried_at,
1272
+ },
1273
+ receipt: null,
1274
+ report_requires_validation: true,
1275
+ claims_retained: true,
1276
+ };
1277
+ }
1278
+ catch (error) {
1279
+ return refusalResult(error);
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Persisted-history read on a FRESH server. `notLoaded` describes only this server's load state,
1284
+ * never the original execution. The canonical thread cwd is always validated against the
1285
+ * requested cwd, and with `requireCompleted` only an exact completed turn with a usable native
1286
+ * result is accepted.
1287
+ */
1288
+ export async function queryPersistedHistory(options) {
1289
+ const threadId = typeof options.threadId === 'string' && options.threadId.trim()
1290
+ ? options.threadId
1291
+ : refuse('thread_id_required');
1292
+ const turnId = options.turnId ?? null;
1293
+ const timeoutMs = bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeout_out_of_range');
1294
+ const cwd = absolute(options.cwd, 'cwd_must_be_absolute');
1295
+ let transport = null;
1296
+ try {
1297
+ transport = await openCodexTransport({
1298
+ codexBin: options.codexBin,
1299
+ cwd,
1300
+ env: options.env,
1301
+ deadlineMs: timeoutMs,
1302
+ maxBytes: options.maxBytes,
1303
+ });
1304
+ const read = await transport.request('thread/read', { threadId, includeTurns: false }, { timeoutMs });
1305
+ const thread = read?.thread;
1306
+ if (!isObject(thread) || thread.id !== threadId)
1307
+ refuse('native_thread_identity_mismatch', 'needs_input');
1308
+ if (typeof thread.cwd !== 'string')
1309
+ refuse('native_thread_cwd_unsupported', 'needs_input');
1310
+ if (resolvePath(thread.cwd) !== resolvePath(cwd))
1311
+ refuse('native_cwd_mismatch', 'needs_input');
1312
+ const listed = await transport.request('thread/turns/list', { threadId, itemsView: 'full', sortDirection: 'asc', limit: 50 }, { timeoutMs });
1313
+ const turns = Array.isArray(listed?.data)
1314
+ ? listed.data
1315
+ : refuse('native_turns_malformed', 'needs_input');
1316
+ const turn = turnId === null
1317
+ ? null
1318
+ : (turns.find((item) => item?.id === turnId) ?? null);
1319
+ if (turnId !== null && !turn)
1320
+ refuse('native_turn_not_found', 'needs_input');
1321
+ const base = {
1322
+ schema: SCHEMA,
1323
+ status: 'persisted_history_only',
1324
+ blockers: [],
1325
+ note: 'thread status from a fresh server describes this server load state only; it is not a statement about the original server or process',
1326
+ requested: { thread_id: threadId, turn_id: turnId, cwd },
1327
+ native: {
1328
+ thread_id: threadId,
1329
+ turn_id: turnId,
1330
+ thread_status: thread.status ?? null,
1331
+ thread_cwd: thread.cwd,
1332
+ turn_status: turn?.status ?? null,
1333
+ },
1334
+ transport: {
1335
+ identity: transport.identity,
1336
+ request_ids: transport.requestIds(),
1337
+ queried_at: nowIso(),
1338
+ },
1339
+ in_flight_status: null,
1340
+ claims_retained: true,
1341
+ };
1342
+ if (options.requireCompleted === true) {
1343
+ if (!turn)
1344
+ refuse('turn_id_required', 'invalid');
1345
+ if (turn.status !== 'completed')
1346
+ return {
1347
+ ...base,
1348
+ status: 'needs_input',
1349
+ blockers: ['terminal_turn_not_completed'],
1350
+ };
1351
+ const classified = await readItems(transport, threadId, turnId, timeoutMs);
1352
+ if (classified.result_kind === null)
1353
+ return {
1354
+ ...base,
1355
+ status: 'needs_input',
1356
+ blockers: ['native_result_unavailable'],
1357
+ item_count: classified.item_count,
1358
+ };
1359
+ return {
1360
+ ...base,
1361
+ status: 'recovered',
1362
+ result_kind: classified.result_kind,
1363
+ agent_message: classified.agent_message,
1364
+ native_command_output: classified.native_command_output,
1365
+ native_command_exit_code: classified.native_command_exit_code,
1366
+ item_count: classified.item_count,
1367
+ receipt: null,
1368
+ report_requires_validation: true,
1369
+ };
1370
+ }
1371
+ return base;
1372
+ }
1373
+ catch (error) {
1374
+ return refusalResult(error);
1375
+ }
1376
+ finally {
1377
+ if (transport)
1378
+ await transport.close();
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Cancellation plus controlled-runtime stop for one exact recorded attempt.
1383
+ *
1384
+ * The native handshake (CANCEL intent + `turn/interrupt` + observed terminal event) is reported
1385
+ * separately from the group/process stop proof. The owned process group is stopped ONLY when the
1386
+ * cancellation intent was actually recorded, and only for a controlled transport that created
1387
+ * the original runtime. Claims are retained, no CANCELLED is sent, and the result always carries
1388
+ * `reconciliation_required: true` once the intent exists. A non-terminal native handshake may
1389
+ * still require the controlled shutdown; it is never reported as a native interruption.
1390
+ * Task-specific evidence (known child pids/heartbeats) remains the caller's responsibility:
1391
+ * an empty owned group is not proof that every OS side effect stopped.
1392
+ */
1393
+ export async function cancelAndStopCodexAttempt(options) {
1394
+ // Exact target validation FIRST and read-only: no native request, no mutation on an invalid
1395
+ // or unowned target, so a snapshot is never taken for work this adapter does not own.
1396
+ let prepared;
1397
+ try {
1398
+ prepared = normalizeAttemptOptions(options);
1399
+ requireAttemptRecord(prepared);
1400
+ if (!prepared.transport.owned(prepared.threadId, prepared.turnId))
1401
+ refuse('unowned_native_turn', 'needs_input');
1402
+ }
1403
+ catch (error) {
1404
+ const known = error instanceof CodexNativeRefusal;
1405
+ return {
1406
+ schema: SCHEMA,
1407
+ status: known ? error.status : 'invalid',
1408
+ blockers: [known ? error.code : 'internal_error'],
1409
+ detail: known
1410
+ ? error.detail
1411
+ : String(error instanceof Error ? errorDetail(error) : error),
1412
+ native_handshake: null,
1413
+ runtime_stop: {
1414
+ attempted: false,
1415
+ reason: known ? error.code : 'internal_error',
1416
+ },
1417
+ claims_retained: true,
1418
+ reconciliation_required: false,
1419
+ cancelled_message_sent: false,
1420
+ task_specific_verification_required: true,
1421
+ };
1422
+ }
1423
+ const controlled = prepared.transport.runtime?.controlled === true;
1424
+ let snapshot = null;
1425
+ if (controlled) {
1426
+ // Read-only snapshot of the genuine owned root and its actual descendant groups, taken
1427
+ // before the interrupt so orphaned groups stay targetable. A failed capture refuses instead
1428
+ // of creating a runtime that could not be stopped.
1429
+ try {
1430
+ snapshot = prepared.transport.runtime.snapshotTree();
1431
+ }
1432
+ catch (error) {
1433
+ return {
1434
+ schema: SCHEMA,
1435
+ status: 'needs_input',
1436
+ blockers: [errorCode(error) ?? 'snapshot_failed'],
1437
+ detail: errorDetail(error) ?? '',
1438
+ native_handshake: null,
1439
+ runtime_stop: {
1440
+ attempted: false,
1441
+ refused: 'snapshot_unavailable',
1442
+ reason: errorCode(error) ?? 'snapshot_failed',
1443
+ },
1444
+ claims_retained: true,
1445
+ reconciliation_required: false,
1446
+ cancelled_message_sent: false,
1447
+ task_specific_verification_required: true,
1448
+ };
1449
+ }
1450
+ }
1451
+ const handshake = await cancelCodexAttempt(options);
1452
+ const native_handshake = {
1453
+ status: handshake.status,
1454
+ blockers: handshake.blockers ?? [],
1455
+ detail: handshake.detail ?? null,
1456
+ cancel_message_id: handshake.cancel_message_id ?? null,
1457
+ intent_recorded: handshake.intent_recorded === true,
1458
+ interrupts: handshake.evidence?.interrupt_request_ids ?? [],
1459
+ interrupt_response: handshake.evidence?.interrupt_response ??
1460
+ handshake.interrupt_response ??
1461
+ null,
1462
+ interrupt_error: handshake.evidence?.interrupt_error ?? handshake.interrupt_error ?? null,
1463
+ terminal_event: handshake.evidence?.terminal_event ?? handshake.terminal_event ?? null,
1464
+ native: handshake.native ?? null,
1465
+ native_terminal: handshake.native_terminal ?? null,
1466
+ native_interrupted_confirmed: handshake.status === 'terminal' &&
1467
+ handshake.native?.turn_status === 'interrupted',
1468
+ evidence_error: handshake.evidence_error ?? null,
1469
+ };
1470
+ const base = {
1471
+ schema: SCHEMA,
1472
+ status: handshake.status,
1473
+ native_handshake,
1474
+ snapshot: snapshot === null
1475
+ ? null
1476
+ : {
1477
+ scope: snapshot.scope,
1478
+ original_pgid: snapshot.original_pgid,
1479
+ groups: snapshot.groups,
1480
+ descendants: snapshot.descendants.length,
1481
+ captured_at: snapshot.captured_at,
1482
+ },
1483
+ claims_retained: true,
1484
+ reconciliation_required: true,
1485
+ cancelled_message_sent: false,
1486
+ task_specific_verification_required: true,
1487
+ };
1488
+ if (native_handshake.intent_recorded !== true) {
1489
+ return {
1490
+ ...base,
1491
+ runtime_stop: {
1492
+ attempted: false,
1493
+ reason: handshake.blockers?.[0] ?? 'cancel_intent_not_recorded',
1494
+ },
1495
+ };
1496
+ }
1497
+ if (!controlled) {
1498
+ return {
1499
+ ...base,
1500
+ runtime_stop: { attempted: false, refused: 'runtime_not_controlled' },
1501
+ };
1502
+ }
1503
+ try {
1504
+ const proof = await prepared.transport.runtime.stopTree(snapshot, {
1505
+ termMs: options.stopTermMs,
1506
+ killMs: options.stopKillMs,
1507
+ });
1508
+ return {
1509
+ ...base,
1510
+ runtime_stop: {
1511
+ attempted: true,
1512
+ source: 'owned-descendant-tree-snapshot',
1513
+ ...proof,
1514
+ },
1515
+ };
1516
+ }
1517
+ catch (error) {
1518
+ return {
1519
+ ...base,
1520
+ runtime_stop: {
1521
+ attempted: true,
1522
+ stopped: false,
1523
+ error: errorCode(error) ?? 'internal_error',
1524
+ detail: errorDetail(error),
1525
+ },
1526
+ };
1527
+ }
1528
+ }