@the-open-engine/zeroshot 6.21.0 → 6.23.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 (68) hide show
  1. package/lib/agent-cli-provider/adapters/omp.d.ts +3 -1
  2. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  3. package/lib/agent-cli-provider/adapters/omp.js +252 -269
  4. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  5. package/lib/agent-cli-provider/contract-invoke.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/contract-invoke.js +68 -14
  7. package/lib/agent-cli-provider/contract-invoke.js.map +1 -1
  8. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  9. package/lib/agent-cli-provider/contract-options.js +5 -3
  10. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  11. package/lib/agent-cli-provider/index.d.ts +6 -0
  12. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  13. package/lib/agent-cli-provider/index.js +12 -1
  14. package/lib/agent-cli-provider/index.js.map +1 -1
  15. package/lib/agent-cli-provider/omp-release.d.ts +3 -1
  16. package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/omp-release.js +22 -2
  18. package/lib/agent-cli-provider/omp-release.js.map +1 -1
  19. package/lib/agent-cli-provider/omp-rpc-bounds.d.ts +7 -0
  20. package/lib/agent-cli-provider/omp-rpc-bounds.d.ts.map +1 -0
  21. package/lib/agent-cli-provider/omp-rpc-bounds.js +27 -0
  22. package/lib/agent-cli-provider/omp-rpc-bounds.js.map +1 -0
  23. package/lib/agent-cli-provider/omp-rpc-driver.d.ts +40 -0
  24. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -0
  25. package/lib/agent-cli-provider/omp-rpc-driver.js +494 -0
  26. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -0
  27. package/lib/agent-cli-provider/omp-rpc-events.d.ts +28 -0
  28. package/lib/agent-cli-provider/omp-rpc-events.d.ts.map +1 -0
  29. package/lib/agent-cli-provider/omp-rpc-events.js +264 -0
  30. package/lib/agent-cli-provider/omp-rpc-events.js.map +1 -0
  31. package/lib/agent-cli-provider/omp-rpc-protocol.d.ts +1 -1
  32. package/lib/agent-cli-provider/omp-rpc-protocol.d.ts.map +1 -1
  33. package/lib/agent-cli-provider/omp-rpc-protocol.js +8 -3
  34. package/lib/agent-cli-provider/omp-rpc-protocol.js.map +1 -1
  35. package/lib/agent-cli-provider/omp-rpc-session.d.ts +17 -0
  36. package/lib/agent-cli-provider/omp-rpc-session.d.ts.map +1 -0
  37. package/lib/agent-cli-provider/omp-rpc-session.js +6 -0
  38. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -0
  39. package/lib/agent-cli-provider/provider-registry.d.ts +15 -11
  40. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  41. package/lib/agent-cli-provider/provider-registry.js +16 -5
  42. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  43. package/lib/agent-cli-provider/types.d.ts +13 -11
  44. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  45. package/lib/agent-cli-provider/types.js.map +1 -1
  46. package/package.json +2 -1
  47. package/src/agent-cli-provider/adapters/omp.ts +276 -329
  48. package/src/agent-cli-provider/contract-invoke.ts +86 -15
  49. package/src/agent-cli-provider/contract-options.ts +5 -3
  50. package/src/agent-cli-provider/index.ts +13 -0
  51. package/src/agent-cli-provider/omp-release.ts +24 -1
  52. package/src/agent-cli-provider/omp-rpc-bounds.ts +28 -0
  53. package/src/agent-cli-provider/omp-rpc-driver.ts +611 -0
  54. package/src/agent-cli-provider/omp-rpc-events.ts +318 -0
  55. package/src/agent-cli-provider/omp-rpc-protocol.ts +8 -3
  56. package/src/agent-cli-provider/omp-rpc-session.ts +20 -0
  57. package/src/agent-cli-provider/provider-registry.ts +26 -7
  58. package/src/agent-cli-provider/types.ts +14 -11
  59. package/src/command-cleanup-ownership.js +307 -0
  60. package/src/omp-config-overlay.js +96 -0
  61. package/src/orchestrator.js +20 -5
  62. package/src/preflight.js +35 -3
  63. package/src/watcher-prompt-channel.js +209 -0
  64. package/task-lib/command-spec-cleanup.js +1 -262
  65. package/task-lib/provider-helper-runtime.js +6 -0
  66. package/task-lib/rpc-watcher.js +186 -0
  67. package/task-lib/runner.js +40 -6
  68. package/task-lib/watcher-output-runtime.js +32 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Private parent -> detached-watcher prompt channel.
