agentproc 0.3.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentproc",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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",
@@ -8,7 +8,7 @@
8
8
  "agentproc": "src/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test src/index.test.js src/runner.test.js"
11
+ "test": "node --test src/index.test.js src/runner.test.js src/hub.test.js src/conformance.test.js"
12
12
  },
13
13
  "keywords": ["agentproc", "agent", "bridge", "protocol", "cli", "ai", "runner"],
14
14
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  /**
4
- * agentproc CLI — run any AgentProc profile against a message.
4
+ * agentproc CLI — drive any AgentProc profile against a message.
5
5
  *
6
- * Usage:
6
+ * Quick start (hub):
7
+ * agentproc hub list
8
+ * agentproc hub run echo-agent -p "hello"
9
+ * cd ~/projects/my-app && agentproc hub run claude-code -p "explain this"
10
+ *
11
+ * Advanced (local profile YAML, no hub fetch):
7
12
  * agentproc --profile <path.yaml> --prompt "hello" [options]
8
13
  *
9
- * Options:
14
+ * Options (local-profile mode):
10
15
  * --profile, -p <path> Profile YAML path (required)
11
16
  * --prompt <text> User message (required, unless --stdin)
12
17
  * --session <id> Previous session id for multi-turn
@@ -119,9 +124,51 @@ async function runHubSubcommand(args) {
119
124
  return 0;
120
125
  }
121
126
 
122
- // Parse common flags
127
+ // Any subcommand with --help/-h shows the hub help (covers `hub run --help`,
128
+ // `hub install --help`, etc. — useful for muscle-memory discovery).
129
+ if (rest.includes('--help') || rest.includes('-h')) {
130
+ showHubHelp();
131
+ return 0;
132
+ }
133
+
134
+ // Parse hub args. We can't reuse parseArgs() here because it throws on
135
+ // any unknown `--flag`, and hub supports `--refresh` which the runner
136
+ // parser doesn't know about. Instead, walk the args ourselves, separating
137
+ // hub-level flags (--refresh, --help) from runner-level flags (-p/--prompt,
138
+ // --cwd, --env, ...) and positional args (the profile name).
123
139
  const refresh = rest.includes('--refresh');
124
- const positional = rest.filter(a => !a.startsWith('--'));
140
+ const positional = [];
141
+ const runnerArgs = [];
142
+ for (let i = 0; i < rest.length; i++) {
143
+ const a = rest[i];
144
+ if (a === '--refresh' || a === '-h' || a === '--help') continue;
145
+ // Runner flags that take a value (next arg): match both long and short.
146
+ // In the `hub run` context, `-p` means `--prompt` (the profile name is
147
+ // positional, not a path), so we normalize it before handing off.
148
+ const takesValue =
149
+ a === '--prompt' || a === '-p' ||
150
+ a === '--session' || a === '--session-name' || a === '--from' ||
151
+ a === '--cwd' || a === '--env' || a === '--timeout';
152
+ if (takesValue) {
153
+ runnerArgs.push(a === '-p' ? '--prompt' : a);
154
+ if (i + 1 < rest.length) runnerArgs.push(rest[++i]);
155
+ continue;
156
+ }
157
+ // Other known runner flags (boolean).
158
+ if (a === '--no-stream' || a === '--verbose' || a === '--quiet' ||
159
+ a === '--raw' || a === '--stdin') {
160
+ runnerArgs.push(a);
161
+ continue;
162
+ }
163
+ // Anything else starting with - or -- is unknown to us — surface it
164
+ // as an explicit error instead of silently treating it as a positional.
165
+ if (a.startsWith('-')) {
166
+ process.stderr.write(`error: unknown option: ${a}\n\n`);
167
+ showHubHelp();
168
+ return 2;
169
+ }
170
+ positional.push(a);
171
+ }
125
172
 
126
173
  if (sub === 'list') {
127
174
  const profiles = await hub.listProfiles({
@@ -157,10 +204,16 @@ async function runHubSubcommand(args) {
157
204
  return 2;
158
205
  }
159
206
  const target = process.cwd();
160
- await hub.installProfile(positional[0], target, {
207
+ const dest = await hub.installProfile(positional[0], target, {
161
208
  refresh,
162
209
  onLog: m => process.stderr.write(m + '\n'),
163
210
  });
211
+ // After-the-fact hint: tell the user exactly what they got and how to
212
+ // run it. Without this, "installed to: ./echo-agent/" leaves them
213
+ // guessing what to type next.
214
+ process.stderr.write(`\n`);
215
+ process.stderr.write(`Next: edit ${path.relative(target, path.join(dest, 'profile.yaml'))} if you want, then run:\n`);
216
+ process.stderr.write(` agentproc --profile ${path.relative(target, path.join(dest, 'profile.yaml'))} --prompt "hi" --cwd <your-project>\n`);
164
217
  return 0;
165
218
  }
166
219
 
@@ -176,13 +229,21 @@ async function runHubSubcommand(args) {
176
229
  });
177
230
  const profilePath = path.join(cacheDir, 'profile.yaml');
178
231
 
179
- // Re-parse the remaining args as the runner options (--prompt, --cwd, etc.).
180
- const { opts: runOpts } = parseArgs(rest);
232
+ // Parse the runner-level flags we separated out above.
233
+ const { opts: runOpts } = parseArgs(runnerArgs);
181
234
  if (!runOpts.prompt && !runOpts.stdin) {
182
235
  process.stderr.write('error: hub run requires --prompt <text> or --stdin\n');
183
236
  return 2;
184
237
  }
185
238
 
239
+ // hub run uses the user's current directory as the agent's cwd when
240
+ // --cwd is not given. This matches the hub docs ("uses your current
241
+ // directory as cwd") and is the right default for AI-CLI profiles
242
+ // where the agent should operate on the user's project.
243
+ if (!runOpts.cwd) {
244
+ runOpts.cwd = process.cwd();
245
+ }
246
+
186
247
  return await runAgent(profilePath, runOpts);
187
248
  }
188
249
 
@@ -231,6 +292,7 @@ Profiles are cached at ~/.agentproc/cache/hub/<name>/ (24h TTL).
231
292
  */
