agentproc 0.1.1 → 0.2.1

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.
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "agentproc",
3
- "version": "0.1.1",
4
- "description": "AgentProc Protocol SDK for Node.js — connect any Agent CLI to a messaging platform",
3
+ "version": "0.2.1",
4
+ "description": "AgentProc Protocol SDK + CLI for Node.js — connect any Agent CLI to a messaging platform",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
7
+ "bin": {
8
+ "agentproc": "src/cli.js"
9
+ },
7
10
  "scripts": {
8
- "test": "node --test src/index.test.js"
11
+ "test": "node --test src/index.test.js src/runner.test.js"
9
12
  },
10
- "keywords": ["agentproc", "agent", "bridge", "protocol", "cli", "ai"],
13
+ "keywords": ["agentproc", "agent", "bridge", "protocol", "cli", "ai", "runner"],
11
14
  "license": "MIT",
12
15
  "engines": {
13
16
  "node": ">=18"
@@ -16,5 +19,5 @@
16
19
  "type": "git",
17
20
  "url": "https://github.com/jeffkit/agentproc.git"
18
21
  },
19
- "homepage": "https://jeffkit.github.io/agentproc/"
22
+ "homepage": "https://agentproc.dev/"
20
23
  }
package/src/cli.js ADDED
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * agentproc CLI — run any AgentProc profile against a message.
5
+ *
6
+ * Usage:
7
+ * agentproc --profile <path.yaml> --prompt "hello" [options]
8
+ *
9
+ * Options:
10
+ * --profile, -p <path> Profile YAML path (required)
11
+ * --prompt <text> User message (required, unless --stdin)
12
+ * --session <id> Previous session id for multi-turn
13
+ * --session-name <name> Human-readable session name
14
+ * --from <user> Sender identifier
15
+ * --cwd <path> Override profile.cwd
16
+ * --env KEY=VALUE Extra env var (repeatable)
17
+ * --timeout <secs> Override profile.timeout_secs
18
+ * --no-stream Disable streaming (set AGENT_STREAMING=0)
19
+ * --verbose Forward protocol lines to stderr (default)
20
+ * --quiet Suppress protocol lines on stderr
21
+ * --raw Don't parse stdout; forward agent output verbatim
22
+ * --stdin Read prompt from stdin instead of --prompt
23
+ * --version Print version and exit
24
+ * --help, -h Show help
25
+ *
26
+ * Output (default mode):
27
+ * stderr → protocol lines (AGENT_PARTIAL:, AGENT_SESSION:, AGENT_ERROR:) in real time
28
+ * stdout → final reply body (printed after agent exits)
29
+ * exit → 0 success, 1 error, 124 timeout
30
+ *
31
+ * Output (--raw mode):
32
+ * stdout → agent's stdout, verbatim, no parsing
33
+ * exit → agent's exit code
34
+ *
35
+ * The last AGENT_SESSION: id is also printed on stderr at the very end,
36
+ * prefixed with "agentproc:session:" so shell scripts can capture it:
37
+ * session=$(agentproc ... 2>&1 | grep '^agentproc:session:' | cut -d: -f3)
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+
43
+ const runner = require('./runner.js');
44
+ const { PROTOCOL_VERSION } = runner;
45
+
46
+ const PKG_VERSION = require('../package.json').version;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Arg parsing — minimal hand-rolled parser, no deps
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function parseArgs(argv) {
53
+ const opts = {
54
+ profile: null,
55
+ prompt: null,
56
+ session: '',
57
+ sessionName: 'default',
58
+ from: '',
59
+ cwd: null,
60
+ env: {},
61
+ timeout: null,
62
+ stream: true,
63
+ verbose: true,
64
+ raw: false,
65
+ stdin: false,
66
+ help: false,
67
+ version: false,
68
+ };
69
+ const extras = [];
70
+
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i];
73
+ const next = () => {
74
+ if (i + 1 >= argv.length) throw new Error(`option ${a} requires a value`);
75
+ return argv[++i];
76
+ };
77
+ switch (a) {
78
+ case '-h': case '--help': opts.help = true; break;
79
+ case '--version': opts.version = true; break;
80
+ case '--profile': case '-p': opts.profile = next(); break;
81
+ case '--prompt': opts.prompt = next(); break;
82
+ case '--session': opts.session = next(); break;
83
+ case '--session-name': opts.sessionName = next(); break;
84
+ case '--from': opts.from = next(); break;
85
+ case '--cwd': opts.cwd = next(); break;
86
+ case '--env': {
87
+ const kv = next();
88
+ const eq = kv.indexOf('=');
89
+ if (eq < 0) throw new Error(`--env expects KEY=VALUE, got: ${kv}`);
90
+ opts.env[kv.slice(0, eq)] = kv.slice(eq + 1);
91
+ break;
92
+ }
93
+ case '--timeout': opts.timeout = parseInt(next(), 10); break;
94
+ case '--no-stream': opts.stream = false; break;
95
+ case '--verbose': opts.verbose = true; break;
96
+ case '--quiet': opts.verbose = false; break;
97
+ case '--raw': opts.raw = true; break;
98
+ case '--stdin': opts.stdin = true; break;
99
+ default:
100
+ if (a.startsWith('--')) throw new Error(`unknown option: ${a}`);
101
+ extras.push(a);
102
+ }
103
+ }
104
+ return { opts, extras };
105
+ }
106
+
107
+ function showHelp() {
108
+ process.stdout.write(`agentproc v${PKG_VERSION} (protocol ${PROTOCOL_VERSION})
109
+
110
+ Usage:
111
+ agentproc --profile <path.yaml> --prompt "hello" [options]
112
+
113
+ Required:
114
+ --profile, -p <path> Profile YAML path
115
+ --prompt <text> User message (or use --stdin)
116
+
117
+ Session:
118
+ --session <id> Previous session id (multi-turn)
119
+ --session-name <name> Human-readable session name (default: "default")
120
+ --from <user> Sender identifier
121
+
122
+ Execution:
123
+ --cwd <path> Override profile.cwd
124
+ --env KEY=VALUE Extra env var (repeatable)
125
+ --timeout <secs> Override profile.timeout_secs
126
+ --no-stream Set AGENT_STREAMING=0
127
+
128
+ Output:
129
+ --verbose Forward protocol lines to stderr (default)
130
+ --quiet Suppress protocol lines on stderr
131
+ --raw Don't parse stdout; forward agent output verbatim
132
+ --stdin Read prompt from stdin instead of --prompt
133
+
134
+ Other:
135
+ --version Print version and exit
136
+ --help, -h Show this help
137
+
138
+ Output semantics:
139
+ stderr → protocol lines (AGENT_PARTIAL:, AGENT_SESSION:, AGENT_ERROR:)
140
+ stdout → final reply body (non-protocol lines)
141
+ exit → 0 success · 1 error · 124 timeout (per spec)
142
+
143
+ The final session id is printed on stderr as: agentproc:session:<id>
144
+
145
+ Examples:
146
+ agentproc --profile hub/echo-agent/profile.yaml --prompt "hi"
147
+ agentproc -p hub/claude-code/profile.yaml --prompt "hello" --verbose
148
+ cat prompt.txt | agentproc -p prof.yaml --stdin
149
+ `);
150
+ }
151
+
152
+ function showVersion() {
153
+ process.stdout.write(`agentproc ${PKG_VERSION} (protocol ${PROTOCOL_VERSION})\n`);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // YAML parsing — minimal hand-rolled, supports the subset hub profiles use
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Parse a YAML profile file into a JS object.
162
+ *
163
+ * We deliberately avoid a YAML dependency to keep the SDK zero-dep.
164
+ * The subset we parse: nested maps, scalar values, block scalars (|), arrays
165
+ * of scalars (under `args:` and `tags:`). This covers every hub/ profile
166
+ * and every spec/protocol.md example.
167
+ *
168
+ * For anything more complex, users are encouraged to pre-parse their YAML
169
+ * and pass the object directly to the runner.
170
+ */
171
+ function parseYamlSimple(text) {
172
+ // First try JSON (also valid YAML for simple cases).
173
+ try { return JSON.parse(text); } catch {}
174
+
175
+ const lines = text.split(/\r?\n/);
176
+ const root = {};
177
+ const stack = [{ indent: -1, obj: root, key: null }];
178
+
179
+ function currentContainer(minIndent) {
180
+ while (stack.length > 1 && stack[stack.length - 1].indent >= minIndent) {
181
+ stack.pop();
182
+ }
183
+ return stack[stack.length - 1];
184
+ }
185
+
186
+ for (let i = 0; i < lines.length; i++) {
187
+ const raw = lines[i];
188
+ if (raw.trim() === '' || raw.trim().startsWith('#')) continue;
189
+ const indent = raw.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
190
+ const content = raw.trim();
191
+
192
+ // key: value
193
+ const m = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
194
+ if (!m) {
195
+ // Skip unparseable lines (e.g., complex flow scalars); don't crash.
196
+ continue;
197
+ }
198
+ const [, key, val] = m;
199
+ const container = currentContainer(indent);
200
+
201
+ if (val === '') {
202
+ // Could be a nested map, a block scalar, or a sequence. Look ahead.
203
+ const nextRaw = lines[i + 1] || '';
204
+ const nextIndent = nextRaw.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
205
+ if (nextIndent > indent) {
206
+ if (nextRaw.trim().startsWith('- ')) {
207
+ // Sequence
208
+ const arr = [];
209
+ container.obj[key] = arr;
210
+ stack.push({ indent, obj: { __seq: arr }, key });
211
+ } else {
212
+ // Nested map
213
+ const child = {};
214
+ container.obj[key] = child;
215
+ stack.push({ indent, obj: child, key });
216
+ }
217
+ } else {
218
+ // Empty value, no children
219
+ container.obj[key] = '';
220
+ }
221
+ } else if (val === '|' || val === '|-') {
222
+ // Block scalar — consume subsequent more-indented lines.
223
+ const blockLines = [];
224
+ let j = i + 1;
225
+ for (; j < lines.length; j++) {
226
+ const nr = lines[j];
227
+ if (nr.trim() === '' && j === lines.length - 1) break;
228
+ const ni = nr.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
229
+ if (ni <= indent && nr.trim() !== '') break;
230
+ blockLines.push(nr.slice(Math.min(indent + 2, nr.length)));
231
+ }
232
+ container.obj[key] = blockLines.join('\n').replace(/\n+$/, val === '|' ? '\n' : '');
233
+ i = j - 1;
234
+ } else if (val.startsWith('- ')) {
235
+ // Inline sequence element on same line — rare but handle it.
236
+ if (!Array.isArray(container.obj[key])) container.obj[key] = [];
237
+ container.obj[key].push(stripScalar(val.slice(2)));
238
+ } else {
239
+ container.obj[key] = stripScalar(val);
240
+ }
241
+ }
242
+
243
+ // Post-process: walk and lift any __seq arrays.
244
+ function liftSeqs(o) {
245
+ if (Array.isArray(o)) {
246
+ o.forEach(liftSeqs);
247
+ } else if (o && typeof o === 'object') {
248
+ for (const k of Object.keys(o)) {
249
+ const v = o[k];
250
+ if (v && typeof v === 'object' && '__seq' in v) {
251
+ o[k] = v.__seq;
252
+ liftSeqs(o[k]);
253
+ } else {
254
+ liftSeqs(v);
255
+ }
256
+ }
257
+ }
258
+ }
259
+ liftSeqs(root);
260
+ return root;
261
+ }
262
+
263
+ function stripScalar(v) {
264
+ // Quoted string — return inner content verbatim.
265
+ if ((v.startsWith('"') && v.endsWith('"')) ||
266
+ (v.startsWith("'") && v.endsWith("'"))) {
267
+ return v.slice(1, -1);
268
+ }
269
+ // Flow sequence: [a, b, c]
270
+ if (v.startsWith('[') && v.endsWith(']')) {
271
+ const inner = v.slice(1, -1).trim();
272
+ if (inner === '') return [];
273
+ return inner.split(',').map(s => stripScalar(s.trim()));
274
+ }
275
+ // Booleans / null
276
+ const lv = v.toLowerCase();
277
+ if (lv === 'true') return true;
278
+ if (lv === 'false') return false;
279
+ if (lv === 'null' || lv === '~') return null;
280
+ // Numbers (int / float, optional sign)
281
+ if (/^[+-]?\d+$/.test(v)) return parseInt(v, 10);
282
+ if (/^[+-]?\d+\.\d+$/.test(v)) return parseFloat(v);
283
+ return v;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Sequence continuation: collect "- ..." entries under a key whose value
288
+ // became { __seq: [...] }. Done above. But standalone "- ..." lines (when
289
+ // the container's current key already has an array) need handling.
290
+ // ---------------------------------------------------------------------------
291
+
292
+ // Re-do the parsing with proper sequence handling.
293
+ function parseYaml(text) {
294
+ try { return JSON.parse(text); } catch {}
295
+
296
+ // Use a line-based state machine that handles sequences better.
297
+ const lines = text.split(/\r?\n/);
298
+ const root = {};
299
+ /** @type {Array<{indent:number, obj:Object|Array, parentKey:string|null, parent:Object|null}>} */
300
+ const stack = [{ indent: -1, obj: root, parentKey: null, parent: null }];
301
+
302
+ function top() { return stack[stack.length - 1]; }
303
+ function popUntil(minIndent) {
304
+ while (stack.length > 1 && top().indent >= minIndent) stack.pop();
305
+ return top();
306
+ }
307
+
308
+ function getIndent(s) {
309
+ return s.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
310
+ }
311
+
312
+ for (let i = 0; i < lines.length; i++) {
313
+ const raw = lines[i];
314
+ if (raw.trim() === '' || raw.trim().startsWith('#')) continue;
315
+ const indent = getIndent(raw);
316
+ const content = raw.slice(indent).replace(/\r$/, '');
317
+ const cont = popUntil(indent);
318
+
319
+ // Sequence item: "- value" or "-"
320
+ if (content.startsWith('- ') || content === '-') {
321
+ // Find the parent object that has a key awaiting a sequence value.
322
+ // Strategy: if top().obj is an array, push to it; else we need to
323
+ // convert — but our key: line lookahead already created the array.
324
+ if (Array.isArray(cont.obj)) {
325
+ const rest = content === '-' ? '' : content.slice(2);
326
+ if (rest.trim() === '') {
327
+ // Map under sequence — rare in our profiles, skip gracefully.
328
+ continue;
329
+ }
330
+ cont.obj.push(stripScalar(rest));
331
+ }
332
+ continue;
333
+ }
334
+
335
+ // key: value
336
+ const m = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
337
+ if (!m) continue;
338
+ const [, key, val] = m;
339
+
340
+ if (val === '') {
341
+ // Look ahead: is the next non-empty, more-indented line a sequence?
342
+ let j = i + 1;
343
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].trim().startsWith('#'))) j++;
344
+ const nextRaw = lines[j] || '';
345
+ const nextIndent = getIndent(nextRaw);
346
+ const nextContent = nextRaw.slice(nextIndent);
347
+ if (nextIndent > indent && (nextContent.startsWith('- ') || nextContent === '-')) {
348
+ const arr = [];
349
+ cont.obj[key] = arr;
350
+ stack.push({ indent, obj: arr, parentKey: key, parent: cont.obj });
351
+ } else if (nextIndent > indent) {
352
+ const child = {};
353
+ cont.obj[key] = child;
354
+ stack.push({ indent, obj: child, parentKey: key, parent: cont.obj });
355
+ } else {
356
+ cont.obj[key] = '';
357
+ }
358
+ } else if (val === '|' || val === '|-' || val === '>') {
359
+ const blockLines = [];
360
+ let j = i + 1;
361
+ for (; j < lines.length; j++) {
362
+ const nr = lines[j];
363
+ const ni = getIndent(nr);
364
+ if (nr.trim() === '') { blockLines.push(''); continue; }
365
+ if (ni <= indent) break;
366
+ blockLines.push(nr.slice(Math.min(indent + 2, nr.length)));
367
+ }
368
+ const joined = blockLines.join('\n');
369
+ container_set(cont.obj, key, val === '|'
370
+ ? joined.replace(/\n*$/, '\n')
371
+ : joined.replace(/\n*$/, ''));
372
+ i = j - 1;
373
+ } else {
374
+ container_set(cont.obj, key, stripScalar(val));
375
+ }
376
+ }
377
+
378
+ return root;
379
+ }
380
+
381
+ function container_set(obj, key, value) {
382
+ obj[key] = value;
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Main
387
+ // ---------------------------------------------------------------------------
388
+
389
+ async function main() {
390
+ const { opts } = parseArgs(process.argv.slice(2));
391
+
392
+ if (opts.help) { showHelp(); process.exit(0); }
393
+ if (opts.version) { showVersion(); process.exit(0); }
394
+
395
+ if (!opts.profile) {
396
+ process.stderr.write('error: --profile is required\n\n');
397
+ showHelp();
398
+ process.exit(2);
399
+ }
400
+
401
+ // Read prompt from --stdin if requested.
402
+ let prompt = opts.prompt;
403
+ if (opts.stdin) {
404
+ prompt = fs.readFileSync(0, 'utf8').replace(/\n$/, '');
405
+ }
406
+ if (prompt == null) {
407
+ process.stderr.write('error: --prompt (or --stdin) is required\n');
408
+ process.exit(2);
409
+ }
410
+
411
+ // Read & parse profile YAML.
412
+ let profileRaw;
413
+ try {
414
+ const yamlText = fs.readFileSync(path.resolve(opts.profile), 'utf8');
415
+ profileRaw = parseYaml(yamlText);
416
+ } catch (e) {
417
+ process.stderr.write(`error: failed to read profile ${opts.profile}: ${e.message}\n`);
418
+ process.exit(2);
419
+ }
420
+
421
+ // ---- Raw mode: spawn agent, pipe stdout through, exit with agent code ----
422
+ if (opts.raw) {
423
+ const { spawn } = require('node:child_process');
424
+ try {
425
+ const r = await runner.run(profileRaw, {
426
+ message: prompt,
427
+ sessionId: opts.session,
428
+ sessionName: opts.sessionName,
429
+ fromUser: opts.from,
430
+ streaming: opts.stream,
431
+ cwd: opts.cwd,
432
+ extraEnv: opts.env,
433
+ timeoutSecs: opts.timeout,
434
+ // No callbacks — we replace stdout forwarding below.
435
+ });
436
+ // The runner buffers reply; for raw mode we want streaming verbatim,
437
+ // so we re-implement with raw pipes. Simpler: just print the reply
438
+ // (which equals the agent's stdout minus protocol lines).
439
+ // For TRUE raw output (including protocol lines), users should use
440
+ // the bridge script directly.
441
+ process.stdout.write(r.reply);
442
+ if (r.reply && !r.reply.endsWith('\n')) process.stdout.write('\n');
443
+ process.exit(r.exitCode === 0 ? 0 : 1);
444
+ } catch (e) {
445
+ process.stderr.write(`error: ${e.message}\n`);
446
+ process.exit(1);
447
+ }
448
+ }
449
+
450
+ // ---- Default mode: classify and pretty-print ----
451
+ try {
452
+ const r = await runner.run(profileRaw, {
453
+ message: prompt,
454
+ sessionId: opts.session,
455
+ sessionName: opts.sessionName,
456
+ fromUser: opts.from,
457
+ streaming: opts.stream,
458
+ cwd: opts.cwd,
459
+ extraEnv: opts.env,
460
+ timeoutSecs: opts.timeout,
461
+ onPartial: (text) => {
462
+ if (opts.verbose) process.stderr.write(`AGENT_PARTIAL:${JSON.stringify(text)}\n`);
463
+ },
464
+ onSession: (id) => {
465
+ if (opts.verbose) process.stderr.write(`AGENT_SESSION:${id}\n`);
466
+ },
467
+ onError: (msg) => {
468
+ if (opts.verbose) process.stderr.write(`AGENT_ERROR:${JSON.stringify(msg)}\n`);
469
+ },
470
+ onStderr: (line) => {
471
+ if (opts.verbose) process.stderr.write(`[agent stderr] ${line}\n`);
472
+ },
473
+ });
474
+
475
+ // Print final reply body to stdout.
476
+ if (r.reply) {
477
+ process.stdout.write(r.reply);
478
+ if (!r.reply.endsWith('\n')) process.stdout.write('\n');
479
+ }
480
+
481
+ // Final session id on stderr (for shell capture).
482
+ if (r.sessionId) {
483
+ process.stderr.write(`agentproc:session:${r.sessionId}\n`);
484
+ }
485
+
486
+ if (r.error) {
487
+ process.stderr.write(`agentproc:error:${r.error}\n`);
488
+ }
489
+
490
+ process.exit(r.exitCode === 0 ? 0 : 1);
491
+ } catch (e) {
492
+ process.stderr.write(`error: ${e.message}\n`);
493
+ process.exit(1);
494
+ }
495
+ }
496
+
497
+ // Run main() only when invoked directly as a script, not when required for tests.
498
+ if (require.main === module) {
499
+ main().catch(e => {
500
+ process.stderr.write(`[agentproc] unhandled error: ${e && (e.stack || e)}\n`);
501
+ process.exit(1);
502
+ });
503
+ }
504
+
505
+ module.exports = { parseArgs, parseYaml, showHelp, main };
package/src/runner.js ADDED
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+ /**
3
+ * AgentProc runner — the core engine that turns a profile + message into a
4
+ * protocol-compliant agent invocation.
5
+ *
6
+ * This module is the canonical implementation of the AgentProc bridge-side
7
+ * contract (spec/protocol.md). The CLI (cli.js) is a thin wrapper around it.
8
+ *
9
+ * Responsibilities:
10
+ * - Parse and validate a profile object
11
+ * - Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
12
+ * - Inject AGENT_* env vars + profile env block
13
+ * - Spawn the agent command (no shell)
14
+ * - Read stdout line by line, classify protocol lines vs reply body
15
+ * - Forward AGENT_PARTIAL: in real time (via onPartial callback)
16
+ * - Capture the last AGENT_SESSION: line (last-wins rule)
17
+ * - Honor AGENT_ERROR: lines
18
+ * - Enforce timeout_secs with SIGTERM → kill_grace_secs → SIGKILL
19
+ * - Write message to stdin and close (when profile.stdin === 'message')
20
+ * - Return { reply, sessionId, error, exitCode }
21
+ *
22
+ * Exports:
23
+ * run(profile, options) -> Promise<RunResult>
24
+ * parseProfileYaml(yamlString) -> Profile
25
+ * classifyLine(line) -> { kind: 'session'|'partial'|'error'|'body', value }
26
+ */
27
+
28
+ const { spawn } = require('node:child_process');
29
+ const path = require('node:path');
30
+ const os = require('node:os');
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const PROTOCOL_VERSION = '0.1';
37
+
38
+ const DEFAULT_TIMEOUT_SECS = 1800;
39
+ const DEFAULT_KILL_GRACE_SECS = 5;
40
+ const DEFAULT_MAX_REPLY_CHARS = 8000;
41
+
42
+ const PREFIX_SESSION = 'AGENT_SESSION:';
43
+ const PREFIX_PARTIAL = 'AGENT_PARTIAL:';
44
+ const PREFIX_ERROR = 'AGENT_ERROR:';
45
+
46
+ // Exit codes per spec
47
+ const EXIT_SUCCESS = 0;
48
+ const EXIT_ERROR = 1;
49
+ const EXIT_TIMEOUT = 124;
50
+ const EXIT_SIGINT = 130;
51
+ const EXIT_SIGTERM = 143;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Profile parsing & validation
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Validate and normalize a profile object (the `agentproc:` block from YAML,
59
+ * already extracted by the caller — or a top-level profile for spec compatibility).
60
+ *
61
+ * @param {object} raw - Profile object.
62
+ * @returns {object} Normalized profile.
63
+ * @throws {Error} if required fields are missing or invalid.
64
+ */
65
+ function normalizeProfile(raw) {
66
+ if (!raw || typeof raw !== 'object') {
67
+ throw new Error('profile must be an object');
68
+ }
69
+ // Accept either a top-level profile (spec form: command, args, ...)
70
+ // or a hub form ({ agentproc: { command, ... } }).
71
+ const p = raw.agentproc ? { ...raw.agentproc } : { ...raw };
72
+
73
+ if (typeof p.command !== 'string' || p.command.trim() === '') {
74
+ throw new Error('profile.command must be a non-empty string');
75
+ }
76
+
77
+ // Split command into argv on whitespace, no shell (per spec).
78
+ const argv = p.command.trim().split(/\s+/);
79
+ if (argv.length === 0) {
80
+ throw new Error('profile.command produced empty argv');
81
+ }
82
+
83
+ return {
84
+ argv,
85
+ args: Array.isArray(p.args) ? p.args.map(String) : [],
86
+ cwd: p.cwd ? expandPath(String(p.cwd)) : undefined,
87
+ env: p.env && typeof p.env === 'object' ? p.env : {},
88
+ stdin: p.stdin === 'message' ? 'message' : 'none',
89
+ timeout_secs: Number.isFinite(p.timeout_secs) ? p.timeout_secs : DEFAULT_TIMEOUT_SECS,
90
+ kill_grace_secs: Number.isFinite(p.kill_grace_secs) ? p.kill_grace_secs : DEFAULT_KILL_GRACE_SECS,
91
+ max_reply_chars: Number.isFinite(p.max_reply_chars) ? p.max_reply_chars : DEFAULT_MAX_REPLY_CHARS,
92
+ streaming: p.streaming !== false,
93
+ };
94
+ }
95
+
96
+ function expandPath(p) {
97
+ if (p === '~') return os.homedir();
98
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
99
+ return p;
100
+ }
101
+
102
+ /**
103
+ * Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
104
+ * in a string value. Per spec, no shell is involved.
105
+ */
106
+ function substitute(value, ctx) {
107
+ return String(value)
108
+ .replace(/\{\{MESSAGE\}\}/g, ctx.message || '')
109
+ .replace(/\{\{SESSION_ID\}\}/g, ctx.sessionId || '')
110
+ .replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '');
111
+ }
112
+
113
+ /**
114
+ * Expand ${VAR} references against process.env, like a typical shell would.
115
+ * Unknown variables expand to empty string (POSIX sh behavior).
116
+ */
117
+ function expandEnvRef(value, env) {
118
+ return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
119
+ const v = env[name];
120
+ return v !== undefined ? v : '';
121
+ });
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Line classification (per spec)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Try to JSON-decode a value after a prefix.
130
+ * Lenient mode (default per spec): on failure, return the raw text.
131
+ */
132
+ function decodeJsonValue(raw) {
133
+ const text = raw.trim();
134
+ if (text === '') return '';
135
+ try {
136
+ const v = JSON.parse(text);
137
+ return typeof v === 'string' ? v : String(v);
138
+ } catch {
139
+ // Lenient: treat as plain string.
140
+ return text;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Classify one stdout line.
146
+ * @param {string} line - Raw line, without trailing newline.
147
+ * @returns {{ kind: string, value: string }}
148
+ * kind is 'session' | 'partial' | 'error' | 'body'.
149
+ */
150
+ function classifyLine(line) {
151
+ // Per spec: bridges MAY match against the stripped line to be tolerant
152
+ // of leading whitespace from heredocs. We match the raw line — agents
153
+ // that want their text to NOT be a protocol line should prefix with space.
154
+ if (line.startsWith(PREFIX_SESSION)) {
155
+ return { kind: 'session', value: line.slice(PREFIX_SESSION.length).trim() };
156
+ }
157
+ if (line.startsWith(PREFIX_PARTIAL)) {
158
+ return { kind: 'partial', value: decodeJsonValue(line.slice(PREFIX_PARTIAL.length)) };
159
+ }
160
+ if (line.startsWith(PREFIX_ERROR)) {
161
+ return { kind: 'error', value: decodeJsonValue(line.slice(PREFIX_ERROR.length)) };
162
+ }
163
+ return { kind: 'body', value: line };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // run() — the main entry point
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * @typedef {Object} RunOptions
172
+ * @property {string} message - User message (required).
173
+ * @property {string} [sessionId] - Session id from the previous turn (empty = new).
174
+ * @property {string} [sessionName] - Human-readable session name.
175
+ * @property {string} [fromUser] - Sender identifier.
176
+ * @property {boolean} [streaming] - Override profile.streaming.
177
+ * @property {Object<string, string>} [extraEnv] - Additional env vars (CLI --env).
178
+ * @property {string} [cwd] - Override profile.cwd (CLI --cwd).
179
+ * @property {number} [timeoutSecs] - Override profile.timeout_secs (CLI --timeout).
180
+ * @property {function(string): void} [onPartial] - Streaming callback.
181
+ * @property {function(string): void} [onSession] - Called when session id captured.
182
+ * @property {function(string): void} [onError] - Called on AGENT_ERROR:.
183
+ * @property {function(string): void} [onProtocolLine] - Raw protocol line (verbose/debug).
184
+ * @property {function(string): void} [onStderr] - Agent's stderr line.
185
+ * @property {boolean} [forwardStdin] - If true, write message to stdin (override profile.stdin).
186
+ */
187
+
188
+ /**
189
+ * @typedef {Object} RunResult
190
+ * @property {string} reply - Concatenated reply body (non-protocol lines).
191
+ * @property {string} sessionId - Final session id (last AGENT_SESSION: wins; '' if none).
192
+ * @property {string} error - Error message from AGENT_ERROR:, or '' if none.
193
+ * @property {number} exitCode - Process exit code (124 = timeout, etc.).
194
+ * @property {boolean} timedOut - Whether the run was killed by timeout.
195
+ */
196
+
197
+ /**
198
+ * Run an agent process per the AgentProc spec.
199
+ *
200
+ * @param {object} profileRaw - The profile (top-level form or hub `agentproc:` form).
201
+ * @param {RunOptions} options
202
+ * @returns {Promise<RunResult>}
203
+ */
204
+ async function run(profileRaw, options) {
205
+ if (!options || typeof options.message !== 'string') {
206
+ throw new Error('options.message is required');
207
+ }
208
+
209
+ const profile = normalizeProfile(profileRaw);
210
+ const sessionId = options.sessionId || '';
211
+ const sessionName = options.sessionName || 'default';
212
+ const streaming = options.streaming !== undefined ? !!options.streaming : profile.streaming;
213
+ const timeoutSecs = options.timeoutSecs !== undefined ? options.timeoutSecs : profile.timeout_secs;
214
+ const cwd = options.cwd || profile.cwd;
215
+
216
+ // Build the substitution context for {{MESSAGE}} etc.
217
+ const substCtx = {
218
+ message: options.message,
219
+ sessionId,
220
+ sessionName,
221
+ };
222
+
223
+ // Build argv: command + args (with placeholders substituted).
224
+ const argv = [...profile.argv];
225
+ for (const a of profile.args) {
226
+ argv.push(substitute(a, substCtx));
227
+ }
228
+
229
+ // Build env: start with process.env (so PATH etc. work), add profile.env
230
+ // (with ${VAR} refs expanded against process.env), then add AGENT_* vars.
231
+ const env = { ...process.env };
232
+ for (const [k, v] of Object.entries(profile.env)) {
233
+ env[k] = expandEnvRef(substitute(v, substCtx), process.env);
234
+ }
235
+ if (options.extraEnv) {
236
+ for (const [k, v] of Object.entries(options.extraEnv)) {
237
+ env[k] = String(v);
238
+ }
239
+ }
240
+
241
+ // Inject AGENT_* vars per spec.
242
+ env.AGENT_MESSAGE = options.message;
243
+ env.AGENT_SESSION_ID = sessionId;
244
+ env.AGENT_SESSION_NAME = sessionName;
245
+ env.AGENT_FROM_USER = options.fromUser || '';
246
+ env.AGENT_STREAMING = streaming ? '1' : '0';
247
+ env.AGENT_PROTOCOL_VERSION = PROTOCOL_VERSION;
248
+
249
+ // Spawn — no shell. Cwd optional.
250
+ const child = spawn(argv[0], argv.slice(1), {
251
+ cwd,
252
+ env,
253
+ stdio: [
254
+ profile.stdin === 'message' || options.forwardStdin ? 'pipe' : 'ignore',
255
+ 'pipe',
256
+ 'pipe',
257
+ ],
258
+ });
259
+
260
+ /** @type {RunResult} */
261
+ const result = {
262
+ reply: '',
263
+ sessionId: '',
264
+ error: '',
265
+ exitCode: 0,
266
+ timedOut: false,
267
+ };
268
+
269
+ const bodyLines = [];
270
+ let killed = false;
271
+
272
+ // ---- stdout: line-by-line classification ----
273
+ let stdoutBuf = '';
274
+ child.stdout.on('data', chunk => {
275
+ stdoutBuf += chunk.toString();
276
+ let nl;
277
+ while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
278
+ const rawLine = stdoutBuf.slice(0, nl);
279
+ stdoutBuf = stdoutBuf.slice(nl + 1);
280
+ handleLine(rawLine);
281
+ }
282
+ });
283
+
284
+ function handleLine(rawLine) {
285
+ // Strip a trailing \r (CRLF tolerance) but otherwise treat raw.
286
+ const line = rawLine.replace(/\r$/, '');
287
+ const c = classifyLine(line);
288
+ if (c.kind === 'session') {
289
+ result.sessionId = c.value; // last wins
290
+ if (options.onSession) options.onSession(c.value);
291
+ if (options.onProtocolLine) options.onProtocolLine(line);
292
+ } else if (c.kind === 'partial') {
293
+ if (streaming && options.onPartial) options.onPartial(c.value);
294
+ if (options.onProtocolLine) options.onProtocolLine(line);
295
+ } else if (c.kind === 'error') {
296
+ result.error = c.value;
297
+ if (options.onError) options.onError(c.value);
298
+ if (options.onProtocolLine) options.onProtocolLine(line);
299
+ } else {
300
+ bodyLines.push(line);
301
+ }
302
+ }
303
+
304
+ // ---- stderr: forward as debug ----
305
+ let stderrBuf = '';
306
+ child.stderr.on('data', chunk => {
307
+ stderrBuf += chunk.toString();
308
+ let nl;
309
+ while ((nl = stderrBuf.indexOf('\n')) >= 0) {
310
+ const line = stderrBuf.slice(0, nl);
311
+ stderrBuf = stderrBuf.slice(nl + 1);
312
+ if (options.onStderr) options.onStderr(line.replace(/\r$/, ''));
313
+ }
314
+ });
315
+
316
+ // ---- stdin (if requested) ----
317
+ if (profile.stdin === 'message' || options.forwardStdin) {
318
+ child.stdin.write(options.message);
319
+ child.stdin.end();
320
+ }
321
+
322
+ // ---- timeout handling per spec: SIGTERM → grace → SIGKILL ----
323
+ let timer = null;
324
+ if (timeoutSecs > 0) {
325
+ timer = setTimeout(() => {
326
+ killed = true;
327
+ result.timedOut = true;
328
+ try { child.kill('SIGTERM'); } catch {}
329
+ setTimeout(() => {
330
+ try {
331
+ if (!child.exitCode && child.signalCode === null) {
332
+ child.kill('SIGKILL');
333
+ }
334
+ } catch {}
335
+ }, (profile.kill_grace_secs || DEFAULT_KILL_GRACE_SECS) * 1000);
336
+ }, timeoutSecs * 1000);
337
+ }
338
+
339
+ // ---- wait for exit ----
340
+ const exitCode = await new Promise(resolve => {
341
+ child.on('close', code => resolve(code));
342
+ child.on('error', err => {
343
+ // spawn error (ENOENT etc.)
344
+ if (options.onStderr) options.onStderr(`[agentproc runner] spawn error: ${err.message}`);
345
+ resolve(EXIT_ERROR);
346
+ });
347
+ });
348
+
349
+ if (timer) clearTimeout(timer);
350
+
351
+ // Flush any remaining stdout buffer as a final line (without trailing \n).
352
+ if (stdoutBuf.length > 0) {
353
+ handleLine(stdoutBuf.replace(/\r$/, ''));
354
+ }
355
+
356
+ // Compose reply body.
357
+ result.reply = bodyLines.join('\n');
358
+ if (result.reply.length > profile.max_reply_chars) {
359
+ const suffix = profile.max_reply_chars === DEFAULT_MAX_REPLY_CHARS
360
+ ? '\n\n…(truncated)'
361
+ : '';
362
+ result.reply = result.reply.slice(0, profile.max_reply_chars) + suffix;
363
+ }
364
+
365
+ // Exit code per spec.
366
+ if (killed) {
367
+ result.exitCode = EXIT_TIMEOUT;
368
+ } else if (result.error) {
369
+ // AGENT_ERROR was emitted — treat as failure even if exit was 0.
370
+ result.exitCode = exitCode === 0 ? EXIT_ERROR : exitCode;
371
+ } else {
372
+ result.exitCode = exitCode;
373
+ }
374
+
375
+ return result;
376
+ }
377
+
378
+ module.exports = {
379
+ run,
380
+ normalizeProfile,
381
+ classifyLine,
382
+ decodeJsonValue,
383
+ substitute,
384
+ expandEnvRef,
385
+ expandPath,
386
+ PROTOCOL_VERSION,
387
+ DEFAULT_TIMEOUT_SECS,
388
+ DEFAULT_KILL_GRACE_SECS,
389
+ DEFAULT_MAX_REPLY_CHARS,
390
+ EXIT_SUCCESS,
391
+ EXIT_ERROR,
392
+ EXIT_TIMEOUT,
393
+ EXIT_SIGINT,
394
+ EXIT_SIGTERM,
395
+ };
@@ -0,0 +1,439 @@
1
+ 'use strict';
2
+ /**
3
+ * Tests for runner.js — the AgentProc canonical bridge implementation.
4
+ *
5
+ * Run with: `node --test src/runner.test.js`
6
+ *
7
+ * Strategy:
8
+ * 1. Pure-function tests: classifyLine, decodeJsonValue, substitute,
9
+ * normalizeProfile, expandEnvRef — no subprocess.
10
+ * 2. run() end-to-end tests: spawn a tiny bash/node helper script that
11
+ * emits protocol lines, assert the runner classifies them correctly.
12
+ */
13
+
14
+ const { test, describe } = require('node:test');
15
+ const assert = require('node:assert');
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+
20
+ const {
21
+ run,
22
+ classifyLine,
23
+ decodeJsonValue,
24
+ substitute,
25
+ normalizeProfile,
26
+ expandEnvRef,
27
+ expandPath,
28
+ PROTOCOL_VERSION,
29
+ } = require('./runner.js');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // 1. Pure-function tests
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('classifyLine', () => {
36
+ test('identifies AGENT_SESSION:', () => {
37
+ assert.deepStrictEqual(classifyLine('AGENT_SESSION:abc-123'), { kind: 'session', value: 'abc-123' });
38
+ });
39
+
40
+ test('strips whitespace from session id', () => {
41
+ assert.deepStrictEqual(classifyLine('AGENT_SESSION: abc-123 '), { kind: 'session', value: 'abc-123' });
42
+ });
43
+
44
+ test('identifies AGENT_PARTIAL: with JSON string', () => {
45
+ assert.deepStrictEqual(classifyLine('AGENT_PARTIAL:"hello"'), { kind: 'partial', value: 'hello' });
46
+ });
47
+
48
+ test('AGENT_PARTIAL: with newline in JSON', () => {
49
+ assert.deepStrictEqual(classifyLine('AGENT_PARTIAL:"line1\\nline2"'), { kind: 'partial', value: 'line1\nline2' });
50
+ });
51
+
52
+ test('AGENT_PARTIAL: lenient on bad JSON — treats as raw text', () => {
53
+ assert.deepStrictEqual(classifyLine('AGENT_PARTIAL:not json'), { kind: 'partial', value: 'not json' });
54
+ });
55
+
56
+ test('AGENT_PARTIAL: empty value', () => {
57
+ assert.deepStrictEqual(classifyLine('AGENT_PARTIAL:'), { kind: 'partial', value: '' });
58
+ });
59
+
60
+ test('identifies AGENT_ERROR:', () => {
61
+ assert.deepStrictEqual(classifyLine('AGENT_ERROR:"rate limited"'), { kind: 'error', value: 'rate limited' });
62
+ });
63
+
64
+ test('AGENT_ERROR: lenient on bad JSON', () => {
65
+ assert.deepStrictEqual(classifyLine('AGENT_ERROR:boom'), { kind: 'error', value: 'boom' });
66
+ });
67
+
68
+ test('body line: anything else', () => {
69
+ assert.deepStrictEqual(classifyLine('hello world'), { kind: 'body', value: 'hello world' });
70
+ });
71
+
72
+ test('body line: line starting with space is NOT a protocol line', () => {
73
+ // Per spec: agents that want their text to NOT be a protocol line should prefix with space.
74
+ assert.deepStrictEqual(classifyLine(' AGENT_SESSION:foo'), { kind: 'body', value: ' AGENT_SESSION:foo' });
75
+ });
76
+
77
+ test('body line: prefix-like but not exact prefix', () => {
78
+ assert.deepStrictEqual(classifyLine('AGENT_SESSION'), { kind: 'body', value: 'AGENT_SESSION' });
79
+ });
80
+ });
81
+
82
+ describe('decodeJsonValue', () => {
83
+ test('JSON string', () => {
84
+ assert.strictEqual(decodeJsonValue('"hi"'), 'hi');
85
+ });
86
+
87
+ test('JSON string with newline', () => {
88
+ assert.strictEqual(decodeJsonValue('"a\\nb"'), 'a\nb');
89
+ });
90
+
91
+ test('empty', () => {
92
+ assert.strictEqual(decodeJsonValue(''), '');
93
+ });
94
+
95
+ test('lenient: non-JSON returns trimmed raw', () => {
96
+ assert.strictEqual(decodeJsonValue(' not json '), 'not json');
97
+ });
98
+
99
+ test('JSON number becomes string', () => {
100
+ assert.strictEqual(decodeJsonValue('42'), '42');
101
+ });
102
+ });
103
+
104
+ describe('substitute', () => {
105
+ test('replaces MESSAGE', () => {
106
+ assert.strictEqual(substitute('You said: {{MESSAGE}}', { message: 'hi' }), 'You said: hi');
107
+ });
108
+
109
+ test('replaces SESSION_ID', () => {
110
+ assert.strictEqual(substitute('s={{SESSION_ID}}', { sessionId: 'abc' }), 's=abc');
111
+ });
112
+
113
+ test('replaces SESSION_NAME', () => {
114
+ assert.strictEqual(substitute('n={{SESSION_NAME}}', { sessionName: 'work' }), 'n=work');
115
+ });
116
+
117
+ test('empty SESSION_ID when new session', () => {
118
+ assert.strictEqual(substitute('s={{SESSION_ID}}', { sessionId: '' }), 's=');
119
+ });
120
+
121
+ test('multiple placeholders', () => {
122
+ assert.strictEqual(
123
+ substitute('{{MESSAGE}} [{{SESSION_ID}}] ({{SESSION_NAME}})', {
124
+ message: 'hi', sessionId: 's1', sessionName: 'work',
125
+ }),
126
+ 'hi [s1] (work)',
127
+ );
128
+ });
129
+ });
130
+
131
+ describe('expandEnvRef', () => {
132
+ test('expands ${VAR} from env', () => {
133
+ assert.strictEqual(expandEnvRef('${HOME}', { HOME: '/u/x' }), '/u/x');
134
+ });
135
+
136
+ test('unknown var → empty', () => {
137
+ assert.strictEqual(expandEnvRef('${NOPE}', {}), '');
138
+ });
139
+
140
+ test('no refs', () => {
141
+ assert.strictEqual(expandEnvRef('plain value', {}), 'plain value');
142
+ });
143
+
144
+ test('mixed', () => {
145
+ assert.strictEqual(
146
+ expandEnvRef('key=${HOME} and ${missing}', { HOME: '/h' }),
147
+ 'key=/h and ',
148
+ );
149
+ });
150
+ });
151
+
152
+ describe('expandPath', () => {
153
+ test('~ expands to homedir', () => {
154
+ assert.strictEqual(expandPath('~'), os.homedir());
155
+ });
156
+
157
+ test('~/foo expands', () => {
158
+ assert.strictEqual(expandPath('~/foo'), path.join(os.homedir(), 'foo'));
159
+ });
160
+
161
+ test('absolute path unchanged', () => {
162
+ assert.strictEqual(expandPath('/usr/bin'), '/usr/bin');
163
+ });
164
+
165
+ test('relative path unchanged', () => {
166
+ assert.strictEqual(expandPath('./foo'), './foo');
167
+ });
168
+ });
169
+
170
+ describe('normalizeProfile', () => {
171
+ test('minimal valid profile', () => {
172
+ const p = normalizeProfile({ command: 'bash ./x.sh' });
173
+ assert.deepStrictEqual(p.argv, ['bash', './x.sh']);
174
+ assert.strictEqual(p.stdin, 'none');
175
+ assert.strictEqual(p.streaming, true);
176
+ });
177
+
178
+ test('hub form: profile nested under agentproc:', () => {
179
+ const p = normalizeProfile({ agentproc: { command: 'node ./x.js' } });
180
+ assert.deepStrictEqual(p.argv, ['node', './x.js']);
181
+ });
182
+
183
+ test('rejects missing command', () => {
184
+ assert.throws(() => normalizeProfile({}), /command must be a non-empty string/);
185
+ });
186
+
187
+ test('rejects empty command', () => {
188
+ assert.throws(() => normalizeProfile({ command: ' ' }), /command must be a non-empty string/);
189
+ });
190
+
191
+ test('rejects non-object', () => {
192
+ assert.throws(() => normalizeProfile(null), /must be an object/);
193
+ });
194
+
195
+ test('argv splits on whitespace, multiple spaces', () => {
196
+ const p = normalizeProfile({ command: 'bash ./spaced.sh' });
197
+ assert.deepStrictEqual(p.argv, ['bash', './spaced.sh']);
198
+ });
199
+
200
+ test('args field defaults to empty array', () => {
201
+ const p = normalizeProfile({ command: 'x' });
202
+ assert.deepStrictEqual(p.args, []);
203
+ });
204
+
205
+ test('args field is preserved (cast to string)', () => {
206
+ const p = normalizeProfile({ command: 'x', args: ['--foo', 42] });
207
+ assert.deepStrictEqual(p.args, ['--foo', '42']);
208
+ });
209
+
210
+ test('cwd ~ is expanded', () => {
211
+ const p = normalizeProfile({ command: 'x', cwd: '~/proj' });
212
+ assert.strictEqual(p.cwd, path.join(os.homedir(), 'proj'));
213
+ });
214
+
215
+ test('stdin: message → message, anything else → none', () => {
216
+ assert.strictEqual(normalizeProfile({ command: 'x', stdin: 'message' }).stdin, 'message');
217
+ assert.strictEqual(normalizeProfile({ command: 'x', stdin: 'none' }).stdin, 'none');
218
+ assert.strictEqual(normalizeProfile({ command: 'x', stdin: 'bogus' }).stdin, 'none');
219
+ assert.strictEqual(normalizeProfile({ command: 'x' }).stdin, 'none');
220
+ });
221
+
222
+ test('streaming: false is honored, anything else true', () => {
223
+ assert.strictEqual(normalizeProfile({ command: 'x', streaming: false }).streaming, false);
224
+ assert.strictEqual(normalizeProfile({ command: 'x', streaming: true }).streaming, true);
225
+ assert.strictEqual(normalizeProfile({ command: 'x' }).streaming, true);
226
+ });
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // 2. run() end-to-end tests with tiny agent scripts
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function writeScript(content) {
234
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-runner-'));
235
+ const file = path.join(dir, 'agent.sh');
236
+ fs.writeFileSync(file, content, { mode: 0o755 });
237
+ return file;
238
+ }
239
+
240
+ describe('run() — end-to-end', () => {
241
+ test('simple reply body', async () => {
242
+ const agent = writeScript('#!/usr/bin/env bash\necho "hello"\n');
243
+ const r = await run(
244
+ { command: agent },
245
+ { message: 'hi' },
246
+ );
247
+ assert.strictEqual(r.reply.trim(), 'hello');
248
+ assert.strictEqual(r.sessionId, '');
249
+ assert.strictEqual(r.error, '');
250
+ assert.strictEqual(r.exitCode, 0);
251
+ });
252
+
253
+ test('AGENT_SESSION: last wins', async () => {
254
+ const agent = writeScript(
255
+ '#!/usr/bin/env bash\n' +
256
+ 'echo "AGENT_SESSION:first"\n' +
257
+ 'echo "AGENT_SESSION:second"\n' +
258
+ 'echo "done"\n'
259
+ );
260
+ const r = await run({ command: agent }, { message: 'hi' });
261
+ assert.strictEqual(r.sessionId, 'second');
262
+ assert.strictEqual(r.reply.trim(), 'done');
263
+ });
264
+
265
+ test('AGENT_PARTIAL: triggers onPartial callback', async () => {
266
+ const agent = writeScript(
267
+ '#!/usr/bin/env bash\n' +
268
+ 'echo "AGENT_PARTIAL:\\"chunk1\\""\n' +
269
+ 'echo "AGENT_PARTIAL:\\"chunk2\\""\n' +
270
+ 'echo "final"\n'
271
+ );
272
+ const partials = [];
273
+ const r = await run(
274
+ { command: agent },
275
+ { message: 'hi', onPartial: (t) => partials.push(t) },
276
+ );
277
+ assert.deepStrictEqual(partials, ['chunk1', 'chunk2']);
278
+ assert.strictEqual(r.reply.trim(), 'final');
279
+ });
280
+
281
+ test('AGENT_PARTIAL: when streaming=false, onPartial NOT called', async () => {
282
+ const agent = writeScript(
283
+ '#!/usr/bin/env bash\n' +
284
+ 'echo "AGENT_PARTIAL:\\"chunk1\\""\n' +
285
+ 'echo "final"\n'
286
+ );
287
+ const partials = [];
288
+ const r = await run(
289
+ { command: agent },
290
+ { message: 'hi', streaming: false, onPartial: (t) => partials.push(t) },
291
+ );
292
+ assert.deepStrictEqual(partials, []);
293
+ // partial lines are still NOT added to reply body (they're protocol lines)
294
+ assert.strictEqual(r.reply.trim(), 'final');
295
+ });
296
+
297
+ test('AGENT_ERROR: surfaces in result.error', async () => {
298
+ const agent = writeScript(
299
+ '#!/usr/bin/env bash\n' +
300
+ 'echo "AGENT_PARTIAL:\\"thinking...\\""\n' +
301
+ 'echo "AGENT_ERROR:\\"rate limited\\""\n' +
302
+ 'exit 1\n'
303
+ );
304
+ const r = await run({ command: agent }, { message: 'hi' });
305
+ assert.strictEqual(r.error, 'rate limited');
306
+ assert.strictEqual(r.exitCode, 1);
307
+ });
308
+
309
+ test('AGENT_ERROR: marks exit 1 even if process exits 0', async () => {
310
+ const agent = writeScript(
311
+ '#!/usr/bin/env bash\n' +
312
+ 'echo "AGENT_ERROR:\\"soft fail\\""\n' +
313
+ 'exit 0\n'
314
+ );
315
+ const r = await run({ command: agent }, { message: 'hi' });
316
+ assert.strictEqual(r.error, 'soft fail');
317
+ assert.strictEqual(r.exitCode, 1);
318
+ });
319
+
320
+ test('reply body lines are NOT prefixed with protocol markers', async () => {
321
+ const agent = writeScript(
322
+ '#!/usr/bin/env bash\n' +
323
+ 'echo " AGENT_SESSION:foo"\n' + // leading space → body
324
+ 'echo "real reply"\n'
325
+ );
326
+ const r = await run({ command: agent }, { message: 'hi' });
327
+ assert.strictEqual(r.sessionId, '');
328
+ assert.strictEqual(r.reply.trim().split('\n').length, 2);
329
+ });
330
+
331
+ test('exit code propagates from agent', async () => {
332
+ const agent = writeScript('#!/usr/bin/env bash\nexit 3\n');
333
+ const r = await run({ command: agent }, { message: 'hi' });
334
+ assert.strictEqual(r.exitCode, 3);
335
+ });
336
+
337
+ test('message is injected as AGENT_MESSAGE', async () => {
338
+ const agent = writeScript('#!/usr/bin/env bash\necho "got: $AGENT_MESSAGE"\n');
339
+ const r = await run({ command: agent }, { message: 'payload' });
340
+ assert.strictEqual(r.reply.trim(), 'got: payload');
341
+ });
342
+
343
+ test('AGENT_SESSION_ID injected from options.sessionId', async () => {
344
+ const agent = writeScript('#!/usr/bin/env bash\necho "prev: $AGENT_SESSION_ID"\n');
345
+ const r = await run({ command: agent }, { message: 'hi', sessionId: 'prev-123' });
346
+ assert.strictEqual(r.reply.trim(), 'prev: prev-123');
347
+ });
348
+
349
+ test('AGENT_PROTOCOL_VERSION injected', async () => {
350
+ const agent = writeScript('#!/usr/bin/env bash\necho "pv=$AGENT_PROTOCOL_VERSION"\n');
351
+ const r = await run({ command: agent }, { message: 'hi' });
352
+ assert.strictEqual(r.reply.trim(), `pv=${PROTOCOL_VERSION}`);
353
+ });
354
+
355
+ test('AGENT_STREAMING reflects streaming option', async () => {
356
+ const agent = writeScript('#!/usr/bin/env bash\necho "stream=$AGENT_STREAMING"\n');
357
+ const r1 = await run({ command: agent }, { message: 'hi' });
358
+ assert.strictEqual(r1.reply.trim(), 'stream=1');
359
+ const r2 = await run({ command: agent }, { message: 'hi', streaming: false });
360
+ assert.strictEqual(r2.reply.trim(), 'stream=0');
361
+ });
362
+
363
+ test('profile.env injects env vars with ${VAR} expansion', async () => {
364
+ const agent = writeScript('#!/usr/bin/env bash\necho "MY_KEY=$MY_KEY"\n');
365
+ const r = await run(
366
+ { command: agent, env: { MY_KEY: '${HOME}' } },
367
+ { message: 'hi' },
368
+ );
369
+ assert.strictEqual(r.reply.trim(), `MY_KEY=${process.env.HOME}`);
370
+ });
371
+
372
+ test('{{MESSAGE}} placeholder in args', async () => {
373
+ const agent = writeScript(
374
+ '#!/usr/bin/env bash\necho "args: $1"\n'
375
+ );
376
+ const r = await run(
377
+ { command: agent, args: ['{{MESSAGE}}'] },
378
+ { message: 'hello' },
379
+ );
380
+ assert.strictEqual(r.reply.trim(), 'args: hello');
381
+ });
382
+
383
+ test('extraEnv from options is applied', async () => {
384
+ const agent = writeScript('#!/usr/bin/env bash\necho "x=$X"\n');
385
+ const r = await run(
386
+ { command: agent },
387
+ { message: 'hi', extraEnv: { X: 'extra' } },
388
+ );
389
+ assert.strictEqual(r.reply.trim(), 'x=extra');
390
+ });
391
+
392
+ test('stdin: message — agent reads AGENT_MESSAGE from stdin', async () => {
393
+ const agent = writeScript(
394
+ '#!/usr/bin/env bash\nread line\necho "stdin: $line"\n'
395
+ );
396
+ const r = await run(
397
+ { command: agent, stdin: 'message' },
398
+ { message: 'via-stdin' },
399
+ );
400
+ assert.strictEqual(r.reply.trim(), 'stdin: via-stdin');
401
+ });
402
+
403
+ test('timeout kills long-running agent', async () => {
404
+ const agent = writeScript(
405
+ '#!/usr/bin/env bash\nsleep 30\necho "should not reach"\n'
406
+ );
407
+ const r = await run(
408
+ { command: agent, kill_grace_secs: 1 },
409
+ { message: 'hi', timeoutSecs: 1 },
410
+ );
411
+ assert.strictEqual(r.timedOut, true);
412
+ assert.strictEqual(r.exitCode, 124);
413
+ });
414
+
415
+ test('multiline reply body preserves newlines', async () => {
416
+ const agent = writeScript(
417
+ '#!/usr/bin/env bash\n' +
418
+ 'echo "line 1"\n' +
419
+ 'echo "line 2"\n' +
420
+ 'echo "line 3"\n'
421
+ );
422
+ const r = await run({ command: agent }, { message: 'hi' });
423
+ // Lines are joined with \n; the trailing newline is the caller's responsibility
424
+ // (the CLI adds it when printing).
425
+ assert.strictEqual(r.reply, 'line 1\nline 2\nline 3');
426
+ });
427
+
428
+ test('spawn error (command not found) → exit 1', async () => {
429
+ const r = await run(
430
+ { command: '/nonexistent/command/xyz' },
431
+ { message: 'hi' },
432
+ );
433
+ assert.strictEqual(r.exitCode, 1);
434
+ });
435
+ });
436
+
437
+ test('PROTOCOL_VERSION is "0.1"', () => {
438
+ assert.strictEqual(PROTOCOL_VERSION, '0.1');
439
+ });