@mobileaidev/ai-app-bridge 0.3.8 → 0.4.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 (56) hide show
  1. package/README.md +52 -39
  2. package/bin/ai-app-bridge.js +56 -17
  3. package/bin/command-discovery.js +19 -4
  4. package/bin/command-registry.js +15 -4
  5. package/bin/command-request.js +68 -4
  6. package/bin/execution-host.js +31 -5
  7. package/bin/execution-runtime.js +17 -10
  8. package/bin/executors/preparation.js +19 -5
  9. package/bin/extraction/json-value.js +26 -0
  10. package/bin/extraction/prepare.js +57 -0
  11. package/bin/extraction/regex.js +30 -0
  12. package/bin/extraction/runner.js +77 -0
  13. package/bin/mcp-server.js +15 -20
  14. package/bin/public-reply.js +184 -0
  15. package/bin/response-store.js +60 -0
  16. package/bin/runtime-client.js +32 -15
  17. package/bin/runtime-directory.js +37 -8
  18. package/bin/script/node-runtime-adapter.js +139 -123
  19. package/bin/script/python-runtime-adapter.js +1 -1
  20. package/bin/script/script-diagnostics.js +21 -0
  21. package/bin/script/script-durable-restore.js +1 -0
  22. package/bin/script/script-sdk.js +39 -4
  23. package/bin/script/script-sdk.py +79 -7
  24. package/bin/script/script-session-channel.js +27 -10
  25. package/bin/script/script-supervisor.js +8 -0
  26. package/bin/shared-kernel/argument-schema.js +44 -12
  27. package/bin/shared-kernel/evidence-archive.js +2 -2
  28. package/bin/shared-kernel/evidence-schema.js +16 -1
  29. package/bin/shared-kernel/evidence-store.js +3 -3
  30. package/bin/shared-kernel/execution-contracts.js +13 -5
  31. package/docs/COMMAND_CONTRACT.md +91 -19
  32. package/docs/EVIDENCE_ARCHIVE.md +14 -1
  33. package/docs/INSTALLATION.md +73 -0
  34. package/docs/INTENT_FOREGROUND.md +4 -1
  35. package/docs/OPTIONAL_EXECUTORS.md +14 -14
  36. package/docs/RELEASE.md +60 -122
  37. package/docs/RESPONSE_EXTRACTION.md +122 -0
  38. package/docs/SCRIPT_AUTHORING.md +125 -6
  39. package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
  40. package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
  41. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
  42. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
  43. package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
  44. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
  45. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
  46. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
  47. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
  48. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
  49. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
  50. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
  51. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
  52. package/package.json +12 -5
  53. package/runtime/executors/android/prepare.init.gradle +22 -0
  54. package/runtime/executors/playwright/package-lock.json +2 -2
  55. package/runtime/executors/playwright/package.json +1 -1
  56. package/skills/ai-app-bridge-use/SKILL.md +19 -4
@@ -4,10 +4,11 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const http = require('node:http');
6
6
  const { fork } = require('node:child_process');
7
- const { CommandError, commandFailure } = require('./command-errors');
8
- const { validateRunRequest } = require('./command-request');
7
+ const { CommandError } = require('./command-errors');
8
+ const { validateRunRequest, publicOutputLimit } = require('./command-request');
9
+ const { publicReply, publicFailure, isPublicReply, finishReply } = require('./public-reply');
9
10
  const { protocol, maxMessageBytes, readJson, decodeReply } = require('./runtime-protocol');
10
- const { runtimeLocation, runtimeIdentity, acquireRuntimeLock, readEndpoint, prepareDirectory } = require('./runtime-directory');
11
+ const { runtimeLocation, runtimeIdentity, requireCompatible, acquireRuntimeLock, readEndpoint, prepareDirectory } = require('./runtime-directory');
11
12
  const { executablePath } = require('./shared-kernel/executable-path');
12
13
  const starting = new Map();
13
14
 
@@ -66,11 +67,6 @@ async function probe(location, signal) {
66
67
  return { state: endpoint ? 'unresponsive' : 'starting', cause };
67
68
  }
68
69
 
