@kylecheng3146/agent-ops 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +64 -0
  2. package/dist/packages/cli/src/args.js +214 -10
  3. package/dist/packages/cli/src/bin.js +77 -17
  4. package/dist/packages/cli/src/cli.js +42 -10
  5. package/dist/packages/cli/src/commands/advance.js +173 -0
  6. package/dist/packages/cli/src/commands/review-show.js +189 -0
  7. package/dist/packages/cli/src/commands/review.js +201 -34
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +62 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/install/harness.js +1 -1
  26. package/dist/runtime/src/install/preauth.js +10 -1
  27. package/dist/runtime/src/parallel/finish.js +217 -39
  28. package/dist/runtime/src/parallel/guard.js +5 -1
  29. package/dist/runtime/src/parallel/integrate.js +416 -0
  30. package/dist/runtime/src/parallel/manage.js +4 -1
  31. package/dist/runtime/src/parallel/receipt.js +153 -0
  32. package/dist/runtime/src/parallel/service.js +45 -3
  33. package/dist/runtime/src/review/attestation.js +60 -11
  34. package/dist/runtime/src/review/execute.js +24 -1
  35. package/dist/runtime/src/review/packet.js +35 -1
  36. package/dist/runtime/src/review/render.js +71 -0
  37. package/dist/runtime/src/review/runner.js +12 -1
  38. package/dist/runtime/src/review/scope.js +18 -1
  39. package/dist/runtime/src/review/slots.js +35 -10
  40. package/dist/runtime/src/run/authorization.js +60 -0
  41. package/dist/runtime/src/run/budget.js +30 -0
  42. package/dist/runtime/src/run/controls.js +139 -0
  43. package/dist/runtime/src/run/convergence.js +177 -0
  44. package/dist/runtime/src/run/hosts/claude.js +180 -0
  45. package/dist/runtime/src/run/hosts/codex.js +279 -0
  46. package/dist/runtime/src/run/hosts/index.js +4 -0
  47. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  48. package/dist/runtime/src/run/hosts/types.js +1 -0
  49. package/dist/runtime/src/run/hosts/util.js +136 -0
  50. package/dist/runtime/src/run/integration.js +204 -0
  51. package/dist/runtime/src/run/macOS.js +189 -0
  52. package/dist/runtime/src/run/ownership.js +22 -0
  53. package/dist/runtime/src/run/planning.js +25 -0
  54. package/dist/runtime/src/run/policy-transition.js +1076 -0
  55. package/dist/runtime/src/run/policy.js +176 -0
  56. package/dist/runtime/src/run/ratchet.js +71 -0
  57. package/dist/runtime/src/run/scheduler.js +260 -0
  58. package/dist/runtime/src/run/service.js +425 -0
  59. package/dist/runtime/src/run/supervisor.js +635 -0
  60. package/dist/runtime/src/run/transport.js +159 -0
  61. package/dist/runtime/src/run/types.js +1 -0
  62. package/dist/runtime/src/run/usage.js +81 -0
  63. package/dist/runtime/src/run/verification.js +64 -0
  64. package/dist/runtime/src/run/worker-gate.js +48 -0
  65. package/dist/runtime/src/schema/acceptance.js +91 -0
  66. package/dist/runtime/src/schema/validate.js +72 -7
  67. package/dist/runtime/src/task/completion.js +26 -6
  68. package/dist/runtime/src/task/contract.js +17 -0
  69. package/dist/runtime/src/task/pin-finding.js +81 -0
  70. package/dist/runtime/src/task/render.js +3 -0
  71. package/dist/runtime/src/task/service.js +361 -10
  72. package/dist/runtime/src/task/store.js +57 -7
  73. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  74. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  75. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  76. package/dist/runtime/src/verify/adapters/index.js +5 -0
  77. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  78. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  79. package/dist/runtime/src/verify/adapters/node.js +254 -0
  80. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  81. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  82. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  83. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  84. package/dist/runtime/src/verify/evidence.js +5 -2
  85. package/dist/runtime/src/verify/service.js +24 -8
  86. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  87. package/dist/runtime/src/verify/spawn.js +13 -2
  88. package/docs/en/guides/configuration.md +10 -0
  89. package/docs/en/spec/acceptance-and-evidence.md +58 -0
  90. package/docs/en/spec/delegation.md +4 -4
  91. package/docs/en/spec/loop-engineering.md +49 -0
  92. package/docs/en/spec/review.md +24 -2
  93. package/docs/zh-TW/guides/configuration.md +9 -0
  94. package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
  95. package/docs/zh-TW/spec/delegation.md +4 -4
  96. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  97. package/docs/zh-TW/spec/review.md +24 -2
  98. package/package.json +1 -1
  99. package/schemas/config.schema.json +221 -19
  100. package/schemas/evidence.schema.json +129 -6
  101. package/schemas/task.schema.json +268 -6
  102. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  103. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,279 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AgentOpsError } from "../../fs/paths.js";