3
+ *
4
+ * The OMP prompt is task content (repository text, instructions, whatever a caller pasted in), so
5
+ * it must never travel in the watcher's argv: `ps` and /proc/<pid>/cmdline expose argv to every
6
+ * local user for the entire lifetime of a long-lived watcher. task-lib/runner.js therefore hands
7
+ * the prompt to task-lib/rpc-watcher.js over the anonymous stdin pipe fork() creates for it. The
8
+ * pipe has exactly two ends and no name, so nothing is written to the filesystem, the task log, or
9
+ * the ledger while the prompt is in transit.
10
+ *
11
+ * Framing is a newline-terminated JSON header followed by exactly `promptBytes` UTF-8 bytes. That
12
+ * declared length is what lets the receiver tell a complete payload apart from an absent,
13
+ * truncated, over-long, or over-contract one, so it can fail closed instead of prompting the
14
+ * provider with a partial instruction.
15
+ */
16
+
17
+ const { TextDecoder } = require('util');
18
+
19
+ const PROMPT_CHANNEL_KIND = 'zeroshot-watcher-prompt-v1';
20
+
21
+ // The header is a single short JSON object ({"kind":"...","promptBytes":1048576} is 56 bytes), so a
22
+ // peer that never sends a newline must not be able to make the receiver buffer without bound before
23
+ // the declared payload length is even known.
24
+ const MAX_PROMPT_CHANNEL_HEADER_BYTES = 256;
25
+
26
+ class WatcherPromptChannelError extends Error {
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.name = 'WatcherPromptChannelError';
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ function encodeWatcherPromptFrame(prompt) {
35
+ const body = Buffer.from(prompt, 'utf8');
36
+ const header = `${JSON.stringify({ kind: PROMPT_CHANNEL_KIND, promptBytes: body.byteLength })}\n`;
37
+ return Buffer.concat([Buffer.from(header, 'utf8'), body]);
38
+ }
39
+
40
+ /**
41
+ * Write the framed prompt into the watcher's stdin pipe and close the write end.
42
+ *
43
+ * The receiver owns fail-closed handling for a short or missing payload, so a watcher that already
44
+ * exited (a cancellation requested before spawn closes the read end) has to surface here as a
45
+ * swallowed EPIPE rather than as an unhandled 'error' that would take down the spawning process.
46
+ */
47
+ function sendWatcherPrompt(stream, prompt) {
48
+ stream.on('error', () => {});
49
+ stream.end(encodeWatcherPromptFrame(prompt));
50
+ }
51
+
52
+ function parsePromptChannelHeader(headerText, maxBytes) {
53
+ let header;
54
+ try {
55
+ header = JSON.parse(headerText);
56
+ } catch {
57
+ throw new WatcherPromptChannelError(
58
+ 'malformed-header',
59
+ 'Prompt channel header is not valid JSON.'
60
+ );
61
+ }
62
+ if (header === null || typeof header !== 'object' || header.kind !== PROMPT_CHANNEL_KIND) {
63
+ throw new WatcherPromptChannelError(
64
+ 'malformed-header',
65
+ `Prompt channel header is not a ${PROMPT_CHANNEL_KIND} frame.`
66
+ );
67
+ }
68
+ const { promptBytes } = header;
69
+ if (!Number.isSafeInteger(promptBytes) || promptBytes < 0) {
70
+ throw new WatcherPromptChannelError(
71
+ 'malformed-header',
72
+ "Prompt channel header has an invalid 'promptBytes' value."
73
+ );
74
+ }
75
+ if (promptBytes > maxBytes) {
76
+ throw new WatcherPromptChannelError(
77
+ 'prompt-too-large',
78
+ `Prompt channel declared ${promptBytes} bytes, above the ${maxBytes}-byte contract.`
79
+ );
80
+ }
81
+ return promptBytes;
82
+ }
83
+
84
+ /**
85
+ * Read exactly one framed prompt off `stream`, or reject with a WatcherPromptChannelError.
86
+ *
87
+ * Every rejection path is a fail-closed one: an absent channel, a header that never arrives, a
88
+ * declared length above `maxBytes`, a payload that ends short of (or overruns) the declared length,
89
+ * and invalid UTF-8 all reject rather than resolving with partial text.
90
+ */
91
+ function receiveWatcherPrompt(stream, { maxBytes }) {
92
+ return new Promise((resolve, reject) => {
93
+ if (!stream || typeof stream.on !== 'function' || stream.isTTY) {
94
+ reject(
95
+ new WatcherPromptChannelError(
96
+ 'absent',
97
+ 'No private prompt pipe is attached to this watcher.'
98
+ )
99
+ );
100
+ return;
101
+ }
102
+
103
+ let buffer = Buffer.alloc(0);
104
+ let declaredBytes = null;
105
+ let settled = false;
106
+
107
+ function detach() {
108
+ stream.removeListener('data', onData);
109
+ stream.removeListener('end', onEnd);
110
+ stream.removeListener('error', onError);
111
+ // destroy() can emit 'error' after the listeners above are gone, which would otherwise throw
112
+ // as an unhandled 'error' event and mask the real channel outcome.
113
+ stream.on('error', () => {});
114
+ try {
115
+ stream.destroy();
116
+ } catch {
117
+ // Already released; nothing further to close.
118
+ }
119
+ }
120
+
121
+ function fail(code, message) {
122
+ if (settled) return;
123
+ settled = true;
124
+ detach();
125
+ reject(new WatcherPromptChannelError(code, message));
126
+ }
127
+
128
+ function succeed(prompt) {
129
+ if (settled) return;
130
+ settled = true;
131
+ detach();
132
+ resolve(prompt);
133
+ }
134
+
135
+ function readHeader() {
136
+ const newlineIndex = buffer.indexOf(0x0a);
137
+ if (newlineIndex === -1) {
138
+ if (buffer.byteLength > MAX_PROMPT_CHANNEL_HEADER_BYTES) {
139
+ fail(
140
+ 'malformed-header',
141
+ `Prompt channel header exceeded ${MAX_PROMPT_CHANNEL_HEADER_BYTES} bytes without a newline.`
142
+ );
143
+ }
144
+ return false;
145
+ }
146
+ const headerText = buffer.subarray(0, newlineIndex).toString('utf8');
147
+ buffer = buffer.subarray(newlineIndex + 1);
148
+ try {
149
+ declaredBytes = parsePromptChannelHeader(headerText, maxBytes);
150
+ } catch (error) {
151
+ fail(error.code, error.message);
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+
157
+ function consume() {
158
+ if (declaredBytes === null && !readHeader()) return;
159
+ if (buffer.byteLength > declaredBytes) {
160
+ fail(
161
+ 'overlong-payload',
162
+ `Prompt channel delivered more than the declared ${declaredBytes} bytes.`
163
+ );
164
+ return;
165
+ }
166
+ if (buffer.byteLength < declaredBytes) return;
167
+ let prompt;
168
+ try {
169
+ prompt = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
170
+ } catch {
171
+ fail('invalid-utf8', 'Prompt channel payload is not valid UTF-8.');
172
+ return;
173
+ }
174
+ succeed(prompt);
175
+ }
176
+
177
+ function onData(chunk) {
178
+ if (settled) return;
179
+ buffer = Buffer.concat([buffer, chunk]);
180
+ consume();
181
+ }
182
+
183
+ function onEnd() {
184
+ fail(
185
+ 'truncated',
186
+ declaredBytes === null
187
+ ? 'Prompt channel closed before a complete header arrived.'
188
+ : `Prompt channel closed after ${buffer.byteLength} of ${declaredBytes} declared bytes.`
189
+ );
190
+ }
191
+
192
+ function onError(error) {
193
+ fail('channel-error', `Prompt channel failed: ${error.message}`);
194
+ }
195
+
196
+ stream.on('data', onData);
197
+ stream.on('end', onEnd);
198
+ stream.on('error', onError);
199
+ });
200
+ }
201
+
202
+ module.exports = {
203
+ MAX_PROMPT_CHANNEL_HEADER_BYTES,
204
+ PROMPT_CHANNEL_KIND,
205
+ WatcherPromptChannelError,
206
+ encodeWatcherPromptFrame,
207
+ receiveWatcherPrompt,
208
+ sendWatcherPrompt,
209
+ };
@@ -1,265 +1,4 @@
1
- import { lstat, realpath, rm, unlink } from 'fs/promises';
2
- import { lstatSync, realpathSync, rmSync, unlinkSync } from 'fs';
3
- import { tmpdir } from 'os';
4
- import { basename, dirname, isAbsolute, resolve } from 'path';
5
1
  import { createRequire } from 'module';
6
2
 
7
3
  const require = createRequire(import.meta.url);
8
- const {
9
- isCanonicalClaudeSettingsOverlayDirectory,
10
- isClaudeSettingsOverlayDirectory,
11
- } = require('../src/worktree-claude-config');
12
-
13
- const CLEANUP_METADATA_KEYS = ['kind', 'path', 'provider', 'reason'];
14
- const SCHEMA_DIRECTORY_PATTERN = /^zeroshot-schema-[A-Za-z0-9_-]+$/u;
15
- const SCHEMA_FILE_PATTERN =
16
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/u;
17
- const POLICY_DIRECTORY_PATTERN = /^zeroshot-gemini-policy-[A-Za-z0-9_-]+$/u;
18
- const POLICY_FILE_PATTERN =
19
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.toml$/u;
20
- const OPENCODE_CONFIG_DIRECTORY_PATTERN = /^zeroshot-opencode-config-[A-Za-z0-9_-]+$/u;
21
-
22
- function assertClosedCleanupMetadata(cleanupPath, metadata) {
23
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
24
- throw new Error(`Refusing cleanup without closed ownership metadata: ${cleanupPath}`);
25
- }
26
- const keys = Object.keys(metadata).sort();
27
- if (
28
- keys.length !== CLEANUP_METADATA_KEYS.length ||
29
- keys.some((key, index) => key !== CLEANUP_METADATA_KEYS[index])
30
- ) {
31
- throw new Error(`Refusing cleanup with open ownership metadata: ${cleanupPath}`);
32
- }
33
- if (metadata.path !== cleanupPath) {
34
- throw new Error(`Refusing cleanup with mismatched ownership path: ${cleanupPath}`);
35
- }
36
- }
37
-
38
- function createCleanupPlan(commandSpec) {
39
- if (!Array.isArray(commandSpec?.cleanup) || !Array.isArray(commandSpec?.cleanupMetadata)) {
40
- throw new Error('Refusing cleanup with malformed cleanup collections');
41
- }
42
- if (commandSpec.cleanup.length !== commandSpec.cleanupMetadata.length) {
43
- throw new Error('Refusing cleanup without one closed metadata receipt per path');
44
- }
45
- const uniquePaths = new Set(commandSpec.cleanup);
46
- if (uniquePaths.size !== commandSpec.cleanup.length) {
47
- throw new Error('Refusing cleanup with duplicate cleanup paths');
48
- }
49
-
50
- return commandSpec.cleanup.map((cleanupPath) => {
51
- if (typeof cleanupPath !== 'string' || cleanupPath.length === 0) {
52
- throw new Error('Refusing cleanup with a non-string or empty path');
53
- }
54
- const matches = commandSpec.cleanupMetadata.filter(
55
- (metadata) => metadata?.path === cleanupPath
56
- );
57
- if (matches.length !== 1) {
58
- throw new Error(`Refusing cleanup without exact ownership metadata: ${cleanupPath}`);
59
- }
60
- assertClosedCleanupMetadata(cleanupPath, matches[0]);
61
- return { cleanupPath, metadata: matches[0] };
62
- });
63
- }
64
-
65
- function assertOwnedTempDirectory(cleanupPath, metadata) {
66
- const isOpenCodeConfig =
67
- metadata.provider === 'opencode' && metadata.reason === 'isolated-config';
68
- if (isOpenCodeConfig) {
69
- const canonical =
70
- isAbsolute(cleanupPath) &&
71
- resolve(cleanupPath) === cleanupPath &&
72
- dirname(cleanupPath) === resolve(tmpdir()) &&
73
- OPENCODE_CONFIG_DIRECTORY_PATTERN.test(basename(cleanupPath));
74
- if (!canonical) {
75
- throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
76
- }
77
- let stat;
78
- try {
79
- stat = lstatSync(cleanupPath);
80
- } catch (error) {
81
- if (error?.code === 'ENOENT') return true;
82
- throw error;
83
- }
84
- const tempRoot = realpathSync(tmpdir());
85
- const realDirectory = realpathSync(cleanupPath);
86
- if (
87
- stat.isSymbolicLink() ||
88
- !stat.isDirectory() ||
89
- dirname(realDirectory) !== tempRoot ||
90
- basename(realDirectory) !== basename(cleanupPath)
91
- ) {
92
- throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
93
- }
94
- return false;
95
- }
96
-
97
- if (
98
- metadata.provider !== 'claude' ||
99
- metadata.reason !== 'settings-overlay' ||
100
- !isCanonicalClaudeSettingsOverlayDirectory(cleanupPath)
101
- ) {
102
- throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
103
- }
104
- if (isClaudeSettingsOverlayDirectory(cleanupPath)) {
105
- return false;
106
- }
107
- try {
108
- lstatSync(cleanupPath);
109
- } catch (error) {
110
- if (error?.code === 'ENOENT') return true;
111
- throw error;
112
- }
113
- throw new Error(`Refusing unowned temporary directory cleanup: ${cleanupPath}`);
114
- }
115
-
116
- function assertCanonicalSchemaPath(cleanupPath, metadata) {
117
- const isCodexSchema =
118
- metadata.kind === 'temp-file' &&
119
- metadata.provider === 'codex' &&
120
- metadata.reason === 'output-schema';
121
- const isGeminiPolicy =
122
- metadata.kind === 'temp-file' &&
123
- metadata.provider === 'gemini' &&
124
- metadata.reason === 'admin-policy';
125
- if (
126
- (!isCodexSchema && !isGeminiPolicy) ||
127
- !isAbsolute(cleanupPath) ||
128
- resolve(cleanupPath) !== cleanupPath
129
- ) {
130
- throw new Error(
131
- `Refusing unowned ${isGeminiPolicy ? 'admin-policy' : 'output-schema'} cleanup: ${cleanupPath}`
132
- );
133
- }
134
- const ownedDirectory = dirname(cleanupPath);
135
- const matchesOwnedName = isCodexSchema
136
- ? SCHEMA_DIRECTORY_PATTERN.test(basename(ownedDirectory)) &&
137
- SCHEMA_FILE_PATTERN.test(basename(cleanupPath))
138
- : POLICY_DIRECTORY_PATTERN.test(basename(ownedDirectory)) &&
139
- POLICY_FILE_PATTERN.test(basename(cleanupPath));
140
- if (dirname(ownedDirectory) !== resolve(tmpdir()) || !matchesOwnedName) {
141
- throw new Error(
142
- `Refusing non-canonical ${isGeminiPolicy ? 'admin-policy' : 'output-schema'} cleanup: ${cleanupPath}`
143
- );
144
- }
145
- }
146
-
147
- async function assertOwnedSchemaFile(cleanupPath, metadata) {
148
- assertCanonicalSchemaPath(cleanupPath, metadata);
149
- const schemaDirectory = dirname(cleanupPath);
150
- const [tempRoot, realSchemaDirectory, directoryStat, schemaStat] = await Promise.all([
151
- realpath(tmpdir()),
152
- realpath(schemaDirectory),
153
- lstat(schemaDirectory),
154
- lstat(cleanupPath),
155
- ]);
156
- if (
157
- directoryStat.isSymbolicLink() ||
158
- !directoryStat.isDirectory() ||
159
- dirname(realSchemaDirectory) !== tempRoot ||
160
- basename(realSchemaDirectory) !== basename(schemaDirectory) ||
161
- schemaStat.isSymbolicLink() ||
162
- !schemaStat.isFile()
163
- ) {
164
- throw new Error(`Refusing non-regular or escaped output-schema cleanup: ${cleanupPath}`);
165
- }
166
- }
167
-
168
- function assertOwnedSchemaFileSync(cleanupPath, metadata) {
169
- assertCanonicalSchemaPath(cleanupPath, metadata);
170
- const schemaDirectory = dirname(cleanupPath);
171
- const tempRoot = realpathSync(tmpdir());
172
- const realSchemaDirectory = realpathSync(schemaDirectory);
173
- const directoryStat = lstatSync(schemaDirectory);
174
- const schemaStat = lstatSync(cleanupPath);
175
- if (
176
- directoryStat.isSymbolicLink() ||
177
- !directoryStat.isDirectory() ||
178
- dirname(realSchemaDirectory) !== tempRoot ||
179
- basename(realSchemaDirectory) !== basename(schemaDirectory) ||
180
- schemaStat.isSymbolicLink() ||
181
- !schemaStat.isFile()
182
- ) {
183
- throw new Error(`Refusing non-regular or escaped output-schema cleanup: ${cleanupPath}`);
184
- }
185
- }
186
-
187
- async function removeCleanupPath(cleanupPath, metadata) {
188
- if (metadata.kind === 'temp-directory') {
189
- if (assertOwnedTempDirectory(cleanupPath, metadata)) return;
190
- await rm(cleanupPath, { recursive: true, force: true });
191
- return;
192
- }
193
- await assertOwnedSchemaFile(cleanupPath, metadata);
194
- await unlink(cleanupPath);
195
- }
196
-
197
- function removeCleanupPathSync(cleanupPath, metadata) {
198
- if (metadata.kind === 'temp-directory') {
199
- if (assertOwnedTempDirectory(cleanupPath, metadata)) return;
200
- rmSync(cleanupPath, { recursive: true, force: true });
201
- return;
202
- }
203
- assertOwnedSchemaFileSync(cleanupPath, metadata);
204
- unlinkSync(cleanupPath);
205
- }
206
-
207
- /**
208
- * Build one idempotent cleanup owner for a provider command. Callers may run it
209
- * only after the persisted provider termination boundary is terminal.
210
- */
211
- export function createCommandSpecCleanup(commandSpec, logFailure) {
212
- let started = false;
213
- let result = true;
214
-
215
- return {
216
- async run() {
217
- if (started) return result;
218
- started = true;
219
- let cleanupPlan;
220
- try {
221
- cleanupPlan = createCleanupPlan(commandSpec);
222
- } catch (error) {
223
- logFailure('<command-cleanup>', error);
224
- result = false;
225
- return result;
226
- }
227
- let succeeded = true;
228
- for (const { cleanupPath, metadata } of cleanupPlan) {
229
- try {
230
- await removeCleanupPath(cleanupPath, metadata);
231
- } catch (error) {
232
- if (error?.code === 'ENOENT') continue;
233
- succeeded = false;
234
- logFailure(cleanupPath, error);
235
- }
236
- }
237
- result = succeeded;
238
- return result;
239
- },
240
- runSync() {
241
- if (started) return result;
242
- started = true;
243
- let cleanupPlan;
244
- try {
245
- cleanupPlan = createCleanupPlan(commandSpec);
246
- } catch (error) {
247
- logFailure('<command-cleanup>', error);
248
- result = false;
249
- return result;
250
- }
251
- let succeeded = true;
252
- for (const { cleanupPath, metadata } of cleanupPlan) {
253
- try {
254
- removeCleanupPathSync(cleanupPath, metadata);
255
- } catch (error) {
256
- if (error?.code === 'ENOENT') continue;
257
- succeeded = false;
258
- logFailure(cleanupPath, error);
259
- }
260
- }
261
- result = succeeded;
262
- return result;
263
- },
264
- };
265
- }
4
+ export const { createCommandSpecCleanup } = require('../src/command-cleanup-ownership');
@@ -13,8 +13,13 @@ try {
13
13
  }
14
14
 
15
15
  export const {
16
+ ABORT_GRACE_MS,
17
+ DEFAULT_OMP_RPC_DECODER_LIMITS,
18
+ EXIT_GRACE_MS,
16
19
  NO_MESSAGES_RETURNED,
20
+ OMP_SUPPORTED_VERSION,
17
21
  STREAMING_MODE_ERROR,
22
+ buildOmpPrompt,
18
23
  buildProviderCommand,
19
24
  classifyProviderError,
20
25
  detectProviderFatalError,
@@ -32,6 +37,7 @@ export const {
32
37
  recoverProviderStructuredOutput,
33
38
  resolveProviderCommand,
34
39
  resolveModelSpec,
40
+ runOmpRpcTask,
35
41
  supportsProviderCapability,
36
42
  providerAliasMap,
37
43
  providerAliases,
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Watcher process for the rpc-stdio invoke lane (OMP RPC v2). Runs detached from the parent,
5
+ * drives the session over stdio via the shared runOmpRpcTask driver (task-lib/runner.js resolves
6
+ * this script instead of watcher.js whenever the resolved provider's invoke.lane is 'rpc-stdio'),
7
+ * and updates task status on completion. Foreground (contract-invoke.ts) and this detached watcher
8
+ * both call runOmpRpcTask and therefore produce identical result semantics; the only difference is
9
+ * that this watcher persists OmpRpcSpawnEvidence via updateTask before the prompt is written.
10
+ *
11
+ * The prompt itself never appears in argv (`ps` and /proc/<pid>/cmdline would expose it for as long
12
+ * as this watcher lives). task-lib/runner.js sends it over the private stdin pipe described in
13
+ * src/watcher-prompt-channel.js, and OMP is not spawned until a complete payload has arrived.
14
+ */
15
+
16
+ import { appendFileSync } from 'fs';
17
+ import { getTask, updateTask } from './store.js';
18
+ import { createCommandSpecCleanup } from './command-spec-cleanup.js';
19
+ import {
20
+ completeWatcherFailure,
21
+ completePendingWatcherCancellation,
22
+ completeWatcherTask,
23
+ createRpcWatcherOutputRuntime,
24
+ terminateWatcherProvider,
25
+ } from './watcher-output-runtime.js';
26
+ import { createRequire } from 'module';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const {
30
+ ABORT_GRACE_MS,
31
+ DEFAULT_OMP_RPC_DECODER_LIMITS,
32
+ EXIT_GRACE_MS,
33
+ OMP_SUPPORTED_VERSION,
34
+ runOmpRpcTask,
35
+ } = require('./provider-helper-runtime.js');
36
+ const { receiveWatcherPrompt } = require('../src/watcher-prompt-channel.js');
37
+
38
+ // No overall task timeout: detached tasks run until the provider produces a terminal frame,
39
+ // matching watcher.js's unbounded child.on('close') wait for every other lane.
40
+ const NO_TIMEOUT_MS = 2_147_483_647;
41
+
42
+ const [, , taskId, cwd, logFile, , configJson] = process.argv;
43
+ const config = configJson ? JSON.parse(configJson) : {};
44
+ const commandSpec = config.commandSpec || {};
45
+
46
+ function log(msg) {
47
+ appendFileSync(logFile, msg);
48
+ }
49
+
50
+ function emergencyLog(msg) {
51
+ try {
52
+ log(msg);
53
+ } catch {
54
+ process.stderr.write(msg);
55
+ }
56
+ }
57
+
58
+ const commandCleanup = createCommandSpecCleanup(commandSpec, (cleanupPath, error) => {
59
+ emergencyLog(`[${Date.now()}][CLEANUP] Failed to delete ${cleanupPath}: ${error.message}\n`);
60
+ });
61
+
62
+ const outputRuntime = createRpcWatcherOutputRuntime({ log });
63
+
64
+ let crashStarted = false;
65
+ let spawnEvidence = null;
66
+
67
+ function terminateOwnedProviderBoundary(exitObserved = false) {
68
+ if (!spawnEvidence) return true;
69
+ return terminateWatcherProvider(
70
+ { pid: spawnEvidence.pid },
71
+ { exitObserved, platform: process.platform }
72
+ );
73
+ }
74
+
75
+ async function crashWithError(error, source) {
76
+ if (crashStarted) return;
77
+ crashStarted = true;
78
+ await completeWatcherFailure({
79
+ taskId,
80
+ error,
81
+ source,
82
+ commandCleanup,
83
+ terminateProvider: terminateOwnedProviderBoundary,
84
+ updateTask,
85
+ emergencyLog,
86
+ });
87
+ process.exit(1);
88
+ }
89
+
90
+ process.on('uncaughtException', (error) => {
91
+ void crashWithError(error, 'uncaughtException');
92
+ });
93
+
94
+ process.on('unhandledRejection', (reason) => {
95
+ void crashWithError(reason, 'unhandledRejection');
96
+ });
97
+
98
+ async function run() {
99
+ if (
100
+ await completePendingWatcherCancellation({
101
+ taskId,
102
+ getTask,
103
+ commandCleanup,
104
+ terminateProvider: () => true,
105
+ updateTask,
106
+ emergencyLog,
107
+ })
108
+ ) {
109
+ process.exit(0);
110
+ return;
111
+ }
112
+
113
+ // Fail closed before any provider process exists: an absent, truncated, over-contract, or
114
+ // early-closed prompt channel must end the task through the same ownership-aware cleanup path as
115
+ // any other crash, never spawn OMP with a partial instruction. The 1 MiB ceiling is the pinned
116
+ // physical RPC frame limit — a larger prompt could never be written as a `prompt` command anyway.
117
+ let prompt;
118
+ try {
119
+ prompt = await receiveWatcherPrompt(process.stdin, {
120
+ maxBytes: DEFAULT_OMP_RPC_DECODER_LIMITS.maxPhysicalFrameBytes,
121
+ });
122
+ } catch (error) {
123
+ await crashWithError(error, 'prompt-channel');
124
+ return;
125
+ }
126
+
127
+ const controller = new AbortController();
128
+
129
+ const result = await runOmpRpcTask(
130
+ {
131
+ commandSpec: { ...commandSpec, cwd: commandSpec.cwd || cwd },
132
+ prompt,
133
+ expectedVersion: OMP_SUPPORTED_VERSION,
134
+ session: { kind: 'none' },
135
+ signal: controller.signal,
136
+ timeoutMs: NO_TIMEOUT_MS,
137
+ abortGraceMs: ABORT_GRACE_MS,
138
+ exitGraceMs: EXIT_GRACE_MS,
139
+ },
140
+ {
141
+ onSpawn: async (evidence) => {
142
+ spawnEvidence = evidence;
143
+ await updateTask(taskId, {
144
+ pid: evidence.pid,
145
+ processGroupId: evidence.processGroupId,
146
+ terminationStrategy: evidence.terminationStrategy,
147
+ });
148
+ if (getTask(taskId)?.cancelRequested) {
149
+ crashStarted = true;
150
+ controller.abort();
151
+ await completePendingWatcherCancellation({
152
+ taskId,
153
+ getTask,
154
+ commandCleanup,
155
+ terminateProvider: terminateOwnedProviderBoundary,
156
+ updateTask,
157
+ emergencyLog,
158
+ });
159
+ process.exit(0);
160
+ }
161
+ },
162
+ onEvent: (event) => {
163
+ outputRuntime.logEvent(event);
164
+ return Promise.resolve();
165
+ },
166
+ onSession: async () => {},
167
+ }
168
+ );
169
+
170
+ if (crashStarted) return; // Cancelled mid-spawn; the onSpawn hook already exited the process.
171
+
172
+ const completion = outputRuntime.complete(result);
173
+ await completeWatcherTask({
174
+ taskId,
175
+ completion,
176
+ commandCleanup,
177
+ terminateProvider: () => terminateOwnedProviderBoundary(true),
178
+ updateTask,
179
+ emergencyLog,
180
+ });
181
+ process.exit(0);
182
+ }
183
+
184
+ run().catch((error) => {
185
+ void crashWithError(error, 'run');
186
+ });