69
- function requireCompatible(status, expected) {
70
- if (status.identity.code !== expected.code) throw new CommandError('runtime_code_mismatch', 'The running runtime uses different code. Inspect runtime status and explicitly stop it before starting this build.', { details: { runtimeId: status.runtimeId, pid: status.pid } });
71
- if (status.identity.config !== expected.config) throw new CommandError('runtime_configuration_mismatch', 'The running runtime uses different persistent/provider configuration. Use the same configuration or explicitly stop it first.', { details: { runtimeId: status.runtimeId, pid: status.pid } });
72
- }
73
-
74
70
  function launch(location) {
75
71
  prepareDirectory(location);
76
72
  const log = fs.openSync(location.logFile, 'a', 0o600);
@@ -132,27 +128,48 @@ async function ensureRuntime(location, identity, signal) {
132
128
  return starting.get(location.directory);
133
129
  }
134
130
 
131
+ // The reply is always the public reply of the request. Executed commands
132
+ // arrive assembled by the Runtime; local paths and client failures are
133
+ // assembled here with the same module.
135
134
  async function run(request, { signal } = {}) {
135
+ const command = typeof request?.command === 'string' ? request.command : undefined;
136
+ const maxBytes = publicOutputLimit(request);
137
+ let extract;
136
138
  try {
137
139
  request = validateRunRequest(request);
140
+ if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')) {
141
+ extract = require('./extraction/prepare').prepareExtraction(request.extract);
142
+ }
143
+ }
144
+ catch (error) { return { value: publicFailure({ command, stage: 'validation', error, maxBytes }) }; }
145
+ const local = async reply => ({ value: await finishReply({ body: publicReply({ command: request.command, reply, completed: true }),
146
+ extract, output: request.output }) });
147
+ try {
138
148
  // Verification is an offline command in both transports; it neither
139
149
  // opens FactStore nor depends on a running owner or valid store profile.
140
150
  if (request.command === 'evidence' && request.arguments.operation === 'verify') {
141
- return { value: await require('./shared-kernel/evidence-archive').handle(request.arguments) };
151
+ return local({ value: await require('./shared-kernel/evidence-archive').handle(request.arguments) });
142
152
  }
143
153
  const location = runtimeLocation();
144
154
  const identity = runtimeIdentity(location);
145
155
  if (request.command === 'runtime' && request.arguments.operation !== 'start') {
146
156
  const state = await probe(location, signal);
147
- if (state.state === 'stopped') return { value: { ok: true, command: 'runtime', status: 'stopped', facts: location.facts } };
157
+ if (state.state === 'stopped') return local({ value: { ok: true, command: 'runtime', status: 'stopped', facts: location.facts } });
148
158
  if (state.state !== 'running') throw new CommandError('runtime_unresponsive', 'A runtime owns the OS lock but has not answered. It was not restarted.');
149
- if (request.arguments.operation === 'stop') return await exchange(state.endpoint, 'stop', {}, { signal });
150
- return { value: { ...state.status, compatible: state.status.identity.code === identity.code && state.status.identity.config === identity.config } };
159
+ if (request.arguments.operation === 'stop') return local(await exchange(state.endpoint, 'stop', {}, { signal }));
160
+ return local({ value: { ...state.status, compatible: state.status.identity.code === identity.code && state.status.identity.config === identity.config } });
151
161
  }
152
162
  const endpoint = await ensureRuntime(location, identity, signal);
153
- if (request.command === 'runtime') return await exchange(endpoint, 'status', {}, { signal });
154
- return await exchange(endpoint, 'execute', request, { identity, signal });
155
- } catch (error) { return { value: commandFailure(error, request?.command) }; }
163
+ if (request.command === 'runtime') return local(await exchange(endpoint, 'status', {}, { signal }));
164
+ const reply = await exchange(endpoint, 'execute', request, { identity, signal });
165
+ if (isPublicReply(reply.value)) return reply;
166
+ // Authentication and request-decoding failures precede execute dispatch,
167
+ // so the internal RPC cannot yet identify the requested public command.
168
+ if (reply.value?.ok === false && reply.value.dispatched === false
169
+ && ['runtime_access_denied', 'runtime_protocol_error', 'runtime_message_too_large'].includes(reply.value.error)) return { value: publicFailure({
170
+ command: request.command, stage: 'execution', error: new CommandError(reply.value.error, reply.value.message), maxBytes: request.output?.maxBytes }) };
171
+ throw new CommandError('runtime_protocol_error', 'Execute returned no public reply.', { dispatched: null, ambiguous: true });
172
+ } catch (error) { return { value: publicFailure({ command: request.command, stage: 'execution', error, maxBytes: request.output?.maxBytes }) }; }
156
173
  }