3
+ import { JsonRpcSession } from "./protocol.js";
4
+ import { actionResult, assertContext, commandParts, event, assertProcessRunning, spawnNative, stopNativeProcess } from "./util.js";
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function nestedRecord(value, key) {
9
+ if (!isRecord(value) || !isRecord(value[key])) {
10
+ return null;
11
+ }
12
+ return value[key];
13
+ }
14
+ function stringField(value, key) {
15
+ return isRecord(value) && typeof value[key] === "string" ? value[key] : null;
16
+ }
17
+ function statusField(value) {
18
+ const status = stringField(value, "status");
19
+ return status === "active" || status === "paused" || status === "blocked" ||
20
+ status === "usageLimited" || status === "budgetLimited" || status === "complete"
21
+ ? status
22
+ : "unknown";
23
+ }
24
+ function resultThreadId(result, fallback = null) {
25
+ const thread = nestedRecord(result, "thread");
26
+ const id = stringField(thread, "id");
27
+ if (id === null) {
28
+ if (fallback !== null) {
29
+ return fallback;
30
+ }
31
+ throw new AgentOpsError("NATIVE_PROTOCOL_INVALID", "Codex app server did not return a thread id.");
32
+ }
33
+ return id;
34
+ }
35
+ function resultTurnId(result) {
36
+ const turn = nestedRecord(result, "turn");
37
+ return stringField(turn, "id");
38
+ }
39
+ export class CodexGoalHost {
40
+ options;
41
+ kind = "codex";
42
+ sessions = new WeakMap();
43
+ turns = new WeakMap();
44
+ now;
45
+ constructor(options = {}) {
46
+ this.options = options;
47
+ this.now = options.now ?? (() => new Date());
48
+ }
49
+ async start(input) {
50
+ const handle = await this.open(input, false, null);
51
+ return handle;
52
+ }
53
+ async resume(input, sessionId, objective) {
54
+ if (sessionId.length === 0) {
55
+ throw new AgentOpsError("NATIVE_SESSION_INVALID", "Codex thread id is required to resume.");
56
+ }
57
+ const handle = await this.open(input, true, sessionId);
58
+ if (objective !== undefined) {
59
+ await this.activate(handle, objective, input, input.tokenBudget);
60
+ }
61
+ return handle;
62
+ }
63
+ async activate(handle, objective, context, tokenBudget = null) {
64
+ assertContext(handle, context);
65
+ assertProcessRunning(handle.process);
66
+ if (objective.length === 0 || objective.length > 4000) {
67
+ throw new AgentOpsError("NATIVE_GOAL_INVALID", "Codex goal objective must be 1–4000 characters.");
68
+ }
69
+ const session = this.session(handle);
70
+ const goal = await session.request("thread/goal/set", {
71
+ threadId: handle.threadId,
72
+ objective,
73
+ status: "active",
74
+ tokenBudget
75
+ });
76
+ const turn = await session.request("turn/start", {
77
+ threadId: handle.threadId,
78
+ input: [{ type: "text", text: objective }],
79
+ approvalPolicy: "on-request",
80
+ approvalsReviewer: "auto_review"
81
+ });
82
+ const turnId = resultTurnId(turn);
83
+ if (turnId !== null) {
84
+ this.turns.set(handle, turnId);
85
+ }
86
+ return actionResult(true, statusField(nestedRecord(goal, "goal") ?? goal), goal);
87
+ }
88
+ async update(handle, update) {
89
+ assertContext(handle, update);
90
+ assertProcessRunning(handle.process);
91
+ const session = this.session(handle);
92
+ const goal = await session.request("thread/goal/set", {
93
+ threadId: handle.threadId,
94
+ objective: update.objective,
95
+ status: "active",
96
+ tokenBudget: update.tokenBudget ?? null
97
+ });
98
+ const turnId = this.turns.get(handle);
99
+ let turn;
100
+ if (turnId === undefined) {
101
+ turn = await session.request("turn/start", {
102
+ threadId: handle.threadId,
103
+ input: [{ type: "text", text: update.objective }],
104
+ approvalPolicy: "on-request",
105
+ approvalsReviewer: "auto_review"
106
+ });
107
+ }
108
+ else {
109
+ try {
110
+ turn = await session.request("turn/steer", {
111
+ threadId: handle.threadId,
112
+ expectedTurnId: turnId,
113
+ input: [{ type: "text", text: update.objective }]
114
+ });
115
+ }
116
+ catch (error) {
117
+ if (!(error instanceof AgentOpsError) || error.code !== "NATIVE_REQUEST_FAILED") {
118
+ throw error;
119
+ }
120
+ turn = await session.request("turn/start", {
121
+ threadId: handle.threadId,
122
+ input: [{ type: "text", text: update.objective }],
123
+ approvalPolicy: "on-request",
124
+ approvalsReviewer: "auto_review"
125
+ });
126
+ }
127
+ }
128
+ const nextTurnId = resultTurnId(turn);
129
+ if (nextTurnId !== null) {
130
+ this.turns.set(handle, nextTurnId);
131
+ }
132
+ return actionResult(true, statusField(nestedRecord(goal, "goal") ?? goal), goal);
133
+ }
134
+ async *observe(handle, options = {}) {
135
+ const session = this.session(handle);
136
+ while (true) {
137
+ const message = await session.nextEvent(options.signal);
138
+ if (message === null) {
139
+ return;
140
+ }
141
+ const mapped = this.mapEvent(handle, message);
142
+ yield { ...mapped, ...(typeof message.method === "string" ? { transportMethod: message.method } : {}) };
143
+ if (mapped.type === "completed" || mapped.type === "error" || mapped.type === "closed") {
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ async interrupt(handle, context) {
149
+ assertContext(handle, context);
150
+ const session = this.session(handle);
151
+ const turnId = this.turns.get(handle);
152
+ if (turnId !== undefined) {
153
+ await session.request("turn/interrupt", { threadId: handle.threadId, turnId });
154
+ }
155
+ const result = await session.request("thread/goal/set", {
156
+ threadId: handle.threadId,
157
+ objective: null,
158
+ status: "paused",
159
+ tokenBudget: null
160
+ });
161
+ return actionResult(true, statusField(nestedRecord(result, "goal") ?? result), result);
162
+ }
163
+ async stop(handle, context, signal = "SIGTERM") {
164
+ assertContext(handle, context);
165
+ let payload = null;
166
+ try {
167
+ const result = await this.interrupt(handle, context);
168
+ payload = result.payload;
169
+ }
170
+ catch {
171
+ // A crashed or already closed native process is already stopped.
172
+ }
173
+ await stopNativeProcess(handle.process, signal);
174
+ return actionResult(true, "paused", payload);
175
+ }
176
+ async open(input, resume, threadId) {
177
+ const parts = commandParts(this.options, "codex", ["app-server", "--listen", "stdio://"]);
178
+ const process = spawnNative(parts.command, parts.args, input.cwd, input.env, this.options);
179
+ const channel = new JsonRpcSession(process, input.timeoutMs ?? this.options.requestTimeoutMs ?? 30_000);
180
+ try {
181
+ if (process.pid !== undefined)
182
+ await input.registerProcess?.(process.pid);
183
+ await this.sendInitialize(channel);
184
+ let nativeThreadId;
185
+ if (resume) {
186
+ const result = await channel.request("thread/resume", {
187
+ threadId,
188
+ cwd: input.cwd,
189
+ approvalPolicy: "on-request",
190
+ approvalsReviewer: "auto_review",
191
+ sandbox: "workspace-write",
192
+ excludeTurns: true
193
+ });
194
+ nativeThreadId = resultThreadId(result, threadId);
195
+ }
196
+ else {
197
+ const result = await channel.request("thread/start", {
198
+ cwd: input.cwd,
199
+ approvalPolicy: "on-request",
200
+ approvalsReviewer: "auto_review",
201
+ sandbox: "workspace-write",
202
+ ephemeral: false
203
+ });
204
+ nativeThreadId = resultThreadId(result);
205
+ }
206
+ const handle = {
207
+ runId: input.runId,
208
+ workerId: input.workerId,
209
+ generation: input.generation,
210
+ contractHash: input.contractHash,
211
+ kind: this.kind,
212
+ sessionId: input.sessionId ?? randomUUID(),
213
+ threadId: nativeThreadId,
214
+ nativeVersion: input.nativeVersion ?? this.options.nativeVersion ?? null,
215
+ cwd: input.cwd,
216
+ processId: process.pid ?? null,
217
+ startedAt: this.now().toISOString(),
218
+ process,
219
+ goalStatus: "unknown"
220
+ };
221
+ this.sessions.set(handle, channel);
222
+ return handle;
223
+ }
224
+ catch (cause) {
225
+ channel.close(cause);
226
+ if (process.pid !== undefined)
227
+ await stopNativeProcess(process, "SIGKILL");
228
+ throw cause;
229
+ }
230
+ }
231
+ async sendInitialize(session) {
232
+ // Keep the notification sequence in one helper so callers cannot send a
233
+ // goal before the app-server handshake is complete.
234
+ await session.request("initialize", {
235
+ capabilities: { experimentalApi: true },
236
+ clientInfo: {
237
+ name: "agent-ops",
238
+ title: "agent-ops native goal host",
239
+ version: "0.6.0"
240
+ }
241
+ });
242
+ session.notify("initialized", {});
243
+ }
244
+ session(handle) {
245
+ const session = this.sessions.get(handle);
246
+ if (session === undefined) {
247
+ throw new AgentOpsError("NATIVE_SESSION_INVALID", "Unknown Codex native handle.");
248
+ }
249
+ return session;
250
+ }
251
+ mapEvent(handle, message) {
252
+ const method = typeof message.method === "string" ? message.method : "message";
253
+ const payload = isRecord(message.params) ? message.params : message;
254
+ if (method === "thread/goal/updated") {
255
+ const goal = isRecord(payload.goal) ? payload.goal : payload;
256
+ const status = statusField(goal);
257
+ return event(handle, status === "complete" ? "completed" : "goal-updated", status, payload, this.now);
258
+ }
259
+ if (method === "thread/goal/cleared") {
260
+ return event(handle, "goal-updated", "complete", payload, this.now);
261
+ }
262
+ if (method === "turn/completed") {
263
+ this.turns.delete(handle);
264
+ // A completed turn is not necessarily a completed native goal. Native
265
+ // goals can continue automatically after a turn boundary.
266
+ return event(handle, "message", "unknown", payload, this.now);
267
+ }
268
+ if (method === "turn/started") {
269
+ const turn = nestedRecord(payload, "turn");
270
+ const id = stringField(turn, "id");
271
+ if (id !== null)
272
+ this.turns.set(handle, id);
273
+ }
274
+ if (method.includes("usage") || method.includes("token")) {
275
+ return event(handle, "usage", "unknown", payload, this.now);
276
+ }
277
+ return event(handle, "message", "unknown", payload, this.now);
278
+ }
279
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./protocol.js";
3
+ export * from "./codex.js";
4
+ export * from "./claude.js";
@@ -0,0 +1,254 @@
1
+ import { createInterface } from "node:readline";
2
+ import { AgentOpsError } from "../../fs/paths.js";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function parseLine(line, source) {
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(line);
10
+ }
11
+ catch (error) {
12
+ throw new AgentOpsError("NATIVE_PROTOCOL_INVALID", `Invalid JSON from ${source}.`, { cause: error });
13
+ }
14
+ if (!isRecord(parsed)) {
15
+ throw new AgentOpsError("NATIVE_PROTOCOL_INVALID", `Native ${source} message is not an object.`);
16
+ }
17
+ return parsed;
18
+ }
19
+ /** JSONL request/notification channel used by the Codex app server. */
20
+ export class JsonRpcSession {
21
+ child;
22
+ requestTimeoutMs;
23
+ lines;
24
+ pending = new Map();
25
+ events = [];
26
+ waiters = [];
27
+ nextId = 1;
28
+ closed = null;
29
+ constructor(child, requestTimeoutMs = 30_000) {
30
+ this.child = child;
31
+ this.requestTimeoutMs = requestTimeoutMs;
32
+ this.lines = createInterface({ input: child.stdout });
33
+ this.lines.on("line", (line) => this.receive(line, "stdout"));
34
+ child.once("error", (error) => this.close(error));
35
+ child.once("exit", (code, signal) => {
36
+ this.close(new AgentOpsError("NATIVE_PROCESS_CLOSED", `Native process closed (code=${String(code)}, signal=${String(signal)}).`));
37
+ });
38
+ }
39
+ async request(method, params) {
40
+ if (this.closed !== null) {
41
+ throw this.closed;
42
+ }
43
+ const id = this.nextId++;
44
+ const request = { jsonrpc: "2.0", id, method, params };
45
+ const response = new Promise((resolve, reject) => {
46
+ const timer = setTimeout(() => {
47
+ this.pending.delete(id);
48
+ reject(new AgentOpsError("NATIVE_REQUEST_TIMEOUT", `Native request timed out: ${method}`));
49
+ }, this.requestTimeoutMs);
50
+ this.pending.set(id, { resolve, reject, timer });
51
+ });
52
+ this.write(request);
53
+ return response;
54
+ }
55
+ notify(method, params = {}) {
56
+ if (this.closed !== null) {
57
+ throw this.closed;
58
+ }
59
+ this.write({ jsonrpc: "2.0", method, params });
60
+ }
61
+ async nextEvent(signal) {
62
+ if (this.events.length > 0) {
63
+ return this.events.shift() ?? null;
64
+ }
65
+ if (this.closed !== null) {
66
+ return null;
67
+ }
68
+ return new Promise((resolve, reject) => {
69
+ const waiter = { resolve, reject, signal };
70
+ if (signal !== undefined) {
71
+ if (signal.aborted) {
72
+ resolve(null);
73
+ return;
74
+ }
75
+ const onAbort = () => {
76
+ const index = this.waiters.indexOf(waiter);
77
+ if (index >= 0) {
78
+ this.waiters.splice(index, 1);
79
+ }
80
+ resolve(null);
81
+ };
82
+ waiter.onAbort = onAbort;
83
+ signal.addEventListener("abort", onAbort, { once: true });
84
+ }
85
+ this.waiters.push(waiter);
86
+ });
87
+ }
88
+ close(error = null) {
89
+ if (this.closed !== null) {
90
+ return;
91
+ }
92
+ this.closed = error ?? new AgentOpsError("NATIVE_PROCESS_CLOSED", "Native process closed.");
93
+ this.lines.close();
94
+ for (const pending of this.pending.values()) {
95
+ clearTimeout(pending.timer);
96
+ pending.reject(this.closed);
97
+ }
98
+ this.pending.clear();
99
+ for (const waiter of this.waiters.splice(0)) {
100
+ if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
101
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
102
+ }
103
+ waiter.resolve(null);
104
+ }
105
+ }
106
+ receive(line, source) {
107
+ if (line.trim().length === 0) {
108
+ return;
109
+ }
110
+ let message;
111
+ try {
112
+ message = parseLine(line, source);
113
+ }
114
+ catch (error) {
115
+ this.close(error);
116
+ return;
117
+ }
118
+ const id = message.id;
119
+ if (typeof id === "number" && Number.isSafeInteger(id)) {
120
+ const pending = this.pending.get(id);
121
+ if (pending === undefined) {
122
+ this.enqueue(message);
123
+ return;
124
+ }
125
+ this.pending.delete(id);
126
+ clearTimeout(pending.timer);
127
+ if (isRecord(message.error)) {
128
+ pending.reject(new AgentOpsError("NATIVE_REQUEST_FAILED", String(message.error.message ?? "Native request failed"), { cause: message.error }));
129
+ }
130
+ else if (isRecord(message.result)) {
131
+ pending.resolve(message.result);
132
+ }
133
+ else {
134
+ pending.resolve(message);
135
+ }
136
+ return;
137
+ }
138
+ this.enqueue(message);
139
+ }
140
+ write(message) {
141
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
142
+ }
143
+ enqueue(message) {
144
+ const waiter = this.waiters.shift();
145
+ if (waiter !== undefined) {
146
+ if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
147
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
148
+ }
149
+ waiter.resolve(message);
150
+ }
151
+ else {
152
+ this.events.push(message);
153
+ }
154
+ }
155
+ }
156
+ /** Newline-delimited stream used by Claude Code's stream-json mode. */
157
+ export class JsonLineStream {
158
+ child;
159
+ lines;
160
+ events = [];
161
+ waiters = [];
162
+ closed = false;
163
+ constructor(child) {
164
+ this.child = child;
165
+ this.lines = createInterface({ input: child.stdout });
166
+ this.lines.on("line", (line) => {
167
+ if (line.trim().length === 0) {
168
+ return;
169
+ }
170
+ try {
171
+ this.enqueue(parseLine(line, "stdout"));
172
+ }
173
+ catch (error) {
174
+ this.close(error);
175
+ }
176
+ });
177
+ child.once("error", (error) => this.close(error));
178
+ child.once("exit", () => this.close());
179
+ }
180
+ async next(signal) {
181
+ if (this.events.length > 0) {
182
+ return this.events.shift() ?? null;
183
+ }
184
+ if (this.closed) {
185
+ return null;
186
+ }
187
+ return new Promise((resolve, reject) => {
188
+ const waiter = { resolve, reject, signal };
189
+ if (signal !== undefined) {
190
+ if (signal.aborted) {
191
+ resolve(null);
192
+ return;
193
+ }
194
+ const onAbort = () => {
195
+ const index = this.waiters.indexOf(waiter);
196
+ if (index >= 0) {
197
+ this.waiters.splice(index, 1);
198
+ }
199
+ resolve(null);
200
+ };
201
+ waiter.onAbort = onAbort;
202
+ signal.addEventListener("abort", onAbort, { once: true });
203
+ }
204
+ this.waiters.push(waiter);
205
+ });
206
+ }
207
+ requeue(message) {
208
+ this.events.unshift(message);
209
+ }
210
+ close(_error) {
211
+ if (this.closed) {
212
+ return;
213
+ }
214
+ this.closed = true;
215
+ this.lines.close();
216
+ for (const waiter of this.waiters.splice(0)) {
217
+ if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
218
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
219
+ }
220
+ waiter.resolve(null);
221
+ }
222
+ }
223
+ enqueue(message) {
224
+ const waiter = this.waiters.shift();
225
+ if (waiter !== undefined) {
226
+ if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
227
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
228
+ }
229
+ waiter.resolve(message);
230
+ }
231
+ else {
232
+ this.events.push(message);
233
+ }
234
+ }
235
+ }
236
+ export function asNativePayload(value) {
237
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
238
+ return value;
239
+ }
240
+ if (typeof value === "number") {
241
+ return Number.isFinite(value) ? value : null;
242
+ }
243
+ if (Array.isArray(value)) {
244
+ return value.map((item) => asNativePayload(item));
245
+ }
246
+ if (typeof value === "object") {
247
+ const object = {};
248
+ for (const [key, item] of Object.entries(value)) {
249
+ object[key] = asNativePayload(item);
250
+ }
251
+ return object;
252
+ }
253
+ return null;
254
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+ import { AgentOpsError } from "../../fs/paths.js";
4
+ import { asNativePayload } from "./protocol.js";
5
+ export function spawnNative(command, args, cwd, env, options) {
6
+ const spawn = options.spawn ?? ((name, argv, spawnOptions) => nodeSpawn(name, [...argv], spawnOptions));
7
+ const child = spawn(command, args, {
8
+ cwd,
9
+ env: { ...process.env, ...options.env, ...env },
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ detached: process.platform !== "win32"
12
+ });
13
+ // Native clients can emit diagnostics independently of their structured
14
+ // stdout protocol. Drain stderr so a long-lived writer cannot deadlock on a
15
+ // full pipe; structured lifecycle events remain the source of truth.
16
+ child.stderr.resume();
17
+ return child;
18
+ }
19
+ export function assertContext(handle, context) {
20
+ if (handle.runId !== context.runId ||
21
+ handle.workerId !== context.workerId ||
22
+ handle.generation !== context.generation ||
23
+ handle.contractHash !== context.contractHash) {
24
+ throw new AgentOpsError("NATIVE_STALE_WRITER", "The native action belongs to a stale run worker generation.");
25
+ }
26
+ }
27
+ export function assertProcessRunning(child) {
28
+ if (child.exitCode !== null || child.signalCode !== null) {
29
+ throw new AgentOpsError("NATIVE_PROCESS_CLOSED", "The native worker process is already closed.");
30
+ }
31
+ }
32
+ export function actionResult(accepted, nativeStatus, payload) {
33
+ return {
34
+ actionId: randomUUID(),
35
+ accepted,
36
+ nativeStatus,
37
+ payload: asNativePayload(payload)
38
+ };
39
+ }
40
+ export function event(handle, type, nativeStatus, payload, now) {
41
+ return {
42
+ type,
43
+ host: handle.kind,
44
+ runId: handle.runId,
45
+ workerId: handle.workerId,
46
+ generation: handle.generation,
47
+ contractHash: handle.contractHash,
48
+ at: now().toISOString(),
49
+ nativeStatus,
50
+ payload: asNativePayload(payload),
51
+ proof: false
52
+ };
53
+ }
54
+ export async function waitForExit(child, timeoutMs = 10_000) {
55
+ if (child.exitCode !== null || child.signalCode !== null) {
56
+ return;
57
+ }
58
+ await new Promise((resolve) => {
59
+ let settled = false;
60
+ const settle = () => {
61
+ if (settled)
62
+ return;
63
+ settled = true;
64
+ clearTimeout(timer);
65
+ resolve();
66
+ };
67
+ const timer = setTimeout(settle, timeoutMs);
68
+ child.once("exit", settle);
69
+ child.once("error", settle);
70
+ });
71
+ }
72
+ export function commandParts(options, fallbackCommand, fallbackArgs) {
73
+ return {
74
+ command: options.command ?? fallbackCommand,
75
+ args: options.args ?? fallbackArgs
76
+ };
77
+ }
78
+ /** Native writers own a process group, including their managed command children. */
79
+ export function nativeProcessGroupAlive(pid) {
80
+ try {
81
+ process.kill(-pid, 0);
82
+ return true;
83
+ }
84
+ catch (cause) {
85
+ if (cause.code === "ESRCH")
86
+ return false;
87
+ // Permission errors cannot establish death.
88
+ return true;
89
+ }
90
+ }
91
+ export async function signalNativeProcessGroup(pid, signal) {
92
+ try {
93
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
94
+ }
95
+ catch (cause) {
96
+ const code = cause.code;
97
+ if (code === "ESRCH")
98
+ return;
99
+ if (code === "EPERM" && process.platform !== "win32") {
100
+ // macOS can report EPERM while an already-killed group is being reaped.
101
+ // Permission failure is safe to dismiss only after the kernel confirms absence.
102
+ for (let i = 0; i < 30 && nativeProcessGroupAlive(pid); i++)
103
+ await new Promise(resolve => setTimeout(resolve, 100));
104
+ if (!nativeProcessGroupAlive(pid))
105
+ return;
106
+ }
107
+ throw cause;
108
+ }
109
+ }
110
+ export async function stopNativeProcess(child, signal) {
111
+ const pid = child.pid;
112
+ if (pid === undefined)
113
+ throw new AgentOpsError("NATIVE_PROCESS_UNKNOWN", "Native process has no registered PID.");
114
+ const send = async (s) => {
115
+ if (process.platform !== "win32")
116
+ return await signalNativeProcessGroup(pid, s);
117
+ try {
118
+ child.kill(s);
119
+ }
120
+ catch (cause) {
121
+ if (cause.code !== "ESRCH")
122
+ throw cause;
123
+ }
124
+ };
125
+ await send(signal);
126
+ await waitForExit(child, 3000);
127
+ if (process.platform !== "win32" && nativeProcessGroupAlive(pid)) {
128
+ await send("SIGKILL");
129
+ for (let i = 0; i < 30 && nativeProcessGroupAlive(pid); i++)
130
+ await new Promise(resolve => setTimeout(resolve, 100));
131
+ if (nativeProcessGroupAlive(pid))
132
+ throw new AgentOpsError("NATIVE_PROCESS_STILL_ALIVE", "Managed native descendants have not stopped; takeover is refused.");
133
+ }
134
+ if (child.exitCode === null && child.signalCode === null)
135
+ throw new AgentOpsError("NATIVE_PROCESS_STILL_ALIVE", "Native stop did not confirm process death.");
136
+ }