232
293
  async function runAgent(profilePath, opts) {
233
294
  let profileRaw;
295
+ const profileDir = path.dirname(path.resolve(profilePath));
234
296
  try {
235
297
  const yamlText = fs.readFileSync(path.resolve(profilePath), 'utf8');
236
298
  profileRaw = parseYaml(yamlText);
@@ -270,6 +332,7 @@ async function runAgent(profilePath, opts) {
270
332
  fromUser: opts.from || '',
271
333
  streaming,
272
334
  cwd: opts.cwd,
335
+ profileDir,
273
336
  extraEnv,
274
337
  timeoutSecs: opts.timeout,
275
338
  });
@@ -278,7 +341,8 @@ async function runAgent(profilePath, opts) {
278
341
  return r.exitCode === 0 ? 0 : 1;
279
342
  }
280
343
 
281
- const verbose = opts.verbose || !opts.quiet || (opts.verbose === undefined && opts.quiet === undefined) || opts.verbose;
344
+ // verbose: default true, --verbose keeps it true, --quiet sets it false.
345
+ const verbose = opts.verbose !== false;
282
346
 
283
347
  const r = await runner.run(profileRaw, {
284
348
  message: prompt,
@@ -287,6 +351,7 @@ async function runAgent(profilePath, opts) {
287
351
  fromUser: opts.from || '',
288
352
  streaming,
289
353
  cwd: opts.cwd,
354
+ profileDir,
290
355
  extraEnv,
291
356
  timeoutSecs: opts.timeout,
292
357
  onPartial: (t) => { if (verbose) process.stderr.write(`AGENT_PARTIAL:${JSON.stringify(t)}\n`); },
@@ -307,6 +372,27 @@ async function runAgent(profilePath, opts) {
307
372
  function showHelp() {
308
373
  process.stdout.write(`agentproc v${PKG_VERSION} (protocol ${PROTOCOL_VERSION})
309
374
 
375
+ The fastest way in:
376
+ agentproc hub list # see what's available
377
+ agentproc hub run echo-agent -p "hello" # smoke test (no API key)
378
+ cd ~/projects/my-app && agentproc hub run claude-code -p "explain this"
379
+
380
+ The CLI fetches the profile from the GitHub hub on first use, caches it at
381
+ ~/.agentproc/cache/hub/<name>/ (24h TTL), and uses your current directory as
382
+ the agent's cwd. Set GITHUB_TOKEN to raise the rate limit (see \`agentproc hub --help\`).
383
+
384
+ Hub subcommands:
385
+ hub list List all profiles in the hub
386
+ hub show <name> Show a profile's README
387
+ hub run <name> [run-options] Fetch (if needed) and run a profile
388
+ hub install <name> Copy a profile to the current directory
389
+
390
+ Run \`agentproc hub --help\` for the full hub reference.
391
+
392
+ ───────────────────────────────────────────────────────────────────────────────
393
+
394
+ Advanced: run a local profile YAML directly (no hub fetch)
395
+
310
396
  Usage:
311
397
  agentproc --profile <path.yaml> --prompt "hello" [options]
312
398
 
@@ -320,7 +406,8 @@ Session:
320
406
  --from <user> Sender identifier
321
407
 
322
408
  Execution:
323
- --cwd <path> Override profile.cwd
409
+ --cwd <path> Override profile.cwd (relative paths resolve
410
+ against the profile.yaml's directory)
324
411
  --env KEY=VALUE Extra env var (repeatable)
325
412
  --timeout <secs> Override profile.timeout_secs
326
413
  --no-stream Set AGENT_STREAMING=0
@@ -343,9 +430,16 @@ Output semantics:
343
430
  The final session id is printed on stderr as: agentproc:session:<id>
344
431
 
345
432
  Examples:
346
- agentproc --profile hub/echo-agent/profile.yaml --prompt "hi"
347
- agentproc -p hub/claude-code/profile.yaml --prompt "hello" --verbose
348
- cat prompt.txt | agentproc -p prof.yaml --stdin
433
+ # Local profile (relative cwd resolves next to profile.yaml):
434
+ agentproc --profile ./hub/echo-agent/profile.yaml --prompt "hi"
435
+
436
+ # Local claude-code profile, claude runs against your project:
437
+ agentproc --profile ./hub/claude-code/profile.yaml \\
438
+ --prompt "explain this codebase" \\
439
+ --cwd /path/to/your/project
440
+
441
+ # Prompt from stdin:
442
+ cat prompt.txt | agentproc --profile prof.yaml --stdin
349
443
  `);
350
444
  }
351
445
 
@@ -624,10 +718,35 @@ async function main() {
624
718
 
625
719
  // Run main() only when invoked directly as a script, not when required for tests.
626
720
  if (require.main === module) {
627
- main().catch(e => {
628
- process.stderr.write(`[agentproc] unhandled error: ${e && (e.stack || e)}\n`);
629
- process.exit(1);
630
- });
721
+ main().then(
722
+ (code) => {
723
+ // main() returns an explicit exit code from its various return paths;
724
+ // honor it so shell scripts can distinguish success from failure.
725
+ process.exit(typeof code === 'number' ? code : 0);
726
+ },
727
+ (e) => {
728
+ // Friendly handling for known hub errors: print the message + remediation
729
+ // hint, never a raw Node stack trace.
730
+ if (e && e.name === 'HubError') {
731
+ process.stderr.write(`error: ${e.message}\n`);
732
+ if (e.hint) process.stderr.write(`\n${e.hint}\n`);
733
+ process.exit(1);
734
+ return;
735
+ }
736
+ // For fetch() network errors wrapped by hub.js (also HubError, but be
737
+ // defensive in case some path throws a plain TypeError from fetch).
738
+ if (e && typeof e.message === 'string' && /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET/.test(e.message)) {
739
+ process.stderr.write(`error: network error talking to GitHub: ${e.message}\n`);
740
+ process.stderr.write(`\nThis is usually transient. Re-run the command, or run against a local checkout:\n`);
741
+ process.stderr.write(` agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"\n`);
742
+ process.exit(1);
743
+ return;
744
+ }
745
+ // Everything else: still avoid dumping the stack. Show the message only.
746
+ process.stderr.write(`error: ${e && (e.message || e)}\n`);
747
+ process.exit(1);
748
+ }
749
+ );
631
750
  }
632
751
 
633
752
  module.exports = { parseArgs, parseYaml, showHelp, main };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+ /**
3
+ * Cross-implementation conformance tests.
4
+ *
5
+ * Drives the shared `spec/conformance/cases.json` fixture through the Node
6
+ * runner's `classifyLine` and asserts the result matches the expected
7
+ * {kind, value}. The Python SDK runs the same fixture through its
8
+ * `classify_line` in `sdk/python/tests/test_conformance.py` — together they
9
+ * guarantee the two reference implementations classify stdout identically.
10
+ *
11
+ * When you change the spec's line-recognition rules, add a case to the JSON
12
+ * file first; both SDKs will fail until they agree.
13
+ */
14
+
15
+ const { test } = require('node:test');
16
+ const assert = require('node:assert');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const { classifyLine } = require('./runner.js');
21
+
22
+ const CASES_PATH = path.resolve(__dirname, '../../../spec/conformance/cases.json');
23
+ const data = JSON.parse(fs.readFileSync(CASES_PATH, 'utf8'));
24
+
25
+ for (const c of data.cases) {
26
+ test(`classifyLine: ${c.line.slice(0, 60)}`, () => {
27
+ assert.deepStrictEqual(classifyLine(c.line), c.expect);
28
+ });
29
+ }
package/src/hub.js CHANGED
@@ -74,24 +74,105 @@ function writeCacheMeta(name) {
74
74
  // HTTP helpers
75
75
  // ---------------------------------------------------------------------------
76
76
 
77
+ /**
78
+ * Custom error type for hub fetch failures. Carries a short, user-facing
79
+ * `hint` with remediation, so the CLI can print something helpful instead
80
+ * of a raw Node stack trace.
81
+ */
82
+ class HubError extends Error {
83
+ constructor(message, { hint = '', cause = null, status = 0 } = {}) {
84
+ super(message);
85
+ this.name = 'HubError';
86
+ this.hint = hint;
87
+ this.status = status;
88
+ if (cause) this.cause = cause;
89
+ }
90
+ }
91
+
92
+ function authHeaders({ json = false } = {}) {
93
+ // Optional: an explicit token raises GitHub's anonymous rate limit from
94
+ // 60 req/hour to 5,000. We accept either GITHUB_TOKEN (the env var GitHub
95
+ // Actions injects) or GH_TOKEN (what `gh` CLI users typically have).
96
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
97
+ const h = { 'User-Agent': 'agentproc-cli' };
98
+ if (json) h.Accept = 'application/vnd.github+json';
99
+ if (token) h.Authorization = `Bearer ${token}`;
100
+ return h;
101
+ }
102
+
77
103
  async function httpGetJson(url) {
78
- const r = await fetch(url, {
79
- headers: {
80
- Accept: 'application/vnd.github+json',
81
- 'User-Agent': 'agentproc-cli',
82
- },
83
- });
104
+ let r;
105
+ try {
106
+ r = await fetch(url, { headers: authHeaders({ json: true }) });
107
+ } catch (e) {
108
+ throw new HubError(
109
+ `could not reach GitHub while fetching hub profile`,
110
+ {
111
+ status: 0,
112
+ cause: e,
113
+ hint: [
114
+ 'This is usually a transient network issue. Try:',
115
+ ' 1. Re-run the command (often succeeds on retry).',
116
+ ' 2. If your network requires a proxy, set HTTPS_PROXY.',
117
+ ' 3. To avoid the network entirely, run against a local checkout:',
118
+ ' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
119
+ ].join('\n'),
120
+ }
121
+ );
122
+ }
84
123
  if (!r.ok) {
85
124
  const text = await r.text().catch(() => '');
86
- throw new Error(`GitHub API ${r.status}: ${text.slice(0, 200)}`);
125
+ if (r.status === 403 || r.status === 429) {
126
+ const authed = !!(process.env.GITHUB_TOKEN || process.env.GH_TOKEN);
127
+ throw new HubError(
128
+ `GitHub rate-limited the hub fetch (HTTP ${r.status})`,
129
+ {
130
+ status: r.status,
131
+ hint: authed
132
+ ? [
133
+ 'Your GITHUB_TOKEN is set but still rate-limited. Wait a few minutes and retry,',
134
+ 'or run against a local checkout instead:',
135
+ ' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
136
+ '',
137
+ `Not sure the profile name is right? Check with: agentproc hub list`,
138
+ ].join('\n')
139
+ : [
140
+ 'GitHub limits anonymous hub fetches to ~60/hour. To raise this to 5,000/hour:',
141
+ ' export GITHUB_TOKEN=$(gh auth token) # if you have the GitHub CLI',
142
+ ' # or set GITHUB_TOKEN to any personal access token',
143
+ '',
144
+ 'To skip the network entirely, run against a local checkout:',
145
+ ' git clone https://github.com/jeffkit/agentproc && cd agentproc',
146
+ ' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
147
+ '',
148
+ `Not sure the profile name is right? Check with: agentproc hub list`,
149
+ ].join('\n'),
150
+ }
151
+ );
152
+ }
153
+ if (r.status === 404) {
154
+ throw new HubError(`profile not found on GitHub (HTTP 404)`, {
155
+ status: 404,
156
+ hint: 'Check the profile name with `agentproc hub list`. (Typos are case-sensitive.)',
157
+ });
158
+ }
159
+ throw new HubError(`GitHub returned HTTP ${r.status} for hub fetch`, {
160
+ status: r.status,
161
+ hint: text.slice(0, 200) || 'No additional detail from GitHub.',
162
+ });
87
163
  }
88
164
  return r.json();
89
165
  }
90
166
 
91
167
  async function httpGetText(url) {
92
- const r = await fetch(url, { headers: { 'User-Agent': 'agentproc-cli' } });
168
+ const r = await fetch(url, { headers: authHeaders({ json: false }) });
93
169
  if (!r.ok) {
94
- throw new Error(`fetch ${r.status}: ${url}`);
170
+ // raw.githubusercontent.com is essentially unrate-limited; a failure
171
+ // here is more likely a genuine 404 (profile file missing) than 403.
172
+ throw new HubError(`fetch failed (HTTP ${r.status}) for ${url}`, {
173
+ status: r.status,
174
+ hint: 'Profile files should exist in the hub repo. Try `agentproc hub list` to verify.',
175
+ });
95
176
  }
96
177
  return r.text();
97
178
  }
@@ -160,6 +241,83 @@ async function listRemoteProfileFiles(name) {
160
241
  }));
161
242
  }
162
243
 
244
+ /**
245
+ * List top-level profile names (the directories directly under hub/).
246
+ * Cheap: uses the same in-memory tree cache as getTree(), so calling this
247
+ * after listRemoteProfileFiles does not cost an extra API request.
248
+ * @returns {Promise<string[]>}
249
+ */
250
+ async function listProfileNames() {
251
+ const tree = await getTree();
252
+ const seen = new Set();
253
+ for (const e of tree) {
254
+ if (!e.path.startsWith('hub/')) continue;
255
+ const seg = e.path.slice('hub/'.length).split('/')[0];
256
+ // Directories prefixed with `_` (e.g. `_shared`) hold bridge utilities,
257
+ // not profiles — exclude them from listings and "did you mean" suggestions.
258
+ if (seg && !seg.startsWith('_') && !seen.has(seg)) seen.add(seg);
259
+ }
260
+ return [...seen].sort();
261
+ }
262
+
263
+ /**
264
+ * Lightweight "did you mean" hint using edit distance + prefix matching.
265
+ * Returns the best candidate name, or '' if none is close enough.
266
+ *
267
+ * Two paths to a match:
268
+ * 1. Prefix match — `claude` matches `claude-code`, `echo` matches
269
+ * `echo-agent`. This is the common typo pattern (user forgot a suffix).
270
+ * Only accepts an unambiguous prefix — if multiple candidates share
271
+ * the prefix, none is returned (better no suggestion than a wrong one).
272
+ * 2. Edit distance — tolerate ~1/3 of the input length in edits. Catches
273
+ * transpositions (`calude`) and small typos (`coudex` → `codex`).
274
+ */
275
+ function suggestCloseName(input, candidates) {
276
+ if (!input || !candidates || candidates.length === 0) return '';
277
+
278
+ const n = input.toLowerCase();
279
+
280
+ // Path 1: unique prefix match.
281
+ const prefixMatches = candidates.filter(c => c.toLowerCase().startsWith(n));
282
+ if (prefixMatches.length === 1) return prefixMatches[0];
283
+
284
+ // Path 2: edit distance. Threshold scales with input length:
285
+ // - short (≤6): allow 1 edit (typos in `agy`, `codex`)
286
+ // - medium (7-12): allow 2 edits (transpositions in `calude-code`)
287
+ // - long (>12): allow 3 edits
288
+ const threshold = input.length <= 6 ? 1 : input.length <= 12 ? 2 : 3;
289
+ let best = '';
290
+ let bestDist = Infinity;
291
+ for (const c of candidates) {
292
+ const dist = editDistance(n, c.toLowerCase());
293
+ if (dist < bestDist) { bestDist = dist; best = c; }
294
+ }
295
+ if (best && bestDist <= threshold) return best;
296
+ return '';
297
+ }
298
+
299
+ function editDistance(a, b) {
300
+ const m = a.length, n = b.length;
301
+ if (m === 0) return n;
302
+ if (n === 0) return m;
303
+ const prev = new Array(n + 1);
304
+ const curr = new Array(n + 1);
305
+ for (let j = 0; j <= n; j++) prev[j] = j;
306
+ for (let i = 1; i <= m; i++) {
307
+ curr[0] = i;
308
+ for (let j = 1; j <= n; j++) {
309
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
310
+ curr[j] = Math.min(
311
+ prev[j] + 1, // deletion
312
+ curr[j - 1] + 1, // insertion
313
+ prev[j - 1] + cost // substitution
314
+ );
315
+ }
316
+ for (let j = 0; j <= n; j++) prev[j] = curr[j];
317
+ }
318
+ return prev[n];
319
+ }
320
+
163
321
  async function downloadFile(remotePath, localPath) {
164
322
  const text = await httpGetText(GITHUB_RAW(remotePath));
165
323
  fs.mkdirSync(path.dirname(localPath), { recursive: true });
@@ -203,7 +361,18 @@ async function fetchProfile(name, opts = {}) {
203
361
 
204
362
  const entries = await listRemoteProfileFiles(name);
205
363
  if (entries.length === 0) {
206
- throw new Error(`profile '${name}' not found in hub`);
364
+ // getTree succeeded (otherwise listRemoteProfileFiles would have thrown
365
+ // a HubError already). So the name is genuinely wrong — surface the list
366
+ // of available names so the user can correct the typo.
367
+ const known = await listProfileNames();
368
+ const suggestion = suggestCloseName(name, known);
369
+ const hint = suggestion
370
+ ? [`Did you mean \`${suggestion}\`?`, '', 'Available profiles:', ...known.map(n => ` - ${n}`)].join('\n')
371
+ : ['Available profiles:', ...known.map(n => ` - ${n}`)].join('\n');
372
+ throw new HubError(`profile '${name}' not found in hub`, {
373
+ status: 404,
374
+ hint,
375
+ });
207
376
  }
208
377
 
209
378
  // Clear cache, then re-download every file in the profile directory.
@@ -235,6 +404,9 @@ async function listProfiles(opts = {}) {
235
404
  for (const entry of entries) {
236
405
  if (entry.type !== 'dir') continue;
237
406
  const name = entry.name;
407
+ // Skip utility directories like `_shared/` — they hold shared bridge
408
+ // helpers, not a runnable profile (no profile.yaml).
409
+ if (name.startsWith('_')) continue;
238
410
  try {
239
411
  const yamlText = await httpGetText(GITHUB_RAW(`hub/${name}/profile.yaml`));
240
412
  const { parseYaml } = require('./cli.js');
@@ -300,6 +472,7 @@ module.exports = {
300
472
  HUB_REPO,
301
473
  HUB_REF,
302
474
  HUB_CACHE_TTL_SECS,
475
+ HubError,
303
476
  cacheRoot,
304
477
  cacheDir,
305
478
  cacheAgeSecs,
package/src/hub.test.js CHANGED
@@ -23,6 +23,10 @@ const hub = require('./hub.js');
23
23
 
24
24
  const FAKE_TREE = [
25
25
  { path: 'hub', type: 'tree' },
26
+ { path: 'hub/_shared', type: 'tree' },
27
+ { path: 'hub/_shared/stream_utils.py', type: 'blob' },
28
+ { path: 'hub/_shared/stream_utils.js', type: 'blob' },
29
+ { path: 'hub/_shared/README.md', type: 'blob' },
26
30
  { path: 'hub/echo-agent', type: 'tree' },
27
31
  { path: 'hub/echo-agent/profile.yaml', type: 'blob' },
28
32
  { path: 'hub/echo-agent/bridge.py', type: 'blob' },
@@ -245,6 +249,20 @@ describe('hub', { concurrency: false }, () => {
245
249
  counter.restore();
246
250
  }
247
251
  });
252
+
253
+ test('skips underscore-prefixed utility dirs like _shared', async () => {
254
+ // _shared/ has no profile.yaml; it must not appear in the listing and
255
+ // must not trigger a "could not read metadata" warning/fetch.
256
+ const counter = installFakeFetch();
257
+ try {
258
+ const profiles = await hub.listProfiles({ refresh: true });
259
+ const names = profiles.map(p => p.name);
260
+ assert.ok(!names.some(n => n.startsWith('_')),
261
+ `utility dir leaked into listing: ${names}`);
262
+ } finally {
263
+ counter.restore();
264
+ }
265
+ });
248
266
  });
249
267
 
250
268
  // ----- showReadme -----
package/src/runner.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * protocol-compliant agent invocation.
5
5
  *
6
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.
7
+ * contract (spec/protocol.md, wire protocol 0.1). The CLI (cli.js) is a thin wrapper around it.
8
8
  *
9
9
  * Responsibilities:
10
10
  * - Parse and validate a profile object
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  const { spawn } = require('node:child_process');
29
+ const fs = require('node:fs');
29
30
  const path = require('node:path');
30
31
  const os = require('node:os');
31
32
 
@@ -74,17 +75,43 @@ function normalizeProfile(raw) {
74
75
  throw new Error('profile.command must be a non-empty string');
75
76
  }
76
77
 
77
- // Split command into argv on whitespace, no shell (per spec).
78
- const argv = p.command.trim().split(/\s+/);
79
- if (argv.length === 0) {
78
+ // Per spec: `command` is argv[0]; `args` is argv[1..]. Two mutually
79
+ // exclusive forms:
80
+ // (a) `args` absent + command has whitespace → split command into argv
81
+ // (the legacy shorthand: `command: python3 ./bridge.py`)
82
+ // (b) `args` present (even empty `[]`) → command is a single token,
83
+ // never split. Lets paths with spaces stay whole:
84
+ // command: "/path with spaces/my agent"
85
+ // args: []
86
+ // `args: []` (explicit empty array) is DISTINCT from "args absent": the
87
+ // explicit form means "do not split command"; the absent form falls back
88
+ // to the whitespace-splitting shorthand.
89
+ const argsFieldPresent = raw.agentproc
90
+ ? (Object.prototype.hasOwnProperty.call(raw.agentproc, 'args') && raw.agentproc.args != null)
91
+ : (Object.prototype.hasOwnProperty.call(raw, 'args') && raw.args != null);
92
+ const argv = argsFieldPresent ? [p.command.trim()] : p.command.trim().split(/\s+/);
93
+ if (argv.length === 0 || argv[0] === '') {
80
94
  throw new Error('profile.command produced empty argv');
81
95
  }
82
96
 
97
+ // env_allowlist (optional): when present, ${VAR} references in the env
98
+ // block whose name is NOT in the list expand to empty + a stderr warning.
99
+ // Absent ⇒ current behaviour (expand against the full bridge environment).
100
+ // Opt-in: existing profiles keep working unchanged.
101
+ let envAllowlist = null;
102
+ if (p.env_allowlist !== undefined && p.env_allowlist !== null) {
103
+ if (!Array.isArray(p.env_allowlist)) {
104
+ throw new Error('profile.env_allowlist must be a list');
105
+ }
106
+ envAllowlist = new Set(p.env_allowlist.map(String));
107
+ }
108
+
83
109
  return {
84
110
  argv,
85
111
  args: Array.isArray(p.args) ? p.args.map(String) : [],
86
112
  cwd: p.cwd ? expandPath(String(p.cwd)) : undefined,
87
113
  env: p.env && typeof p.env === 'object' ? p.env : {},
114
+ env_allowlist: envAllowlist,
88
115
  stdin: p.stdin === 'message' ? 'message' : 'none',
89
116
  timeout_secs: Number.isFinite(p.timeout_secs) ? p.timeout_secs : DEFAULT_TIMEOUT_SECS,
90
117
  kill_grace_secs: Number.isFinite(p.kill_grace_secs) ? p.kill_grace_secs : DEFAULT_KILL_GRACE_SECS,
@@ -100,22 +127,147 @@ function expandPath(p) {
100
127
  }
101
128
 
102
129
  /**
103
- * Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
104
- * in a string value. Per spec, no shell is involved.
130
+ * Best-effort pattern check against the agent's accumulated stderr to spot
131
+ * common "bridge file not found" / "module not found" failures that the
132
+ * wrapped interpreter writes to its own stderr before exiting non-zero.
133
+ * Returns a human-friendly hint, or '' if nothing recognizable.
134
+ *
135
+ * This is intentionally narrow — we only flag high-confidence patterns to
136
+ * avoid mis-diagnosing genuine agent errors.
137
+ */
138
+ function diagnoseStderrFailure(stderrText, { argv }) {
139
+ if (!stderrText) return '';
140
+ const lower = stderrText.toLowerCase();
141
+
142
+ // python3: "can't open file '/path/x.py': [Errno 2] No such file or directory"
143
+ // Also covers "cannot open file" (localized variants).
144
+ const pyMatch = stderrText.match(/(?:can'?t|cannot) open file '([^']+)': \[Errno 2\] No such file or directory/);
145
+ if (pyMatch) {
146
+ const file = pyMatch[1];
147
+ return `agent script not found: ${file}. Check the profile's command path (likely a {{PROFILE_DIR}} issue or a typo).`;
148
+ }
149
+
150
+ // node: "Error: Cannot find module '/path/x.js'"
151
+ const nodeMatch = stderrText.match(/Cannot find module '([^']+)'/);
152
+ if (nodeMatch) {
153
+ const mod = nodeMatch[1];
154
+ return `agent script not found: ${mod}. Check the profile's command path (likely a {{PROFILE_DIR}} issue or a typo).`;
155
+ }
156
+
157
+ // bash: "bash: line N: ./x.sh: No such file or directory"
158
+ const bashMatch = stderrText.match(/(?:^|\n)[^:]+: line \d+: ([^:]+): No such file or directory/);
159
+ if (bashMatch) {
160
+ const file = bashMatch[1];
161
+ return `agent script not found: ${file}. Check the profile's command path.`;
162
+ }
163
+
164
+ // Generic Errno 2 sentinel, in case the interpreter phrasing differs.
165
+ if (/errno 2|enoent|no such file or directory/.test(lower)) {
166
+ return `agent reported a missing file. Check the profile's command and cwd.`;
167
+ }
168
+
169
+ return '';
170
+ }
171
+
172
+ /**
173
+ * Produce a human-friendly hint for a spawn ENOENT-style error.
174
+ *
175
+ * Node's spawn attributes the error to argv[0] regardless of whether it was
176
+ * the command itself or a referenced file (e.g. `./bridge.py`) that wasn't
177
+ * found, which is very confusing. We inspect cwd + argv to give a better
178
+ * diagnosis. Returns '' when nothing useful can be said.
179
+ */
180
+ function diagnoseSpawnError(err, { argv, cwd, env }) {
181
+ const code = err && err.code;
182
+ const message = (err && err.message) || '';
183
+ if (code !== 'ENOENT' && !/ENOENT/.test(message)) return '';
184
+
185
+ // (a) cwd doesn't exist or isn't a directory
186
+ if (cwd) {
187
+ try {
188
+ const stat = fs.statSync(cwd);
189
+ if (!stat.isDirectory()) {
190
+ return `profile.cwd is not a directory: ${cwd}`;
191
+ }
192
+ } catch (e) {
193
+ if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
194
+ return `profile.cwd is not accessible (permission denied): ${cwd}`;
195
+ }
196
+ return `profile.cwd does not exist: ${cwd}. Pass --cwd <path> to point at a real directory.`;
197
+ }
198
+ }
199
+
200
+ // (b) the command (argv[0]) is not on PATH
201
+ const cmd = argv[0];
202
+ const isPathed = /[\\/]/.test(cmd);
203
+ if (!isPathed) {
204
+ // Bare command like 'python3' or 'claude' — check PATH ourselves.
205
+ const PATH = (env && env.PATH) || '';
206
+ if (PATH) {
207
+ const found = PATH.split(path.delimiter).some(d => {
208
+ try {
209
+ const p = path.join(d, cmd);
210
+ fs.accessSync(p, fs.constants.X_OK);
211
+ return true;
212
+ } catch { return false; }
213
+ });
214
+ if (!found) {
215
+ return `'${cmd}' not found on PATH. Install it, or if it's installed, make sure PATH is set correctly when the bridge spawns the agent.`;
216
+ }
217
+ }
218
+ return `'${cmd}' could not be executed. Verify it is installed and on PATH.`;
219
+ }
220
+
221
+ // (c) argv[0] looks like a path — check whether the file itself exists
222
+ try {
223
+ fs.accessSync(cmd, fs.constants.X_OK);
224
+ } catch {
225
+ return `command path does not exist or is not executable: ${cmd}`;
226
+ }
227
+
228
+ // (d) Command exists; suspect an argv file argument (e.g. python3 ./bridge.py).
229
+ for (let i = 1; i < argv.length; i++) {
230
+ const a = argv[i];
231
+ if (!a.startsWith('-') && (a.includes('/') || a.includes('\\'))) {
232
+ // Resolve relative to cwd (mirrors spawn's resolution)
233
+ const resolved = path.isAbsolute(a) ? a : (cwd ? path.resolve(cwd, a) : path.resolve(a));
234
+ try {
235
+ fs.accessSync(resolved, fs.constants.R_OK);
236
+ } catch {
237
+ return `argument file not found: ${a} (resolved to ${resolved}). The profile likely needs --cwd or the bundled script path is wrong.`;
238
+ }
239
+ }
240
+ }
241
+
242
+ return '';
243
+ }
244
+
245
+ /**
246
+ * Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}}, {{PROFILE_DIR}}
247
+ * placeholders in a string value. Per spec, no shell is involved.
105
248
  */
106
249
  function substitute(value, ctx) {
107
250
  return String(value)
108
251
  .replace(/\{\{MESSAGE\}\}/g, ctx.message || '')
109
252
  .replace(/\{\{SESSION_ID\}\}/g, ctx.sessionId || '')
110
- .replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '');
253
+ .replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '')
254
+ .replace(/\{\{PROFILE_DIR\}\}/g, ctx.profileDir || '');
111
255
  }
112
256
 
113
257
  /**
114
- * Expand ${VAR} references against process.env, like a typical shell would.
258
+ * Expand ${VAR} references against `env`, like a typical shell would.
115
259
  * Unknown variables expand to empty string (POSIX sh behavior).
260
+ *
261
+ * When `allowlist` is a Set of names, references to names NOT in the set
262
+ * expand to empty and `onBlocked` (if given) is called with each blocked
263
+ * name. When `allowlist` is null, all references expand normally.
116
264
  */
117
- function expandEnvRef(value, env) {
265
+ function expandEnvRef(value, env, allowlist = null, onBlocked = null) {
118
266
  return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
267
+ if (allowlist && !allowlist.has(name)) {
268
+ if (onBlocked) onBlocked(name);
269
+ return '';
270
+ }
119
271
  const v = env[name];
120
272
  return v !== undefined ? v : '';
121
273
  });
@@ -132,13 +284,18 @@ function expandEnvRef(value, env) {
132
284
  function decodeJsonValue(raw) {
133
285
  const text = raw.trim();
134
286
  if (text === '') return '';
287
+ let v;
135
288
  try {
136
- const v = JSON.parse(text);
137
- return typeof v === 'string' ? v : String(v);
289
+ v = JSON.parse(text);
138
290
  } catch {
139
291
  // Lenient: treat as plain string.
140
292
  return text;
141
293
  }
294
+ // Only JSON strings are meaningful payloads — a sentinel's value is text
295
+ // for the user. Non-string JSON (number/bool/null/array/object) means the
296
+ // agent misused the API; fall back to the raw text so the result is
297
+ // language-independent (String(true) != str(True) across runtimes).
298
+ return typeof v === 'string' ? v : text;
142
299
  }
143
300
 
144
301
  /**
@@ -211,17 +368,28 @@ async function run(profileRaw, options) {
211
368
  const sessionName = options.sessionName || 'default';
212
369
  const streaming = options.streaming !== undefined ? !!options.streaming : profile.streaming;
213
370
  const timeoutSecs = options.timeoutSecs !== undefined ? options.timeoutSecs : profile.timeout_secs;
214
- const cwd = options.cwd || profile.cwd;
371
+ let cwd = options.cwd || profile.cwd;
372
+ // Resolve relative cwd against the profile's directory (if known) so that
373
+ // profiles written as `cwd: .` work no matter where the user invokes from.
374
+ // Absolute paths and `~`-prefixed paths are already absolute post-expand.
375
+ if (cwd && !path.isAbsolute(cwd) && options.profileDir) {
376
+ cwd = path.resolve(options.profileDir, cwd);
377
+ }
215
378
 
216
379
  // Build the substitution context for {{MESSAGE}} etc.
380
+ // {{PROFILE_DIR}} resolves to the directory the profile YAML lives in
381
+ // (passed by the CLI; undefined when run programmatically without it),
382
+ // letting profiles reference bundled scripts via absolute paths while
383
+ // still allowing the agent's cwd to be anywhere.
217
384
  const substCtx = {
218
385
  message: options.message,
219
386
  sessionId,
220
387
  sessionName,
388
+ profileDir: options.profileDir || '',
221
389
  };
222
390
 
223
391
  // Build argv: command + args (with placeholders substituted).
224
- const argv = [...profile.argv];
392
+ const argv = profile.argv.map(a => substitute(a, substCtx));
225
393
  for (const a of profile.args) {
226
394
  argv.push(substitute(a, substCtx));
227
395
  }
@@ -229,8 +397,13 @@ async function run(profileRaw, options) {
229
397
  // Build env: start with process.env (so PATH etc. work), add profile.env
230
398
  // (with ${VAR} refs expanded against process.env), then add AGENT_* vars.
231
399
  const env = { ...process.env };
400
+ const allowlist = profile.env_allowlist;
232
401
  for (const [k, v] of Object.entries(profile.env)) {
233
- env[k] = expandEnvRef(substitute(v, substCtx), process.env);
402
+ env[k] = expandEnvRef(substitute(v, substCtx), process.env, allowlist, (name) => {
403
+ if (options.onStderr) {
404
+ options.onStderr(`[agentproc runner] env_allowlist blocked \${${name}} (not in allowlist); expanded to empty`);
405
+ }
406
+ });
234
407
  }
235
408
  if (options.extraEnv) {
236
409
  for (const [k, v] of Object.entries(options.extraEnv)) {
@@ -303,8 +476,23 @@ async function run(profileRaw, options) {
303
476
 
304
477
  // ---- stderr: forward as debug ----
305
478
  let stderrBuf = '';
479
+ // Two views on stderr:
480
+ // - stderrWindow: bounded sliding window (8 KB) — reserved for future
481
+ // UI/display use so a noisy agent cannot exhaust memory.
482
+ // - stderrFull: unbounded capture used for post-mortem pattern
483
+ // diagnosis. Without the full text, a chatty agent can push the real
484
+ // error out of the window and the friendly hint goes missing.
485
+ let stderrWindow = '';
486
+ let stderrFull = '';
487
+ const STDERR_CAP = 8192;
306
488
  child.stderr.on('data', chunk => {
307
- stderrBuf += chunk.toString();
489
+ const text = chunk.toString();
490
+ stderrBuf += text;
491
+ stderrFull += text;
492
+ stderrWindow += text;
493
+ if (stderrWindow.length > STDERR_CAP) {
494
+ stderrWindow = stderrWindow.slice(stderrWindow.length - STDERR_CAP);
495
+ }
308
496
  let nl;
309
497
  while ((nl = stderrBuf.indexOf('\n')) >= 0) {
310
498
  const line = stderrBuf.slice(0, nl);
@@ -320,6 +508,11 @@ async function run(profileRaw, options) {
320
508
  }
321
509
 
322
510
  // ---- timeout handling per spec: SIGTERM → grace → SIGKILL ----
511
+ // On POSIX, child.kill('SIGTERM') is a real signal the agent can trap and
512
+ // flush; on Windows, Node translates any signal name to TerminateProcess,
513
+ // so the grace period is effectively a no-op there. The two-step shape is
514
+ // preserved so POSIX behaviour is correct; Windows callers get a hard kill
515
+ // at the deadline (acceptable per the spec's Windows caveat).
323
516
  let timer = null;
324
517
  if (timeoutSecs > 0) {
325
518
  timer = setTimeout(() => {
@@ -340,8 +533,20 @@ async function run(profileRaw, options) {
340
533
  const exitCode = await new Promise(resolve => {
341
534
  child.on('close', code => resolve(code));
342
535
  child.on('error', err => {
343
- // spawn error (ENOENT etc.)
344
- if (options.onStderr) options.onStderr(`[agentproc runner] spawn error: ${err.message}`);
536
+ // spawn error — usually ENOENT. Node attributes it to argv[0]
537
+ // regardless of whether it was the command or a referenced file that
538
+ // wasn't found, so disambiguate for the user.
539
+ const tip = diagnoseSpawnError(err, { argv, cwd, env });
540
+ if (options.onStderr) {
541
+ options.onStderr(`[agentproc runner] spawn error: ${err.message}`);
542
+ if (tip) options.onStderr(`[agentproc runner] hint: ${tip}`);
543
+ }
544
+ // Surface as an AGENT_ERROR so the user sees it on the bridge too.
545
+ if (options.onError) {
546
+ const msg = tip || err.message;
547
+ options.onError(`failed to start agent: ${msg}`);
548
+ }
549
+ if (!result.error) result.error = tip || err.message;
345
550
  resolve(EXIT_ERROR);
346
551
  });
347
552
  });
@@ -353,7 +558,23 @@ async function run(profileRaw, options) {
353
558
  handleLine(stdoutBuf.replace(/\r$/, ''));
354
559
  }
355
560
 
356
- // Compose reply body.
561
+ // Flush any remaining stderr (the chunk handler only emits on newlines).
562
+ if (stderrBuf.length > 0) {
563
+ if (options.onStderr) options.onStderr(stderrBuf.replace(/\r$/, ''));
564
+ }
565
+
566
+ // If the agent exited non-zero with no AGENT_ERROR, peek at its stderr for
567
+ // common "command/file not found" patterns and surface a friendly hint.
568
+ // Uses the FULL stderr — a noisy agent can fill the 8 KB window with
569
+ // progress junk before the real error lands at the end.
570
+ if (!killed && !result.error && exitCode !== 0) {
571
+ const hint = diagnoseStderrFailure(stderrFull, { argv });
572
+ if (hint) {
573
+ result.error = hint;
574
+ if (options.onError) options.onError(hint);
575
+ }
576
+ }
577
+
357
578
  result.reply = bodyLines.join('\n');
358
579
  if (result.reply.length > profile.max_reply_chars) {
359
580
  const suffix = profile.max_reply_chars === DEFAULT_MAX_REPLY_CHARS