157
174
 
158
175
  module.exports = { run, exchange };
@@ -11,9 +11,7 @@ const { protocol } = require('./runtime-protocol');
11
11
  const { canonicalPath } = require('./shared-kernel/canonical-path');
12
12
  const { executablePath } = require('./shared-kernel/executable-path');
13
13
 
14
- let fingerprint;
15
- function codeFingerprint() {
16
- if (fingerprint) return fingerprint;
14
+ function calculateCodeFingerprint() {
17
15
  const hash = createHash('sha256');
18
16
  function visit(directory) {
19
17
  for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -33,12 +31,19 @@ function codeFingerprint() {
33
31
  }
34
32
  const nativeDirectory = path.dirname(require.resolve('@mobileaidev/segmented-fact-store-native'));
35
33
  hash.update(fs.readFileSync(path.join(nativeDirectory, 'index.js')));
36
- hash.update(fs.readFileSync(path.join(nativeDirectory, 'build/Release/segmented_fact_store.node')));
34
+ hash.update(fs.readFileSync(path.join(nativeDirectory, 'binding-path.js')));
35
+ hash.update(fs.readFileSync(path.join(nativeDirectory, 'prebuilds/manifest.json')));
36
+ const nativeBinding = require('@mobileaidev/segmented-fact-store-native/binding-path').resolveBinding();
37
+ hash.update(fs.readFileSync(nativeBinding.path));
37
38
  hash.update(JSON.stringify({ node: process.versions.node, modules: process.versions.modules }));
38
- fingerprint = hash.digest('hex');
39
- return fingerprint;
39
+ return hash.digest('hex');
40
40
  }
41
41
 
42
+ // Pin the running client's code when this entrypoint loads, before the first
43
+ // request. Replacing installed files cannot make this process claim new code.
44
+ const fingerprint = calculateCodeFingerprint();
45
+ const packageVersion = require('../package.json').version;
46
+
42
47
  function runtimeLocation() {
43
48
  const target = hostFactStoreTarget();
44
49
  const facts = canonicalPath(target.directory);
@@ -55,7 +60,31 @@ function runtimeIdentity(location = runtimeLocation()) {
55
60
  const config = { facts: location.facts, profile: location.profile, ownership: canonicalPath(ownershipDirectory()),
56
61
  adb: executablePath(process.env.ADB || 'adb') ?? { unavailable: process.env.ADB || 'adb' },
57
62
  environment: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])) };
58
- return { protocol, code: codeFingerprint(), config: createHash('sha256').update(JSON.stringify(config)).digest('hex') };
63
+ return { protocol, code: fingerprint, config: createHash('sha256').update(JSON.stringify(config)).digest('hex'),
64
+ version: packageVersion, node: { version: process.versions.node, modules: process.versions.modules, napi: process.versions.napi } };
65
+ }
66
+
67
+ function requireCompatible(status, client) {
68
+ const runtime = status.identity;
69
+ const codeMismatch = runtime?.code !== client?.code;
70
+ const configMismatch = runtime?.config !== client?.config;
71
+ if (!codeMismatch && !configMismatch) return;
72
+ const parse = version => typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version) ? version.split('.').map(Number) : null;
73
+ const runtimeVersion = parse(runtime?.version), clientVersion = parse(client?.version);
74
+ let outdatedSide = 'undetermined';
75
+ if (runtimeVersion && clientVersion) {
76
+ const index = runtimeVersion.findIndex((part, i) => part !== clientVersion[i]);
77
+ if (index >= 0) outdatedSide = runtimeVersion[index] < clientVersion[index] ? 'runtime' : 'client';
78
+ }
79
+ const describe = identity => `package ${identity?.version ?? 'unknown'}, Node ${identity?.node?.version ?? 'unknown'}, ABI ${identity?.node?.modules ?? 'unknown'}, code ${identity?.code ?? 'unknown'}, config ${identity?.config ?? 'unknown'}`;
80
+ let next;
81
+ if (!codeMismatch) next = 'Use matching persistent/provider configuration; the running Runtime was not restarted.';
82
+ else if (outdatedSide === 'client') next = 'The client package is older. Update and reconnect the client; do not stop the newer Runtime to fix this client.';
83
+ else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation.';
84
+ else next = 'Builds or Node environments differ. Align the installations and reconnect; fingerprints alone cannot identify an older side.';
85
+ throw new CommandError(codeMismatch ? 'runtime_code_mismatch' : 'runtime_configuration_mismatch',
86
+ `Client: ${describe(client)}. Runtime: ${describe(runtime)}. ${next}`,
87
+ { details: { runtimeId: status.runtimeId, pid: status.pid, client, runtime, outdatedSide } });
59
88
  }
