agentproc 0.3.0 → 0.4.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 +2 -2
- package/src/cli.js +136 -17
- package/src/conformance.test.js +29 -0
- package/src/hub.js +274 -36
- package/src/hub.test.js +74 -3
- package/src/runner.js +239 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentproc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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 —
|
|
4
|
+
* agentproc CLI — drive any AgentProc profile against a message.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
180
|
-
const { opts: runOpts } = parseArgs(
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
agentproc
|
|
348
|
-
|
|
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().
|
|
628
|
-
|
|
629
|
-
|
|
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,36 +74,170 @@ 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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: {
|
|
168
|
+
const r = await fetch(url, { headers: authHeaders({ json: false }) });
|
|
169
|
+
if (!r.ok) {
|
|
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
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return r.text();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Like httpGetText, but returns null on 404 instead of throwing. Used for
|
|
182
|
+
* probing optional profile files (e.g. bridge.sh only exists for echo-agent)
|
|
183
|
+
* and for detecting "profile does not exist" without burning an API call.
|
|
184
|
+
*/
|
|
185
|
+
async function httpGetTextOptional(url) {
|
|
186
|
+
const r = await fetch(url, { headers: authHeaders({ json: false }) });
|
|
187
|
+
if (r.status === 404) return null;
|
|
93
188
|
if (!r.ok) {
|
|
94
|
-
throw new
|
|
189
|
+
throw new HubError(`fetch failed (HTTP ${r.status}) for ${url}`, {
|
|
190
|
+
status: r.status,
|
|
191
|
+
hint: 'Profile files should exist in the hub repo. Try `agentproc hub list` to verify.',
|
|
192
|
+
});
|
|
95
193
|
}
|
|
96
194
|
return r.text();
|
|
97
195
|
}
|
|
98
196
|
|
|
99
197
|
/**
|
|
100
198
|
* Fetch the entire repo tree (1 API call, returns all paths under hub/).
|
|
101
|
-
* Cached in
|
|
199
|
+
* Cached two ways: in-memory for the lifetime of the process, and on disk
|
|
200
|
+
* at ~/.agentproc/cache/hub/tree.json with the same 24h TTL as profiles.
|
|
201
|
+
*
|
|
202
|
+
* The disk cache is the important one for rate-limit relief: the GitHub
|
|
203
|
+
* Trees API is the single call that rate-limits anonymous users to ~60/hr,
|
|
204
|
+
* and every CLI invocation is a fresh process (so the in-memory cache never
|
|
205
|
+
* survives). With the disk cache, a normal user makes at most ~1 Trees API
|
|
206
|
+
* call per day regardless of how many `hub list` / `hub run` they run.
|
|
102
207
|
* @returns {Promise<Array<{path: string, type: 'blob'|'tree'}>>}
|
|
103
208
|
*/
|
|
104
209
|
let _treeCache = null;
|
|
210
|
+
|
|
211
|
+
function treeCachePath() {
|
|
212
|
+
return path.join(cacheRoot(), 'tree.json');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function clearTreeCache() {
|
|
216
|
+
_treeCache = null;
|
|
217
|
+
const p = treeCachePath();
|
|
218
|
+
if (fs.existsSync(p)) {
|
|
219
|
+
try { fs.unlinkSync(p); } catch { /* best effort */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
105
223
|
async function getTree() {
|
|
106
224
|
if (_treeCache) return _treeCache;
|
|
225
|
+
|
|
226
|
+
const tp = treeCachePath();
|
|
227
|
+
if (fs.existsSync(tp)) {
|
|
228
|
+
try {
|
|
229
|
+
const meta = JSON.parse(fs.readFileSync(tp, 'utf8'));
|
|
230
|
+
const age = Math.max(0, Date.now() / 1000 - (meta.fetched_at || 0));
|
|
231
|
+
if (age < HUB_CACHE_TTL_SECS && Array.isArray(meta.tree)) {
|
|
232
|
+
_treeCache = meta.tree.map((e) => ({
|
|
233
|
+
path: String((e && e.path) || ''),
|
|
234
|
+
type: String((e && e.type) || ''),
|
|
235
|
+
}));
|
|
236
|
+
return _treeCache;
|
|
237
|
+
}
|
|
238
|
+
} catch { /* corrupt cache file — refetch */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
107
241
|
const data = await httpGetJson(GITHUB_TREES);
|
|
108
242
|
if (!data || !Array.isArray(data.tree)) {
|
|
109
243
|
throw new Error('unexpected tree API response');
|
|
@@ -114,6 +248,16 @@ async function getTree() {
|
|
|
114
248
|
path: String(e.path || ''),
|
|
115
249
|
type: String(e.type || ''), // 'blob' or 'tree'
|
|
116
250
|
}));
|
|
251
|
+
|
|
252
|
+
fs.mkdirSync(cacheRoot(), { recursive: true });
|
|
253
|
+
try {
|
|
254
|
+
fs.writeFileSync(tp, JSON.stringify({
|
|
255
|
+
fetched_at: Date.now() / 1000,
|
|
256
|
+
ref: HUB_REF,
|
|
257
|
+
tree: _treeCache,
|
|
258
|
+
}), 'utf8');
|
|
259
|
+
} catch { /* disk cache is best-effort */ }
|
|
260
|
+
|
|
117
261
|
return _treeCache;
|
|
118
262
|
}
|
|
119
263
|
|
|
@@ -145,34 +289,104 @@ async function listRemoteFiles(subpath) {
|
|
|
145
289
|
}
|
|
146
290
|
|
|
147
291
|
/**
|
|
148
|
-
* List
|
|
149
|
-
*
|
|
150
|
-
*
|
|
292
|
+
* List top-level profile names (the directories directly under hub/).
|
|
293
|
+
* Cheap: uses the same disk-cached tree as getTree(), so calling this
|
|
294
|
+
* after listRemoteFiles does not cost an extra API request.
|
|
295
|
+
* @returns {Promise<string[]>}
|
|
151
296
|
*/
|
|
152
|
-
async function
|
|
153
|
-
const prefix = `hub/${name}/`;
|
|
297
|
+
async function listProfileNames() {
|
|
154
298
|
const tree = await getTree();
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
for (const e of tree) {
|
|
301
|
+
if (!e.path.startsWith('hub/')) continue;
|
|
302
|
+
const seg = e.path.slice('hub/'.length).split('/')[0];
|
|
303
|
+
// Directories prefixed with `_` (e.g. `_shared`) hold bridge utilities,
|
|
304
|
+
// not profiles — exclude them from listings and "did you mean" suggestions.
|
|
305
|
+
if (seg && !seg.startsWith('_') && !seen.has(seg)) seen.add(seg);
|
|
306
|
+
}
|
|
307
|
+
return [...seen].sort();
|
|
161
308
|
}
|
|
162
309
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Lightweight "did you mean" hint using edit distance + prefix matching.
|
|
312
|
+
* Returns the best candidate name, or '' if none is close enough.
|
|
313
|
+
*
|
|
314
|
+
* Two paths to a match:
|
|
315
|
+
* 1. Prefix match — `claude` matches `claude-code`, `echo` matches
|
|
316
|
+
* `echo-agent`. This is the common typo pattern (user forgot a suffix).
|
|
317
|
+
* Only accepts an unambiguous prefix — if multiple candidates share
|
|
318
|
+
* the prefix, none is returned (better no suggestion than a wrong one).
|
|
319
|
+
* 2. Edit distance — tolerate ~1/3 of the input length in edits. Catches
|
|
320
|
+
* transpositions (`calude`) and small typos (`coudex` → `codex`).
|
|
321
|
+
*/
|
|
322
|
+
function suggestCloseName(input, candidates) {
|
|
323
|
+
if (!input || !candidates || candidates.length === 0) return '';
|
|
324
|
+
|
|
325
|
+
const n = input.toLowerCase();
|
|
326
|
+
|
|
327
|
+
// Path 1: unique prefix match.
|
|
328
|
+
const prefixMatches = candidates.filter(c => c.toLowerCase().startsWith(n));
|
|
329
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
330
|
+
|
|
331
|
+
// Path 2: edit distance. Threshold scales with input length:
|
|
332
|
+
// - short (≤6): allow 1 edit (typos in `agy`, `codex`)
|
|
333
|
+
// - medium (7-12): allow 2 edits (transpositions in `calude-code`)
|
|
334
|
+
// - long (>12): allow 3 edits
|
|
335
|
+
const threshold = input.length <= 6 ? 1 : input.length <= 12 ? 2 : 3;
|
|
336
|
+
let best = '';
|
|
337
|
+
let bestDist = Infinity;
|
|
338
|
+
for (const c of candidates) {
|
|
339
|
+
const dist = editDistance(n, c.toLowerCase());
|
|
340
|
+
if (dist < bestDist) { bestDist = dist; best = c; }
|
|
341
|
+
}
|
|
342
|
+
if (best && bestDist <= threshold) return best;
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function editDistance(a, b) {
|
|
347
|
+
const m = a.length, n = b.length;
|
|
348
|
+
if (m === 0) return n;
|
|
349
|
+
if (n === 0) return m;
|
|
350
|
+
const prev = new Array(n + 1);
|
|
351
|
+
const curr = new Array(n + 1);
|
|
352
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
353
|
+
for (let i = 1; i <= m; i++) {
|
|
354
|
+
curr[0] = i;
|
|
355
|
+
for (let j = 1; j <= n; j++) {
|
|
356
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
357
|
+
curr[j] = Math.min(
|
|
358
|
+
prev[j] + 1, // deletion
|
|
359
|
+
curr[j - 1] + 1, // insertion
|
|
360
|
+
prev[j - 1] + cost // substitution
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j];
|
|
364
|
+
}
|
|
365
|
+
return prev[n];
|
|
167
366
|
}
|
|
168
367
|
|
|
169
368
|
// ---------------------------------------------------------------------------
|
|
170
369
|
// Public API
|
|
171
370
|
// ---------------------------------------------------------------------------
|
|
172
371
|
|
|
372
|
+
// Every hub profile is this fixed set of files (see hub/README.md):
|
|
373
|
+
// profile.yaml (required) + bridge.py + bridge.js + README.md,
|
|
374
|
+
// with echo-agent additionally shipping bridge.sh. We fetch them directly
|
|
375
|
+
// via raw.githubusercontent.com (CDN, not rate-limited) so `hub run` never
|
|
376
|
+
// calls the GitHub Trees API in the happy path. If a future profile adds a
|
|
377
|
+
// new file type, extend this list.
|
|
378
|
+
const PROFILE_FILE_CANDIDATES = [
|
|
379
|
+
'profile.yaml', 'bridge.py', 'bridge.js', 'bridge.sh', 'README.md',
|
|
380
|
+
];
|
|
381
|
+
|
|
173
382
|
/**
|
|
174
383
|
* Fetch a profile directory to local cache. Returns the cache path.
|
|
175
384
|
*
|
|
385
|
+
* Fetches files directly via raw.githubusercontent.com (CDN, not
|
|
386
|
+
* rate-limited) — no GitHub Trees API call in the happy path. Only an
|
|
387
|
+
* unknown profile name (profile.yaml 404) falls back to the disk-cached
|
|
388
|
+
* tree to produce a "did you mean" suggestion.
|
|
389
|
+
*
|
|
176
390
|
* @param {string} name
|
|
177
391
|
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
178
392
|
* @returns {Promise<string>} absolute cache path
|
|
@@ -180,9 +394,8 @@ async function downloadFile(remotePath, localPath) {
|
|
|
180
394
|
async function fetchProfile(name, opts = {}) {
|
|
181
395
|
const { refresh = false, onLog = null } = opts;
|
|
182
396
|
|
|
183
|
-
// On refresh,
|
|
184
|
-
|
|
185
|
-
if (refresh) _treeCache = null;
|
|
397
|
+
// On refresh, clear the tree cache so we see newly-added profiles.
|
|
398
|
+
if (refresh) clearTreeCache();
|
|
186
399
|
|
|
187
400
|
const age = cacheAgeSecs(name);
|
|
188
401
|
const dir = cacheDir(name);
|
|
@@ -201,21 +414,41 @@ async function fetchProfile(name, opts = {}) {
|
|
|
201
414
|
}
|
|
202
415
|
}
|
|
203
416
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
417
|
+
// Probe profile.yaml via raw URL. raw.githubusercontent.com is CDN-backed
|
|
418
|
+
// and not subject to the 60/hr anonymous API limit, so this does not burn
|
|
419
|
+
// rate-limit budget.
|
|
420
|
+
const probe = await httpGetTextOptional(GITHUB_RAW(`hub/${name}/profile.yaml`));
|
|
421
|
+
if (probe === null) {
|
|
422
|
+
// profile.yaml 404 → the name is wrong. Fall back to the (disk-cached)
|
|
423
|
+
// tree to produce a "did you mean" suggestion. This is the only path
|
|
424
|
+
// that may call the Trees API for `hub run`, and it's cached for 24h.
|
|
425
|
+
const known = await listProfileNames();
|
|
426
|
+
const suggestion = suggestCloseName(name, known);
|
|
427
|
+
const hint = suggestion
|
|
428
|
+
? [`Did you mean \`${suggestion}\`?`, '', 'Available profiles:', ...known.map(n => ` - ${n}`)].join('\n')
|
|
429
|
+
: ['Available profiles:', ...known.map(n => ` - ${n}`)].join('\n');
|
|
430
|
+
throw new HubError(`profile '${name}' not found in hub`, {
|
|
431
|
+
status: 404,
|
|
432
|
+
hint,
|
|
433
|
+
});
|
|
207
434
|
}
|
|
208
435
|
|
|
209
|
-
// Clear cache, then
|
|
436
|
+
// Clear cache, then download the candidate file set via raw URLs.
|
|
210
437
|
if (fs.existsSync(dir)) {
|
|
211
438
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
212
439
|
}
|
|
213
440
|
fs.mkdirSync(dir, { recursive: true });
|
|
214
441
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
442
|
+
// profile.yaml already fetched via the probe.
|
|
443
|
+
fs.writeFileSync(path.join(dir, 'profile.yaml'), probe, 'utf8');
|
|
444
|
+
if (onLog) onLog(` - profile.yaml`);
|
|
445
|
+
|
|
446
|
+
for (const fname of PROFILE_FILE_CANDIDATES) {
|
|
447
|
+
if (fname === 'profile.yaml') continue;
|
|
448
|
+
const text = await httpGetTextOptional(GITHUB_RAW(`hub/${name}/${fname}`));
|
|
449
|
+
if (text === null) continue; // optional file not present for this profile
|
|
450
|
+
fs.writeFileSync(path.join(dir, fname), text, 'utf8');
|
|
451
|
+
if (onLog) onLog(` - ${fname}`);
|
|
219
452
|
}
|
|
220
453
|
|
|
221
454
|
writeCacheMeta(name);
|
|
@@ -235,6 +468,9 @@ async function listProfiles(opts = {}) {
|
|
|
235
468
|
for (const entry of entries) {
|
|
236
469
|
if (entry.type !== 'dir') continue;
|
|
237
470
|
const name = entry.name;
|
|
471
|
+
// Skip utility directories like `_shared/` — they hold shared bridge
|
|
472
|
+
// helpers, not a runnable profile (no profile.yaml).
|
|
473
|
+
if (name.startsWith('_')) continue;
|
|
238
474
|
try {
|
|
239
475
|
const yamlText = await httpGetText(GITHUB_RAW(`hub/${name}/profile.yaml`));
|
|
240
476
|
const { parseYaml } = require('./cli.js');
|
|
@@ -300,9 +536,11 @@ module.exports = {
|
|
|
300
536
|
HUB_REPO,
|
|
301
537
|
HUB_REF,
|
|
302
538
|
HUB_CACHE_TTL_SECS,
|
|
539
|
+
HubError,
|
|
303
540
|
cacheRoot,
|
|
304
541
|
cacheDir,
|
|
305
542
|
cacheAgeSecs,
|
|
543
|
+
clearTreeCache,
|
|
306
544
|
fetchProfile,
|
|
307
545
|
listProfiles,
|
|
308
546
|
showReadme,
|
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' },
|
|
@@ -105,7 +109,11 @@ function installFakeFetch(tree = FAKE_TREE, contents = FAKE_FILE_CONTENTS) {
|
|
|
105
109
|
return { ok: true, text: async () => content };
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
|
-
|
|
112
|
+
// Unmatched raw URL → 404. This is now legitimate: `hub run` probes a
|
|
113
|
+
// fixed candidate file set via raw URLs, and optional files (e.g.
|
|
114
|
+
// bridge.sh on a non-echo profile) or a wrong profile name legitimately
|
|
115
|
+
// 404 without burning GitHub's rate-limited Trees API.
|
|
116
|
+
return { ok: false, status: 404, text: async () => '' };
|
|
109
117
|
};
|
|
110
118
|
counter.restore = () => { global.fetch = orig; };
|
|
111
119
|
return counter;
|
|
@@ -179,6 +187,35 @@ describe('hub', { concurrency: false }, () => {
|
|
|
179
187
|
}
|
|
180
188
|
});
|
|
181
189
|
|
|
190
|
+
test('happy path does not call the rate-limited Trees API', async () => {
|
|
191
|
+
// `hub run` fetches profile files via raw.githubusercontent.com (CDN,
|
|
192
|
+
// not rate-limited). A known profile must not trigger any api.github.com
|
|
193
|
+
// call — that's the whole point of the rate-limit fix.
|
|
194
|
+
const counter = installFakeFetch();
|
|
195
|
+
try {
|
|
196
|
+
await hub.fetchProfile('echo-agent');
|
|
197
|
+
assert.strictEqual(counter.json, 0);
|
|
198
|
+
} finally {
|
|
199
|
+
counter.restore();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('skips optional files that 404 (e.g. bridge.sh on claude-code)', async () => {
|
|
204
|
+
const counter = installFakeFetch();
|
|
205
|
+
try {
|
|
206
|
+
const dir = await hub.fetchProfile('claude-code');
|
|
207
|
+
const names = fs.readdirSync(dir);
|
|
208
|
+
assert.ok(names.includes('profile.yaml'));
|
|
209
|
+
assert.ok(names.includes('bridge.py'));
|
|
210
|
+
assert.ok(names.includes('bridge.js'));
|
|
211
|
+
assert.ok(names.includes('README.md'));
|
|
212
|
+
// claude-code has no bridge.sh — the 404 is swallowed, not stored.
|
|
213
|
+
assert.ok(!names.includes('bridge.sh'));
|
|
214
|
+
} finally {
|
|
215
|
+
counter.restore();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
182
219
|
test('unknown profile raises', async () => {
|
|
183
220
|
const counter = installFakeFetch([{ path: 'hub', type: 'tree' }]);
|
|
184
221
|
try {
|
|
@@ -205,9 +242,11 @@ describe('hub', { concurrency: false }, () => {
|
|
|
205
242
|
const counter = installFakeFetch();
|
|
206
243
|
try {
|
|
207
244
|
await hub.fetchProfile('echo-agent');
|
|
208
|
-
const
|
|
245
|
+
const firstText = counter.text;
|
|
209
246
|
await hub.fetchProfile('echo-agent', { refresh: true });
|
|
210
|
-
|
|
247
|
+
// hub run fetches files via raw URLs (CDN), not the rate-limited
|
|
248
|
+
// Trees API — so a refresh re-fetches the file set, not the tree.
|
|
249
|
+
assert.ok(counter.text > firstText);
|
|
211
250
|
} finally {
|
|
212
251
|
counter.restore();
|
|
213
252
|
}
|
|
@@ -245,6 +284,38 @@ describe('hub', { concurrency: false }, () => {
|
|
|
245
284
|
counter.restore();
|
|
246
285
|
}
|
|
247
286
|
});
|
|
287
|
+
|
|
288
|
+
test('skips underscore-prefixed utility dirs like _shared', async () => {
|
|
289
|
+
// _shared/ has no profile.yaml; it must not appear in the listing and
|
|
290
|
+
// must not trigger a "could not read metadata" warning/fetch.
|
|
291
|
+
const counter = installFakeFetch();
|
|
292
|
+
try {
|
|
293
|
+
const profiles = await hub.listProfiles({ refresh: true });
|
|
294
|
+
const names = profiles.map(p => p.name);
|
|
295
|
+
assert.ok(!names.some(n => n.startsWith('_')),
|
|
296
|
+
`utility dir leaked into listing: ${names}`);
|
|
297
|
+
} finally {
|
|
298
|
+
counter.restore();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('disk-caches the tree so repeat calls skip the Trees API', async () => {
|
|
303
|
+
// The first listProfiles hits the Trees API (counter.json 0→1) and
|
|
304
|
+
// writes ~/.agentproc/cache/hub/tree.json. A second call in the same
|
|
305
|
+
// process reuses the cached tree and must not make another API call.
|
|
306
|
+
const counter = installFakeFetch();
|
|
307
|
+
try {
|
|
308
|
+
hub.clearTreeCache(); // reset in-memory + disk from prior tests
|
|
309
|
+
await hub.listProfiles();
|
|
310
|
+
assert.strictEqual(counter.json, 1);
|
|
311
|
+
const treeCache = path.join(hub.cacheRoot(), 'tree.json');
|
|
312
|
+
assert.ok(fs.existsSync(treeCache), 'tree.json not written to disk');
|
|
313
|
+
await hub.listProfiles();
|
|
314
|
+
assert.strictEqual(counter.json, 1, 'second call hit the Trees API again');
|
|
315
|
+
} finally {
|
|
316
|
+
counter.restore();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
248
319
|
});
|
|
249
320
|
|
|
250
321
|
// ----- 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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
104
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
//
|
|
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
|