@prohost/cli 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/CHANGELOG.md +85 -0
- package/README.md +55 -14
- package/dist/agent/codex.d.ts +83 -2
- package/dist/agent/codex.js +103 -4
- package/dist/agent/command.d.ts +24 -0
- package/dist/agent/command.js +85 -18
- package/dist/agent/contract.d.ts +20 -7
- package/dist/agent/contract.js +18 -7
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +35 -0
- package/dist/agent/exec.js +100 -3
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +56 -19
- package/dist/agent/run.d.ts +39 -11
- package/dist/agent/run.js +116 -25
- package/dist/index.js +9 -5
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/run.js
CHANGED
|
@@ -26,8 +26,8 @@ import os from 'node:os';
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import WebSocket from 'ws';
|
|
28
28
|
import { completeRun, postReply } from './api.js';
|
|
29
|
-
import { claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
30
|
-
import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax,
|
|
29
|
+
import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
30
|
+
import { MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
31
31
|
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
32
32
|
import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
|
|
33
33
|
import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
|
|
@@ -41,18 +41,36 @@ import { verifySignature } from './signature.js';
|
|
|
41
41
|
import { isWebhookEvent } from '../forward.js';
|
|
42
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
43
43
|
/**
|
|
44
|
-
* Default ceiling on one agent invocation.
|
|
44
|
+
* Default ceiling on one agent invocation: none.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* and
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
46
|
+
* The wall clock kept being raised — five minutes, then two hours — because it
|
|
47
|
+
* was answering the wrong question. "How long has this run been going?" does
|
|
48
|
+
* not distinguish a wedged child from a productive one; it only distinguishes
|
|
49
|
+
* long work from short, and killing long work is precisely the failure the two
|
|
50
|
+
* previous defaults each produced in production. What the harness can actually
|
|
51
|
+
* observe is whether the child is *doing* anything, which is what
|
|
52
|
+
* {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
|
|
53
|
+
*
|
|
54
|
+
* So a run is now bounded by three things, none of them a fixed duration:
|
|
55
|
+
* the inactivity watchdog (default 15 minutes of total silence), the server's
|
|
56
|
+
* lease on the run (heartbeats stop, the row is reaped), and the Stop button in
|
|
57
|
+
* ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
|
|
58
|
+
* remains fully supported for anyone who wants a hard ceiling as well.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
61
|
+
/**
|
|
62
|
+
* Default silence window before a run is treated as stalled.
|
|
63
|
+
*
|
|
64
|
+
* Fifteen minutes is chosen against what real work looks like, not against
|
|
65
|
+
* what feels responsive: an agent mid-task emits stream events, tool calls and
|
|
66
|
+
* log lines continuously, so the gaps in a healthy run are seconds. The long
|
|
67
|
+
* tail that legitimately goes quiet is a single slow tool call — a test suite,
|
|
68
|
+
* a build, a big fetch — and fifteen minutes clears those comfortably while
|
|
69
|
+
* still catching the failure this exists for, a child wedged on a stdin prompt
|
|
70
|
+
* that heartbeats look perfectly healthy for and that burns the operator's
|
|
71
|
+
* tokens and their agent's availability indefinitely.
|
|
54
72
|
*/
|
|
55
|
-
export const
|
|
73
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
|
|
56
74
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
57
75
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
58
76
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
@@ -311,19 +329,28 @@ function codexMcpEntries(options, extraServers, log, workdir) {
|
|
|
311
329
|
if (acceptedExtraEntries.length === 0)
|
|
312
330
|
acceptedExtraNames.clear();
|
|
313
331
|
const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
|
|
314
|
-
const { entries: disabled, failed } =
|
|
332
|
+
const { entries: disabled, configOverrides, failed } = isolateConfiguredServers(binary, probe, targets, workdir);
|
|
315
333
|
if (failed.length > 0) {
|
|
316
|
-
|
|
334
|
+
// The remedy belongs in the message. This string is echoed into the thread
|
|
335
|
+
// the teammate is waiting in, and "could not be disabled" on its own leaves
|
|
336
|
+
// them with a run that will not start and nothing to do about it.
|
|
337
|
+
return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')} — remove them from ` +
|
|
338
|
+
'~/.codex/config.toml, or run the daemon with a dedicated CODEX_HOME containing only auth.json');
|
|
317
339
|
}
|
|
318
340
|
entries.push(...disabled);
|
|
319
|
-
if (
|
|
341
|
+
if (targets.length > 0) {
|
|
320
342
|
log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
|
|
321
343
|
}
|
|
344
|
+
if (configOverrides.includes(PLUGINS_DISABLED_OVERRIDE)) {
|
|
345
|
+
log(`${timestamp()} ⚙ Codex plugins are switched off for agent runs — a plugin supplies an MCP ` +
|
|
346
|
+
'server that cannot be disabled on its own');
|
|
347
|
+
}
|
|
322
348
|
// Verify the exact combined override, not its pieces. This catches a config
|
|
323
349
|
// merge that re-enables a server and a server installed between the initial
|
|
324
350
|
// survey and this final probe.
|
|
325
351
|
const override = mcpServersOverride(entries);
|
|
326
|
-
const
|
|
352
|
+
const runOverrides = [...configOverrides, ...(override ? [override] : [])];
|
|
353
|
+
const finalListing = listMcpServers(binary, probe, runOverrides, workdir);
|
|
327
354
|
if (finalListing === null)
|
|
328
355
|
return fail('Codex rejected the final isolated MCP configuration');
|
|
329
356
|
const allowed = new Set([
|
|
@@ -341,6 +368,7 @@ function codexMcpEntries(options, extraServers, log, workdir) {
|
|
|
341
368
|
}
|
|
342
369
|
return {
|
|
343
370
|
codexMcpEntries: entries,
|
|
371
|
+
codexConfigOverrides: configOverrides.length > 0 ? configOverrides : undefined,
|
|
344
372
|
execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
|
|
345
373
|
mcpWired: Boolean(options.credentials.mcp_url),
|
|
346
374
|
};
|
|
@@ -365,6 +393,25 @@ function defaultProbe(command, cwd) {
|
|
|
365
393
|
});
|
|
366
394
|
return { status: result.status, stdout: result.stdout ?? '' };
|
|
367
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* What a killed plain command salvages: the last thing it printed.
|
|
398
|
+
*
|
|
399
|
+
* The streaming reader salvages the last thing the *agent* said, having parsed
|
|
400
|
+
* it out of the event stream. There is no parsing to do for a plain `--exec`
|
|
401
|
+
* command — its stdout is its reply — so its rolling tail is the same evidence
|
|
402
|
+
* reached the other way: what the run had produced before it stopped producing
|
|
403
|
+
* anything.
|
|
404
|
+
*
|
|
405
|
+
* Bounded to the same {@link MAX_SALVAGE_CHARS} for the same reason: the
|
|
406
|
+
* completion `error` this ends up in is capped server-side, and it has a stderr
|
|
407
|
+
* tail to carry as well.
|
|
408
|
+
*/
|
|
409
|
+
function partialStdout(tail) {
|
|
410
|
+
const text = tail.trim();
|
|
411
|
+
if (!text)
|
|
412
|
+
return undefined;
|
|
413
|
+
return text.length > MAX_SALVAGE_CHARS ? text.slice(-MAX_SALVAGE_CHARS) : text;
|
|
414
|
+
}
|
|
368
415
|
/**
|
|
369
416
|
* Run the agent command once and turn its output into a reply.
|
|
370
417
|
*
|
|
@@ -380,10 +427,14 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
380
427
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
381
428
|
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
382
429
|
const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
383
|
-
// `--timeout 0
|
|
384
|
-
// ends
|
|
385
|
-
//
|
|
430
|
+
// `--timeout 0`, and the default: no wall-clock ceiling at all. The run then
|
|
431
|
+
// ends when the agent ends it, when the watchdog below finds it silent, when
|
|
432
|
+
// somebody cancels it, or when the server stops renewing its lease — all of
|
|
433
|
+
// which are better judges of "stuck" than a fixed clock.
|
|
386
434
|
const capped = budgetMs > 0;
|
|
435
|
+
// Not shared between attempts the way the wall clock is: the watchdog bounds
|
|
436
|
+
// silence, and a fresh attempt starts a fresh silence.
|
|
437
|
+
const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
387
438
|
const startedAt = Date.now();
|
|
388
439
|
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
389
440
|
const timeLeft = () => !capped || remainingMs() > 0;
|
|
@@ -398,6 +449,16 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
398
449
|
let recordedSession;
|
|
399
450
|
/** The reader for the attempt currently running, if this brain streams. */
|
|
400
451
|
let stream;
|
|
452
|
+
/**
|
|
453
|
+
* Rolling tail of a plain command's stdout — the last thing it printed.
|
|
454
|
+
*
|
|
455
|
+
* Kept as the output arrives rather than sliced off the captured `stdout` at
|
|
456
|
+
* the end, because capture is bounded at {@link MAX_OUTPUT_CHARS} from the
|
|
457
|
+
* *front*: a chatty command that overran that and then stalled would
|
|
458
|
+
* otherwise salvage half a megabyte of stale opening output and call it the
|
|
459
|
+
* unfinished answer.
|
|
460
|
+
*/
|
|
461
|
+
let tail = '';
|
|
401
462
|
/**
|
|
402
463
|
* Act on what the stream has said so far.
|
|
403
464
|
*
|
|
@@ -438,10 +499,12 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
438
499
|
// session, and inheriting the failed attempt's id would resume nothing.
|
|
439
500
|
const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
|
|
440
501
|
stream = reader;
|
|
502
|
+
tail = '';
|
|
441
503
|
return execAgent({
|
|
442
504
|
command,
|
|
443
505
|
prompt,
|
|
444
506
|
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
507
|
+
idleTimeoutMs: idleMs,
|
|
445
508
|
cwd: runtime.workdir,
|
|
446
509
|
env: mcp.execEnv,
|
|
447
510
|
spawnImpl: options.spawnImpl,
|
|
@@ -451,7 +514,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
451
514
|
reader.push(chunk);
|
|
452
515
|
observe(reader);
|
|
453
516
|
}
|
|
454
|
-
:
|
|
517
|
+
: runtime.brain === 'plain'
|
|
518
|
+
? (chunk) => {
|
|
519
|
+
tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
|
|
520
|
+
}
|
|
521
|
+
: undefined,
|
|
455
522
|
});
|
|
456
523
|
};
|
|
457
524
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
@@ -462,10 +529,28 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
462
529
|
}
|
|
463
530
|
const failure = (result) => {
|
|
464
531
|
// A killed run has no reply, but it usually has most of one. Reporting the
|
|
465
|
-
// tail of what the agent was writing turns "
|
|
532
|
+
// tail of what the agent was writing turns "no output for 15 minutes" into
|
|
466
533
|
// something the person waiting can act on — and it is the only trace left
|
|
467
|
-
// of however many hours of work.
|
|
468
|
-
|
|
534
|
+
// of however many hours of work. A stalled run is the case this matters
|
|
535
|
+
// most for: the last thing it said is the best clue to what it stalled on.
|
|
536
|
+
// A plain command has no stream to salvage a parsed answer from, but its
|
|
537
|
+
// stdout *is* its answer by contract — so the tail of what it managed to
|
|
538
|
+
// print is the same evidence, reached the other way. Without this a stalled
|
|
539
|
+
// plain run reports nothing but the fact that it stalled.
|
|
540
|
+
//
|
|
541
|
+
// Deliberately NOT extended to the shaped brains. Both are run with
|
|
542
|
+
// structured output (`--output-format stream-json`, `--json`), whose
|
|
543
|
+
// stream carries the agent's reasoning and the raw output of every local
|
|
544
|
+
// command and tool call it made — which `readClaudeStream`/
|
|
545
|
+
// `parseCodexOutput` exist to filter out. This string is persisted in the
|
|
546
|
+
// completion and echoed into a workspace thread, so tailing that stream
|
|
547
|
+
// raw would publish local shell output, and whatever was in it, to
|
|
548
|
+
// everyone who can read the conversation. A shaped brain whose parser
|
|
549
|
+
// salvaged nothing salvages nothing.
|
|
550
|
+
const killed = result.timedOut || result.idleTimedOut || result.cancelled;
|
|
551
|
+
const salvaged = killed
|
|
552
|
+
? (stream?.salvage() ?? (runtime.brain === 'plain' ? partialStdout(tail) : undefined))
|
|
553
|
+
: undefined;
|
|
469
554
|
// The stderr tail yields room when there is a salvaged answer to carry: the
|
|
470
555
|
// completion `error` is capped server-side, and of the two, what the agent
|
|
471
556
|
// was saying beats the last 500 characters of its logging.
|
|
@@ -498,6 +583,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
498
583
|
? codexCommand(options.exec, {
|
|
499
584
|
sessionId: resume,
|
|
500
585
|
mcpEntries: mcp.codexMcpEntries,
|
|
586
|
+
configOverrides: mcp.codexConfigOverrides,
|
|
501
587
|
skipPermissions: runtime.skipPermissions,
|
|
502
588
|
})
|
|
503
589
|
: claudeCommand(options.exec, {
|
|
@@ -525,7 +611,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
525
611
|
result = await exec(build());
|
|
526
612
|
}
|
|
527
613
|
if (!result.ok) {
|
|
528
|
-
|
|
614
|
+
// A run we killed keeps its session, whichever clock killed it: the
|
|
615
|
+
// transcript is real, it holds however long the agent worked, and the next
|
|
616
|
+
// delivery on that surface can resume it. Only a turn that failed on its
|
|
617
|
+
// own — a crash, an unparseable answer — has no continuity worth keeping.
|
|
618
|
+
if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
|
|
529
619
|
forgetRecordedSession();
|
|
530
620
|
return failure(result);
|
|
531
621
|
}
|
|
@@ -598,8 +688,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
598
688
|
// it is. See PromptContext.mcpToolPattern.
|
|
599
689
|
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
600
690
|
toolsGated: !runtime.skipPermissions,
|
|
601
|
-
// The real
|
|
691
|
+
// The real numbers, not a vague "there is a limit" — see PromptContext.
|
|
602
692
|
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
693
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
|
603
694
|
});
|
|
604
695
|
const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
|
|
605
696
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ function printHelp() {
|
|
|
25
25
|
'Usage:',
|
|
26
26
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
27
27
|
' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
|
|
28
|
-
" prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
28
|
+
" prohost agent run --exec '<command>' [--idle-timeout <seconds>] [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
29
29
|
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] Keep the agent running",
|
|
30
30
|
' prohost agent uninstall-daemon',
|
|
31
31
|
'',
|
|
@@ -43,9 +43,12 @@ function printHelp() {
|
|
|
43
43
|
'',
|
|
44
44
|
'agent run flags:',
|
|
45
45
|
' --exec Command to run for each mention; the prompt arrives on stdin (required)',
|
|
46
|
-
' --timeout
|
|
47
|
-
'
|
|
48
|
-
'
|
|
46
|
+
' --idle-timeout Seconds a run may produce NO output at all before it is treated as',
|
|
47
|
+
' stalled and killed. Defaults to 900 (15 minutes). Any byte on stdout or',
|
|
48
|
+
' stderr resets it, so a working agent never trips it. Pass 0 to disable.',
|
|
49
|
+
' --timeout Seconds one run may take before it is killed, however busy it is.',
|
|
50
|
+
' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
|
|
51
|
+
' one. Set it if you want a hard ceiling as well as the stall watchdog.',
|
|
49
52
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
50
53
|
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
51
54
|
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
|
@@ -71,7 +74,8 @@ function printHelp() {
|
|
|
71
74
|
'',
|
|
72
75
|
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
73
76
|
' --exec The same command you would pass to `agent run` (required).',
|
|
74
|
-
' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools
|
|
77
|
+
' --workdir, --idle-timeout, --timeout, --machine, --url, --allow-unverified, --safe-tools',
|
|
78
|
+
' — passed to `agent run`.',
|
|
75
79
|
'',
|
|
76
80
|
'Global flags:',
|
|
77
81
|
' --help, -h Show this help and exit.',
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.
|
|
10
|
+
export declare const CLI_VERSION = "0.4.1";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.4.1";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.
|
|
10
|
+
export const CLI_VERSION = '0.4.1';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|