60
89
 
61
90
  function prepareDirectory(location) {
@@ -106,4 +135,4 @@ function publishEndpoint(location, endpoint) {
106
135
  fs.renameSync(temporary, location.endpointFile);
107
136
  }
108
137
 
109
- module.exports = { runtimeLocation, runtimeIdentity, prepareDirectory, acquireRuntimeLock, readEndpoint, publishEndpoint };
138
+ module.exports = { runtimeLocation, runtimeIdentity, requireCompatible, prepareDirectory, acquireRuntimeLock, readEndpoint, publishEndpoint };
@@ -38,174 +38,190 @@ function createChildRuntimeAdapter({
38
38
  onSpawnFailure,
39
39
  } = {}) {
40
40
  let channel = null;
41
+ let child = null;
41
42
  let exited = Promise.resolve();
42
43
  let readControl = () => ({ status: 'running', pauseReason: null });
43
44
 
44
45
  async function terminate() {
45
46
  if (channel) channel.stop();
46
47
  await exited;
48
+ child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
47
49
  }
48
50
 
49
- function start(args) {
50
- return inRequestDirectory(args.spec.cwd ?? requestDirectory(), () => startInDirectory(args));
51
+ async function start(args) {
52
+ const result = await inRequestDirectory(args.spec.cwd ?? requestDirectory(), () => startInDirectory(args));
53
+ if (!result.ok && channel) {
54
+ const diagnostics = { ...channel.diagnostics(), ...result.diagnostics };
55
+ if (Object.keys(diagnostics).length) result.diagnostics = diagnostics;
56
+ }
57
+ return result;
51
58
  }
52
59
 
53
60
  async function startInDirectory({ spec, host, agent, emit, control }) {
54
61
  readControl = typeof control === 'function' ? control : () => control || { status: 'running', pauseReason: null };
55
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-script-'));
56
- const artifact = path.join(directory, `main${extension}`);
57
- fs.writeFileSync(artifact, spec.source);
58
- const sdk = path.join(__dirname, sdkFile);
59
- const child = spawn(executable, argsPrefix.concat([sdk, artifact]), {
60
- shell: false,
61
- cwd: requestDirectory(),
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- });
64
- channel = createScriptSessionChannel(child, {
65
- maxFrameBytes: Math.max(spec.policy.maxOutputBytes, spec.policy.maxProgressBytes, 1024 * 1024) + 64 * 1024,
66
- });
67
- exited = childExitPromise(child, onSpawnFailure);
68
- const finished = exited;
62
+ let directory;
69
63
  let timer = null;
70
- const timedOut = new Promise((resolve) => {
71
- timer = setTimeout(() => resolve({ type: 'timeout' }), spec.policy.timeoutMs);
72
- });
73
- const rpc = pending => Promise.race([pending, timedOut, finished.then(() => { throw new Error('child_crashed'); })]);
64
+ channel = null;
65
+ exited = Promise.resolve();
74
66
  try {
75
- const ready = await Promise.race([
76
- channel.nextMessage(),
77
- finished,
78
- timedOut,
79
- ]);
80
- if (ready && ready.type === 'timeout') {
81
- await terminate();
82
- return { ok: false, error: 'timeout' };
83
- }
84
- if (!ready || ready.type !== 'ready') {
85
- await terminate();
86
- return { ok: false, error: ready && ready.type === 'fail' && ready.error === 'handshake_failed' ? 'handshake_failed' : 'child_crashed' };
87
- }
88
- emit('child_started', { kind });
89
- channel.send({
90
- type: 'start',
91
- inputs: spec.inputs,
92
- entrypoint: spec.entrypoint,
93
- control: control(),
94
- });
95
- while (true) {
96
- const message = await Promise.race([
67
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-script-'));
68
+ const artifact = path.join(directory, `main${extension}`);
69
+ fs.writeFileSync(artifact, spec.source);
70
+ const sdk = path.join(__dirname, sdkFile);
71
+ child = spawn(executable, argsPrefix.concat([sdk, artifact]), {
72
+ shell: false,
73
+ cwd: requestDirectory(),
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ });
76
+ channel = createScriptSessionChannel(child, {
77
+ maxFrameBytes: Math.max(spec.policy.maxOutputBytes, spec.policy.maxProgressBytes, 1024 * 1024) + 64 * 1024,
78
+ });
79
+ exited = childExitPromise(child, onSpawnFailure);
80
+ const finished = exited;
81
+ const timedOut = new Promise((resolve) => {
82
+ timer = setTimeout(() => resolve({ type: 'timeout' }), spec.policy.timeoutMs);
83
+ });
84
+ const rpc = pending => Promise.race([pending, timedOut, finished.then(() => { throw new Error('child_crashed'); })]);
85
+ const ready = await Promise.race([
97
86
  channel.nextMessage(),
98
87
  finished,
99
88
  timedOut,
100
89
  ]);
101
- if (message.type === 'timeout') {
90
+ if (ready && ready.type === 'timeout') {
102
91
  await terminate();
103
- emit('script_failed', { error: 'timeout' });
104
92
  return { ok: false, error: 'timeout' };
105
93
  }
106
- if (message.type === 'call') {
107
- const value = await rpc(host.call(message.command, message.args, message.options));
108
- if (value && value.type === 'timeout') {
109
- await terminate();
110
- emit('script_failed', { error: 'timeout' });
111
- return { ok: false, error: 'timeout' };
112
- }
113
- channel.send({ type: 'result', id: message.id, value, control: readControl() });
114
- continue;
94
+ if (!ready || ready.type !== 'ready') {
95
+ await terminate();
96
+ return { ok: false, error: ready && ready.type === 'fail' && ready.error === 'handshake_failed' ? 'handshake_failed' : 'child_crashed' };
115
97
  }
116
- if (message.type === 'assert') {
117
- const value = await rpc(host.assert(message.assertion));
118
- if (value && value.type === 'timeout') {
98
+ emit('child_started', { kind });
99
+ channel.send({
100
+ type: 'start',
101
+ inputs: spec.inputs,
102
+ entrypoint: spec.entrypoint,
103
+ sourceName: spec.sourcePath || artifact,
104
+ control: control(),
105
+ });
106
+ while (true) {
107
+ const message = await Promise.race([
108
+ channel.nextMessage(),
109
+ finished,
110
+ timedOut,
111
+ ]);
112
+ if (message.type === 'timeout') {
119
113
  await terminate();
120
114
  emit('script_failed', { error: 'timeout' });
121
115
  return { ok: false, error: 'timeout' };
122
116
  }
123
- channel.send({ type: 'verdict', id: message.id, value, control: readControl() });
124
- continue;
125
- }
126
- if (message.type === 'control-point') {
127
- channel.send({ type: 'control-state', id: message.id, control: readControl() });
128
- continue;
129
- }
130
- if (message.type === 'progress') {
131
- if (jsonBytes(message.event) > spec.policy.maxProgressBytes) {
132
- await terminate();
133
- emit('script_failed', { error: 'progress_too_large' });
134
- return { ok: false, error: 'progress_too_large' };
117
+ if (message.type === 'call') {
118
+ const value = await rpc(host.call(message.command, message.args, message.options));
119
+ if (value && value.type === 'timeout') {
120
+ await terminate();
121
+ emit('script_failed', { error: 'timeout' });
122
+ return { ok: false, error: 'timeout' };
123
+ }
124
+ channel.send({ type: 'result', id: message.id, value, control: readControl() });
125
+ continue;
135
126
  }
136
- emit('progress', message.event);
137
- continue;
138
- }
139
- if (message.type === 'checkpoint') {
140
- if (jsonBytes(message.state) > spec.policy.maxOutputBytes) {
141
- await terminate();
142
- emit('script_failed', { error: 'checkpoint_too_large' });
143
- return { ok: false, error: 'checkpoint_too_large' };
127
+ if (message.type === 'assert') {
128
+ const value = await rpc(host.assert(message.assertion));
129
+ if (value && value.type === 'timeout') {
130
+ await terminate();
131
+ emit('script_failed', { error: 'timeout' });
132
+ return { ok: false, error: 'timeout' };
133
+ }
134
+ channel.send({ type: 'verdict', id: message.id, value, control: readControl() });
135
+ continue;
144
136
  }
145
- const receipt = await rpc(Promise.resolve(emit('checkpoint', { name: message.name, state: message.state })));
146
- if (receipt && receipt.type === 'timeout') {
147
- await terminate();
148
- emit('script_failed', { error: 'timeout' });
149
- return { ok: false, error: 'timeout' };
137
+ if (message.type === 'control-point') {
138
+ channel.send({ type: 'control-state', id: message.id, control: readControl() });
139
+ continue;
150
140
  }
151
- if (receipt && receipt.ok === false) {
152
- await terminate();
153
- const error = receipt.error || 'checkpoint_not_persisted';
154
- emit('script_failed', { error });
155
- return { ok: false, error };
141
+ if (message.type === 'progress') {
142
+ if (jsonBytes(message.event) > spec.policy.maxProgressBytes) {
143
+ await terminate();
144
+ emit('script_failed', { error: 'progress_too_large' });
145
+ return { ok: false, error: 'progress_too_large' };
146
+ }
147
+ emit('progress', message.event);
148
+ continue;
156
149
  }
157
- channel.send({ type: 'checkpoint-receipt', id: message.id, value: receipt, control: readControl() });
158
- continue;
159
- }
160
- if (message.type === 'ask') {
161
- const value = await rpc(agent.askAgent(message.request));
162
- if (value && value.type === 'timeout') {
163
- await terminate();
164
- emit('script_failed', { error: 'timeout' });
165
- return { ok: false, error: 'timeout' };
150
+ if (message.type === 'checkpoint') {
151
+ if (jsonBytes(message.state) > spec.policy.maxOutputBytes) {
152
+ await terminate();
153
+ emit('script_failed', { error: 'checkpoint_too_large' });
154
+ return { ok: false, error: 'checkpoint_too_large' };
155
+ }
156
+ const receipt = await rpc(Promise.resolve(emit('checkpoint', { name: message.name, state: message.state })));
157
+ if (receipt && receipt.type === 'timeout') {
158
+ await terminate();
159
+ emit('script_failed', { error: 'timeout' });
160
+ return { ok: false, error: 'timeout' };
161
+ }
162
+ if (receipt && receipt.ok === false) {
163
+ await terminate();
164
+ const error = receipt.error || 'checkpoint_not_persisted';
165
+ emit('script_failed', { error });
166
+ return { ok: false, error };
167
+ }
168
+ channel.send({ type: 'checkpoint-receipt', id: message.id, value: receipt, control: readControl() });
169
+ continue;
166
170
  }
167
- channel.send({ type: 'decision', id: message.id, value, control: readControl() });
168
- continue;
169
- }
170
- if (message.type === 'return') {
171
- if (jsonBytes(message.result) > spec.policy.maxOutputBytes) {
171
+ if (message.type === 'ask') {
172
+ const value = await rpc(agent.askAgent(message.request));
173
+ if (value && value.type === 'timeout') {
174
+ await terminate();
175
+ emit('script_failed', { error: 'timeout' });
176
+ return { ok: false, error: 'timeout' };
177
+ }
178
+ channel.send({ type: 'decision', id: message.id, value, control: readControl() });
179
+ continue;
180
+ }
181
+ if (message.type === 'return') {
182
+ if (jsonBytes(message.result) > spec.policy.maxOutputBytes) {
183
+ await terminate();
184
+ emit('script_failed', { error: 'output_too_large' });
185
+ return { ok: false, error: 'output_too_large' };
186
+ }
187
+ child.stdin.end();
188
+ const ended = await Promise.race([finished, timedOut]);
189
+ if (ended && ended.type === 'timeout') {
190
+ await terminate();
191
+ } else {
192
+ await finished;
193
+ }
194
+ emit('script_completed', { result: message.result });
195
+ return { ok: true, result: message.result };
196
+ }
197
+ if (message.type === 'fail') {
172
198
  await terminate();
173
- emit('script_failed', { error: 'output_too_large' });
174
- return { ok: false, error: 'output_too_large' };
199
+ const error = message.error === 'channel_closed' || message.error === 'stopped'
200
+ ? 'child_crashed'
201
+ : (message.error || 'script_failed');
202
+ const captured = channel.diagnostics();
203
+ const diagnostics = { ...captured, ...(message.diagnostic ? { error: message.diagnostic } : {}) };
204
+ const extra = Object.keys(diagnostics).length ? { diagnostics } : {};
205
+ emit('script_failed', { error, ...extra });
206
+ return { ok: false, error, ...extra };
175
207
  }
176
- child.stdin.end();
177
- const ended = await Promise.race([finished, timedOut]);
178
- if (ended && ended.type === 'timeout') {
208
+ if (message.type === 'exit') {
179
209
  await terminate();
180
- } else {
181
- await finished;
210
+ emit('script_failed', { error: 'child_crashed' });
211
+ return { ok: false, error: 'child_crashed' };
182
212
  }
183
- emit('script_completed', { result: message.result });
184
- return { ok: true, result: message.result };
185
- }
186
- if (message.type === 'fail') {
187
- await terminate();
188
- const error = message.error === 'channel_closed' || message.error === 'stopped'
189
- ? 'child_crashed'
190
- : (message.error || 'script_failed');
191
- emit('script_failed', { error });
192
- return { ok: false, error };
193
- }
194
- if (message.type === 'exit') {
195
213
  await terminate();
196
214
  emit('script_failed', { error: 'child_crashed' });
197
215
  return { ok: false, error: 'child_crashed' };
198
216
  }
199
- await terminate();
200
- emit('script_failed', { error: 'child_crashed' });
201
- return { ok: false, error: 'child_crashed' };
202
- }
203
217
  } catch {
204
218
  await terminate();
205
219
  emit('script_failed', { error: 'child_crashed' });
206
220
  return { ok: false, error: 'child_crashed' };
207
221
  } finally {
208
222
  if (timer) clearTimeout(timer);
223
+ await terminate();
224
+ if (directory) fs.rmSync(directory, { recursive: true, force: true });
209
225
  }
210
226
  }
211
227
 
@@ -35,7 +35,7 @@ function pythonCandidates({ pythonPath, env = process.env } = {}) {
35
35
  }
36
36
 
37
37
  function probePython(executable, env) {
38
- const options = { encoding: 'utf8', shell: false };
38
+ const options = { encoding: 'utf8', shell: false, timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 16384 };
39
39
  if (env) options.env = env;
40
40
  const probe = spawnSync(executable, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], options);
41
41
  if (probe.status !== 0 || !probe.stdout) {
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ function boundedText(value, maxBytes) {
4
+ return Buffer.from(String(value)).subarray(0, maxBytes).toString('utf8').replace(/\uFFFD$/, '');
5
+ }
6
+
7
+ function errorDiagnostic(error) {
8
+ const stack = typeof error?.stack === 'string' ? error.stack : '';
9
+ let frameCount = 0;
10
+ const limitedStack = stack.split('\n').filter(line => !/^\s*at /.test(line) || ++frameCount <= 20).join('\n');
11
+ const frame = stack.split('\n').find(line => /:(\d+):(\d+)\)?$/.test(line));
12
+ const location = frame?.match(/(?:at .*?\(|at )(.*):(\d+):(\d+)\)?$/);
13
+ const syntaxLocation = error?.name === 'SyntaxError' ? stack.split('\n')[0].match(/^(.*):(\d+)$/) : null;
14
+ return { type: boundedText(error?.name || 'Error', 128), message: boundedText(error?.message ?? error, 2048),
15
+ ...(stack ? { stack: boundedText(limitedStack, 8192), stackTruncated: frameCount > 20 || Buffer.byteLength(limitedStack) > 8192 } : {}),
16
+ ...(syntaxLocation ? { location: { file: boundedText(syntaxLocation[1], 1024), line: Number(syntaxLocation[2]) } }
17
+ : location ? { location: { file: boundedText(location[1], 1024), line: Number(location[2]), column: Number(location[3]) } } : {}),
18
+ ...(typeof error?.valuePath === 'string' ? { valuePath: boundedText(error.valuePath, 1024) } : {}) };
19
+ }
20
+
21
+ module.exports = { boundedText, errorDiagnostic };
@@ -157,6 +157,7 @@ async function restoreUnknownOperation(args = {}) {
157
157
  operationId,
158
158
  status: terminal ? checkpoint.status : 'failed',
159
159
  error: terminal ? checkpoint.error || null : 'runtime_lost',
160
+ ...(checkpoint.diagnostics ? { diagnostics: checkpoint.diagnostics } : {}),
160
161
  persisted: true,
161
162
  restored: true,
162
163
  resumeMode: gate.ok ? 'checkpoint' : 'none',
@@ -2,8 +2,9 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const readline = require('node:readline');
5
+ const { boundedText, errorDiagnostic } = require('./script-diagnostics');
5
6
 
6
- const artifactPath = process.argv[2];
7
+ const artifactPath = fs.realpathSync(process.argv[2]);
7
8
  const replies = new Map();
8
9
  const controlWaiters = [];
9
10
  let nextId = 0;
@@ -37,6 +38,7 @@ function waitReply(id) {
37
38
  rl.on('line', (line) => {
38
39
  if (!line) return;
39
40
  let message;
41
+ const parseStarted = performance.now();
40
42
  try {
41
43
  message = JSON.parse(line);
42
44
  } catch {
@@ -44,10 +46,21 @@ rl.on('line', (line) => {
44
46
  process.exit(1);
45
47
  return;
46
48
  }
49
+ const inputParseMs = performance.now() - parseStarted;
47
50
  if (message.type === 'start') {
48
51
  applyControl(message.control);
49
- run(message).catch((error) => {
50
- send({ type: 'fail', error: error.message || String(error) });
52
+ run(message, inputParseMs).catch((error) => {
53
+ const diagnostic = errorDiagnostic(error);
54
+ if (message.sourceName && message.sourceName !== artifactPath) {
55
+ if (diagnostic.stack) {
56
+ const mapped = diagnostic.stack.replaceAll(artifactPath, message.sourceName);
57
+ diagnostic.stack = boundedText(mapped, 8192);
58
+ diagnostic.stackTruncated ||= Buffer.byteLength(mapped) > 8192;
59
+ }
60
+ if (diagnostic.location?.file === artifactPath) diagnostic.location.file = boundedText(message.sourceName, 1024);
61
+ }
62
+ send({ type: 'fail', error: message.extraction ? (error.code || 'extraction_failed') : diagnostic.message,
63
+ message: diagnostic.message, diagnostic });
51
64
  process.exit(1);
52
65
  });
53
66
  return;
@@ -92,7 +105,29 @@ function holdIfPaused() {
92
105
  });
93
106
  }
94
107
 
95
- async function run(start) {
108
+ async function run(start, inputParseMs) {
109
+ if (start.extraction) {
110
+ const executionStarted = performance.now();
111
+ let result;
112
+ if (start.extraction.mode === 'regex') result = require('../extraction/regex').extractRegex(start.inputs.response, start.extraction);
113
+ else {
114
+ const Module = require('node:module');
115
+ const path = require('node:path');
116
+ const filename = path.resolve(start.sourceName || artifactPath);
117
+ const loaded = new Module(filename, module);
118
+ loaded.filename = filename;
119
+ loaded.paths = Module._nodeModulePaths(path.dirname(filename));
120
+ loaded._compile(fs.readFileSync(artifactPath, 'utf8'), filename);
121
+ const main = typeof loaded.exports === 'function' ? loaded.exports : loaded.exports.main;
122
+ if (typeof main !== 'function') throw new TypeError('Extraction source must export main(ctx).');
123
+ result = await main({ inputs: start.inputs });
124
+ }
125
+ require('../extraction/json-value').validateJsonValue(result);
126
+ if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) throw Object.assign(new Error('Extraction result exceeds 256 KiB.'), { code: 'extraction_output_too_large' });
127
+ send({ type: 'return', result, timings: { inputParseMs, executionMs: performance.now() - executionStarted } });
128
+ process.exit(0);
129
+ return;
130
+ }
96
131
  const loaded = require(artifactPath);
97
132
  const entry = start.entrypoint || 'main';
98
133
  const main = typeof loaded === 'function' && entry === 'main' ? loaded : loaded[entry];