@prohost/cli 0.1.0 → 0.2.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/CHANGELOG.md +99 -0
- package/README.md +161 -18
- package/dist/agent/api.d.ts +6 -1
- package/dist/agent/api.js +10 -2
- package/dist/agent/claude.d.ts +161 -0
- package/dist/agent/claude.js +336 -0
- package/dist/agent/command.js +169 -19
- package/dist/agent/contract.d.ts +57 -4
- package/dist/agent/contract.js +71 -0
- package/dist/agent/credentials.d.ts +40 -1
- package/dist/agent/credentials.js +76 -1
- package/dist/agent/daemon.d.ts +131 -0
- package/dist/agent/daemon.js +274 -0
- package/dist/agent/pair.d.ts +5 -1
- package/dist/agent/pair.js +41 -1
- package/dist/agent/prompt.d.ts +17 -2
- package/dist/agent/prompt.js +55 -3
- package/dist/agent/run.d.ts +13 -1
- package/dist/agent/run.js +211 -27
- package/dist/agent/sessionstore.d.ts +71 -0
- package/dist/agent/sessionstore.js +138 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -5
- package/dist/listen.d.ts +13 -1
- package/dist/listen.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/agent/pair.js
CHANGED
|
@@ -24,6 +24,25 @@ function isUuid(value) {
|
|
|
24
24
|
function baseFromMcpUrl(mcpUrl, fallback) {
|
|
25
25
|
return mcpUrl.endsWith('/mcp') ? mcpUrl.slice(0, -'/mcp'.length) : fallback;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The websocket URL the server told us to use, or ``undefined``.
|
|
29
|
+
*
|
|
30
|
+
* ``undefined`` covers both "this server predates the field" and "the value is
|
|
31
|
+
* not a websocket URL". A cosmetic bad value must not fail a redeem — the code
|
|
32
|
+
* is already spent by the time we read the body — so the caller falls back to
|
|
33
|
+
* {@link DEFAULT_WS_URL} and says so.
|
|
34
|
+
*/
|
|
35
|
+
function serverWsUrl(value) {
|
|
36
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const protocol = new URL(value).protocol;
|
|
40
|
+
return protocol === 'ws:' || protocol === 'wss:' ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
/**
|
|
28
47
|
* Redeem ``code`` and persist the resulting credentials.
|
|
29
48
|
*
|
|
@@ -33,7 +52,6 @@ export async function runPair(options) {
|
|
|
33
52
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
34
53
|
const onLine = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
|
|
35
54
|
const baseUrl = (options.baseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '');
|
|
36
|
-
const wsUrl = options.wsUrl ?? DEFAULT_WS_URL;
|
|
37
55
|
if (options.webhookUrl && !options.webhookUrl.startsWith('https://')) {
|
|
38
56
|
throw new PairError('--webhook-url must be an https:// URL');
|
|
39
57
|
}
|
|
@@ -111,6 +129,16 @@ export async function runPair(options) {
|
|
|
111
129
|
'redeem and has been removed.'
|
|
112
130
|
: ' Nothing was saved.'));
|
|
113
131
|
}
|
|
132
|
+
// Where the websocket host comes from, in order:
|
|
133
|
+
//
|
|
134
|
+
// 1. `--url` / `$PROHOST_WS_URL` — an explicit operator override, and the
|
|
135
|
+
// escape hatch that let the field work around 0.1.0's bad default.
|
|
136
|
+
// 2. The server's `ws_url`, derived server-side from the same setting as
|
|
137
|
+
// `mcp_url` (`shared/agents/connections.py::pairing_endpoints`). This is
|
|
138
|
+
// the normal path, and the reason a host move no longer needs a release.
|
|
139
|
+
// 3. `DEFAULT_WS_URL`, for a server that predates the field.
|
|
140
|
+
const fromServer = serverWsUrl(body.ws_url);
|
|
141
|
+
const wsUrl = options.wsUrl ?? fromServer ?? DEFAULT_WS_URL;
|
|
114
142
|
const credentials = {
|
|
115
143
|
version: CREDENTIALS_VERSION,
|
|
116
144
|
base_url: baseFromMcpUrl(body.mcp_url ?? '', baseUrl),
|
|
@@ -129,10 +157,22 @@ export async function runPair(options) {
|
|
|
129
157
|
onLine(` API key ${maskSecret(body.api_key)} (stored, never printed in full)`);
|
|
130
158
|
onLine(` Credentials ${file} (chmod 600)`);
|
|
131
159
|
onLine(` MCP URL ${body.mcp_url}`);
|
|
160
|
+
// Printed because 0.1.0's wrong websocket host was invisible here: pairing
|
|
161
|
+
// reported success and the mismatch only surfaced as a 403 reconnect loop in
|
|
162
|
+
// a different command. Showing it makes a bad host obvious at pair time.
|
|
163
|
+
onLine(` Stream URL ${credentials.ws_url}`);
|
|
132
164
|
onLine(` Events ${credentials.events.join(', ') || '(none)'}`);
|
|
133
165
|
onLine(` Delivery ${credentials.stream_only
|
|
134
166
|
? 'stream-only — events arrive over the websocket, nothing is POSTed to you'
|
|
135
167
|
: `over the websocket, and POSTed to ${options.webhookUrl ?? 'your registered endpoint'}`}`);
|
|
168
|
+
if (body.ws_url && !fromServer && !options.wsUrl) {
|
|
169
|
+
// Present but not a ws:// or wss:// URL. Don't fail the redeem over it —
|
|
170
|
+
// the code is spent — but don't let the fallback look like the server's
|
|
171
|
+
// answer either.
|
|
172
|
+
onLine('');
|
|
173
|
+
onLine(` ! The server reported an unusable stream URL (${JSON.stringify(body.ws_url)}).`);
|
|
174
|
+
onLine(` Using ${DEFAULT_WS_URL} instead — override with --url if that is wrong.`);
|
|
175
|
+
}
|
|
136
176
|
if (!credentials.webhook_secret) {
|
|
137
177
|
// Without a secret the runner can't prove an event came from ProhostAI,
|
|
138
178
|
// and a run event makes this machine execute a command — so it refuses to
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -10,8 +10,23 @@ import type { AgentRunRequest } from './contract.js';
|
|
|
10
10
|
export interface PromptContext {
|
|
11
11
|
/** Display name of the paired AI employee, e.g. "Ada". */
|
|
12
12
|
agentName: string;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* MCP server name the run's tools are registered under, e.g. `prohost`.
|
|
15
|
+
*
|
|
16
|
+
* Set **only** when the harness actually wired those tools into this
|
|
17
|
+
* invocation. Naming tools the child cannot call is worse than naming
|
|
18
|
+
* nothing: the agent reports having looked something up when it did not.
|
|
19
|
+
*/
|
|
20
|
+
mcpServerName?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Tools are behind permission prompts this run (`--safe-tools`).
|
|
23
|
+
*
|
|
24
|
+
* Changes what is true, so it has to change what the prompt says. Gated, a
|
|
25
|
+
* tool needing approval will never run and the agent must plan around that.
|
|
26
|
+
* Ungated — the default — it can act, and telling it otherwise would make it
|
|
27
|
+
* decline work it is perfectly able to do.
|
|
28
|
+
*/
|
|
29
|
+
toolsGated?: boolean;
|
|
15
30
|
}
|
|
16
31
|
/** Build the stdin prompt for one run. */
|
|
17
32
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
* a shell script that greps it. The reply contract is stated explicitly so an
|
|
7
7
|
* agent that would otherwise narrate its reasoning returns something sendable.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Render one line of thread history.
|
|
11
|
+
*
|
|
12
|
+
* The role is a parenthetical *qualifier* on a name, not a substitute for one:
|
|
13
|
+
* a row with a role and no author — which is what a system row like
|
|
14
|
+
* `{role: "event", text: "Reservation confirmed"}` looks like — reads as
|
|
15
|
+
* `event: …` rather than the nonsense `someone (event): …`.
|
|
16
|
+
*/
|
|
17
|
+
function renderMessage(message) {
|
|
18
|
+
const who = message.author ?? message.role ?? 'someone';
|
|
19
|
+
const role = message.author && message.role ? ` (${message.role})` : '';
|
|
20
|
+
const when = message.at ? `[${message.at}] ` : '';
|
|
21
|
+
return `${when}${who}${role}: ${message.text}`;
|
|
22
|
+
}
|
|
23
|
+
/** Render one remembered fact. */
|
|
24
|
+
function renderMemory(memory) {
|
|
25
|
+
return memory.scope ? `- (${memory.scope}) ${memory.content}` : `- ${memory.content}`;
|
|
26
|
+
}
|
|
9
27
|
/** Build the stdin prompt for one run. */
|
|
10
28
|
export function buildPrompt(run, context) {
|
|
11
29
|
const name = run.agent_name ?? context.agentName;
|
|
@@ -42,12 +60,18 @@ export function buildPrompt(run, context) {
|
|
|
42
60
|
// only handle the agent has on what was actually said.
|
|
43
61
|
if (run.trigger_message_id)
|
|
44
62
|
facts.push(`Comment/message id: ${run.trigger_message_id}`);
|
|
45
|
-
if (context.mcpUrl) {
|
|
46
|
-
facts.push(`ProhostAI MCP endpoint (for looking things up): ${context.mcpUrl}`);
|
|
47
|
-
}
|
|
48
63
|
if (facts.length > 0) {
|
|
49
64
|
lines.push('Context:', ...facts.map((f) => `- ${f}`), '');
|
|
50
65
|
}
|
|
66
|
+
// Memories first, then the thread, then the message that woke the agent:
|
|
67
|
+
// oldest and most general context at the top, the thing being answered last
|
|
68
|
+
// and closest to the instruction.
|
|
69
|
+
if (run.memories?.length) {
|
|
70
|
+
lines.push('Things you remember (from earlier work in this workspace):', ...run.memories.map(renderMemory), '');
|
|
71
|
+
}
|
|
72
|
+
if (run.recent_messages?.length) {
|
|
73
|
+
lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
|
|
74
|
+
}
|
|
51
75
|
if (run.trigger_text) {
|
|
52
76
|
// The server caps this excerpt, so say so rather than let an agent assume
|
|
53
77
|
// it has the whole message.
|
|
@@ -56,6 +80,34 @@ export function buildPrompt(run, context) {
|
|
|
56
80
|
else {
|
|
57
81
|
lines.push('The message text was not included in the event.', '');
|
|
58
82
|
}
|
|
83
|
+
// An agent that doesn't know it is headless narrates as if it were mid-
|
|
84
|
+
// session — "let me connect to that", "I'll check once the tools load" —
|
|
85
|
+
// and that text is what gets posted to a customer, describing a lookup that
|
|
86
|
+
// never happened. Saying the shape of the invocation out loud is the cheapest
|
|
87
|
+
// fix; the alternative is a reply that is confidently wrong about its own
|
|
88
|
+
// capabilities.
|
|
89
|
+
lines.push('How you are running:', '- This is a single non-interactive turn. Nobody is at a terminal, so', context.toolsGated
|
|
90
|
+
? ' any tool that would need approval will not run — plan around that\n' +
|
|
91
|
+
' rather than asking for permission, which gets no answer.'
|
|
92
|
+
: ' nothing will stop to ask you for permission: your tools run as soon\n' +
|
|
93
|
+
' as you call them. Do the work rather than proposing it — but that\n' +
|
|
94
|
+
' also means a mistaken action happens for real, so check before you\n' +
|
|
95
|
+
' change or delete anything.', '- Nothing about your access changes after this turn. Do not say a tool is', ' "connecting", "loading", or about to become available, and do not', ' promise to follow up.', "- State limits plainly. If you cannot look something up, say what you don't", ' know and answer with what you do — a short honest answer beats a', ' confident invented one.',
|
|
96
|
+
// Deliberately does not list which domains are reachable. A paired
|
|
97
|
+
// credential holds a narrow, server-chosen scope set — the current default
|
|
98
|
+
// covers conversations, tasks, cleanings and approvals but NOT reservations
|
|
99
|
+
// or listings — and any list written here is a promise this CLI cannot
|
|
100
|
+
// keep across pairings or across changes to the default scopes. Naming
|
|
101
|
+
// domains the credential can't reach is the same failure as naming an MCP
|
|
102
|
+
// endpoint it can't call: the agent reports a lookup it never made.
|
|
103
|
+
context.mcpServerName
|
|
104
|
+
? `- You have ProhostAI tools (mcp__${context.mcpServerName}__*). They are the sanctioned ` +
|
|
105
|
+
'way to look up workspace data, so use them rather than guessing. Your credential is ' +
|
|
106
|
+
'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
|
|
107
|
+
'a permission error, that is a real limit rather than a glitch — say what you could not ' +
|
|
108
|
+
'look up instead of retrying it or answering as if you had.'
|
|
109
|
+
: '- You have no ProhostAI tools in this run. Everything you know about this ' +
|
|
110
|
+
'workspace is what is written above.', '');
|
|
59
111
|
// Postability is a separate question from what the run is about: a task run
|
|
60
112
|
// has no comment route in the external API yet, so the agent must be told its
|
|
61
113
|
// output won't be posted rather than left to assume it will.
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -36,7 +36,19 @@ export interface AgentRunOptions {
|
|
|
36
36
|
dryRun?: boolean;
|
|
37
37
|
/** Execute runs whose signature could not be verified. Off by default. */
|
|
38
38
|
allowUnverified?: boolean;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Gate the agent's tools behind permission prompts instead of bypassing them.
|
|
41
|
+
*
|
|
42
|
+
* Off by default: a paired brain is hired to act, and in print mode there is
|
|
43
|
+
* nobody to approve anything, so a gated agent silently declines to do the
|
|
44
|
+
* work. `agent run --safe-tools` turns this on. Claude Code only — no other
|
|
45
|
+
* `--exec` command is shaped at all.
|
|
46
|
+
*/
|
|
47
|
+
safeTools?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Working directory for the agent command. Defaults to
|
|
50
|
+
* ``$PROHOST_HOME/workspace`` — see {@link workspacePath} for why not "here".
|
|
51
|
+
*/
|
|
40
52
|
cwd?: string;
|
|
41
53
|
onLine?: (line: string) => void;
|
|
42
54
|
webSocketCtor?: typeof WebSocket;
|
package/dist/agent/run.js
CHANGED
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import WebSocket from 'ws';
|
|
22
22
|
import { completeRun, postReply } from './api.js';
|
|
23
|
+
import { MCP_SERVER_NAME, claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
|
|
23
24
|
import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
|
|
25
|
+
import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
|
|
24
26
|
import { execAgent } from './exec.js';
|
|
25
27
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
26
28
|
import { RunStore } from './runstore.js';
|
|
29
|
+
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
27
30
|
import { verifySignature } from './signature.js';
|
|
28
31
|
import { isWebhookEvent } from '../forward.js';
|
|
29
32
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
@@ -84,34 +87,192 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
|
84
87
|
}
|
|
85
88
|
return result;
|
|
86
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Decide where the agent command runs.
|
|
92
|
+
*
|
|
93
|
+
* The default is a dedicated directory rather than the CLI's own cwd, because
|
|
94
|
+
* coding agents treat their working directory as configuration — a `CLAUDE.md`,
|
|
95
|
+
* a `.claude/settings.json` permission list, a project `.mcp.json`. Started
|
|
96
|
+
* from a checkout of some unrelated service (which is how this was first run in
|
|
97
|
+
* production), the agent picks up that repo's instructions and denials and
|
|
98
|
+
* answers a customer as if it were working on that codebase.
|
|
99
|
+
*
|
|
100
|
+
* A home directory we can't create in costs the isolation, not the run: fall
|
|
101
|
+
* back to inheriting the CLI's cwd and say what happened.
|
|
102
|
+
*/
|
|
103
|
+
function resolveWorkdir(options, log) {
|
|
104
|
+
if (options.cwd)
|
|
105
|
+
return options.cwd;
|
|
106
|
+
try {
|
|
107
|
+
return ensureWorkspace(options.env);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log(`${timestamp()} ! could not create ${workspacePath(options.env)} ` +
|
|
111
|
+
`(${err instanceof Error ? err.message : String(err)}); running in the current directory ` +
|
|
112
|
+
'instead, which means the agent inherits any agent config it finds there');
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Assemble the per-process runtime: workspace, session ledger, and — for Claude
|
|
118
|
+
* Code only — the MCP config that gives the agent tools.
|
|
119
|
+
*
|
|
120
|
+
* Every step here is allowed to fail without stopping the harness. An agent
|
|
121
|
+
* that answers from the prompt alone is the product as it shipped; refusing to
|
|
122
|
+
* start because a config file couldn't be written would be a regression
|
|
123
|
+
* dressed up as a safety check.
|
|
124
|
+
*/
|
|
125
|
+
function buildRuntime(options, log) {
|
|
126
|
+
const workdir = resolveWorkdir(options, log);
|
|
127
|
+
const sessions = SessionStore.load(options.env, log);
|
|
128
|
+
const claude = isClaudeExec(options.exec);
|
|
129
|
+
const skipPermissions = options.safeTools !== true;
|
|
130
|
+
if (!claude)
|
|
131
|
+
return { workdir, sessions, claude, skipPermissions };
|
|
132
|
+
let mcpConfigPath;
|
|
133
|
+
const mcpUrl = options.credentials.mcp_url;
|
|
134
|
+
if (mcpUrl) {
|
|
135
|
+
try {
|
|
136
|
+
mcpConfigPath = writeMcpConfig({
|
|
137
|
+
mcpUrl,
|
|
138
|
+
apiKey: options.credentials.api_key,
|
|
139
|
+
env: options.env,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// Said out loud rather than swallowed: the visible symptom would
|
|
144
|
+
// otherwise be an agent that answers "I can't look that up", which reads
|
|
145
|
+
// like a model problem and sends someone to the wrong file.
|
|
146
|
+
log(`${timestamp()} ! could not write the MCP config ` +
|
|
147
|
+
`(${err instanceof Error ? err.message : String(err)}); the agent will run without ` +
|
|
148
|
+
'ProhostAI tools');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
|
|
153
|
+
}
|
|
154
|
+
log(`${timestamp()} ⚙ Claude Code detected — resuming a session per conversation` +
|
|
155
|
+
(mcpConfigPath ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '') +
|
|
156
|
+
`${workdir ? ` (workspace: ${workdir})` : ''}`);
|
|
157
|
+
// Said out loud every start-up, not buried in --help. This is the line that
|
|
158
|
+
// tells an operator their machine will run tool calls, unprompted, on text
|
|
159
|
+
// written by someone else in their workspace.
|
|
160
|
+
log(skipPermissions
|
|
161
|
+
? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
|
|
162
|
+
'(--dangerously-skip-permissions). The agent acts as you, with your local ' +
|
|
163
|
+
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
164
|
+
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
165
|
+
'approval will not run (nobody is at a terminal to approve it).');
|
|
166
|
+
return { workdir, sessions, claude, mcpConfigPath, skipPermissions };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Run the agent command once and turn its output into a reply.
|
|
170
|
+
*
|
|
171
|
+
* The two branches are the whole point of the split: a plain command's stdout
|
|
172
|
+
* *is* the reply (the contract every other agent speaks), while Claude Code in
|
|
173
|
+
* print mode is asked for JSON so the harness can pull out the session id and
|
|
174
|
+
* tell a failed turn from a successful one. A plain command's behaviour here is
|
|
175
|
+
* byte-for-byte what it was before this function existed.
|
|
176
|
+
*/
|
|
177
|
+
async function runTurn(run, prompt, options, runtime, log) {
|
|
178
|
+
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
179
|
+
// must not let one run take twice what the operator allowed. Both attempts
|
|
180
|
+
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
181
|
+
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
182
|
+
const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
183
|
+
const startedAt = Date.now();
|
|
184
|
+
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
185
|
+
const exec = (command) => execAgent({
|
|
186
|
+
command,
|
|
187
|
+
prompt,
|
|
188
|
+
timeoutMs: remainingMs(),
|
|
189
|
+
cwd: runtime.workdir,
|
|
190
|
+
spawnImpl: options.spawnImpl,
|
|
191
|
+
});
|
|
192
|
+
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
193
|
+
// rather than what the last attempt took.
|
|
194
|
+
const elapsed = () => Date.now() - startedAt;
|
|
195
|
+
const failure = (result) => ({
|
|
196
|
+
ok: false,
|
|
197
|
+
reason: result.error ??
|
|
198
|
+
(result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
|
|
199
|
+
detail: result.stderr.trim().slice(-500),
|
|
200
|
+
durationMs: elapsed(),
|
|
201
|
+
});
|
|
202
|
+
if (!runtime.claude) {
|
|
203
|
+
const result = await exec(options.exec);
|
|
204
|
+
if (!result.ok)
|
|
205
|
+
return failure(result);
|
|
206
|
+
const reply = replyFromStdout(result.stdout);
|
|
207
|
+
return reply
|
|
208
|
+
? { ok: true, reply, durationMs: elapsed() }
|
|
209
|
+
: { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
|
|
210
|
+
}
|
|
211
|
+
// No session bookkeeping on a dry run. `--dry-run` promises to make no
|
|
212
|
+
// writes, and a resumed turn is a write: Claude Code records it in the
|
|
213
|
+
// transcript whether or not we post the reply, so remembering the session
|
|
214
|
+
// would leave the next *real* run continuing from an answer nobody saw.
|
|
215
|
+
const key = options.dryRun ? undefined : sessionKeyFor(run);
|
|
216
|
+
const sessionId = runtime.sessions.get(key);
|
|
217
|
+
const shape = { mcpConfigPath: runtime.mcpConfigPath, skipPermissions: runtime.skipPermissions };
|
|
218
|
+
let result = await exec(claudeCommand(options.exec, { ...shape, sessionId }));
|
|
219
|
+
// A remembered session may simply be gone — transcripts are cleaned up and
|
|
220
|
+
// the ledger can't know. One retry without it, because the alternative is a
|
|
221
|
+
// surface that fails every run forever over an optimisation.
|
|
222
|
+
if (!result.ok &&
|
|
223
|
+
sessionId &&
|
|
224
|
+
looksLikeResumeFailure(`${result.stderr}\n${result.stdout}`, sessionId) &&
|
|
225
|
+
// Skipped rather than squeezed into a token budget: a retry with no time
|
|
226
|
+
// left would be killed on the spot and reported as a timeout, which is a
|
|
227
|
+
// worse error message than the resume failure it replaced.
|
|
228
|
+
remainingMs() > 0) {
|
|
229
|
+
log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
|
|
230
|
+
runtime.sessions.forget(key);
|
|
231
|
+
result = await exec(claudeCommand(options.exec, shape));
|
|
232
|
+
}
|
|
233
|
+
if (!result.ok)
|
|
234
|
+
return failure(result);
|
|
235
|
+
const parsed = parseClaudeOutput(result.stdout);
|
|
236
|
+
// A denied tool is not an error the agent reports — the turn succeeds and the
|
|
237
|
+
// reply just quietly lacks whatever the tool would have found. Printing it is
|
|
238
|
+
// the difference between "the agent seems dumb" and "the agent asked for
|
|
239
|
+
// WebSearch and there was nobody to approve it".
|
|
240
|
+
if (parsed.denials.length > 0) {
|
|
241
|
+
log(` ! tools the agent asked for and was refused: ${parsed.denials.join(', ')}`);
|
|
242
|
+
}
|
|
243
|
+
if (!parsed.ok) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: 'the agent did not produce a usable reply',
|
|
247
|
+
detail: parsed.error,
|
|
248
|
+
durationMs: elapsed(),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, reply: parsed.text, durationMs: elapsed(), sessionId: parsed.sessionId };
|
|
252
|
+
}
|
|
87
253
|
/**
|
|
88
254
|
* Handle one verified run end-to-end.
|
|
89
255
|
*
|
|
90
256
|
* Every path except a dry run — including every failure — calls the completion
|
|
91
257
|
* endpoint before returning.
|
|
92
258
|
*/
|
|
93
|
-
async function handleRun(run, options, api, log, sleep) {
|
|
259
|
+
async function handleRun(run, options, runtime, api, log, sleep) {
|
|
94
260
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
95
261
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
96
262
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
97
263
|
const prompt = buildPrompt(run, {
|
|
98
264
|
agentName: options.credentials.agent?.name ?? 'your agent',
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
105
|
-
cwd: options.cwd,
|
|
106
|
-
spawnImpl: options.spawnImpl,
|
|
265
|
+
// Named only when the tools are actually reachable from this invocation.
|
|
266
|
+
// Telling an agent about an endpoint it has no client for is what produced
|
|
267
|
+
// replies describing lookups that never happened.
|
|
268
|
+
mcpServerName: runtime.mcpConfigPath ? MCP_SERVER_NAME : undefined,
|
|
269
|
+
toolsGated: !runtime.skipPermissions,
|
|
107
270
|
});
|
|
271
|
+
const result = await runTurn(run, prompt, options, runtime, log);
|
|
108
272
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
109
273
|
if (!result.ok) {
|
|
110
|
-
const reason = result
|
|
111
|
-
|
|
112
|
-
? 'agent command did not run'
|
|
113
|
-
: `agent command exited ${result.exitCode}`);
|
|
114
|
-
const detail = result.stderr.trim().slice(-500);
|
|
274
|
+
const { reason } = result;
|
|
275
|
+
const detail = result.detail ?? '';
|
|
115
276
|
log(`${timestamp()} ✗ ${label} failed after ${seconds}s — ${reason}`);
|
|
116
277
|
if (detail)
|
|
117
278
|
log(` stderr: ${detail}`);
|
|
@@ -126,14 +287,7 @@ async function handleRun(run, options, api, log, sleep) {
|
|
|
126
287
|
}
|
|
127
288
|
return 'failed';
|
|
128
289
|
}
|
|
129
|
-
const reply =
|
|
130
|
-
if (!reply) {
|
|
131
|
-
log(`${timestamp()} ✗ ${label} produced no output after ${seconds}s`);
|
|
132
|
-
if (options.dryRun)
|
|
133
|
-
return 'skipped';
|
|
134
|
-
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: 'agent produced no output' }, log, sleep);
|
|
135
|
-
return reported.ok ? 'failed' : 'unreported';
|
|
136
|
-
}
|
|
290
|
+
const { reply } = result;
|
|
137
291
|
if (options.dryRun) {
|
|
138
292
|
const destination = run.reply_surface === 'none' ? '(no reply surface)' : `${run.reply_surface} ${run.reply_path}`;
|
|
139
293
|
log(`${timestamp()} ◦ ${label} dry-run — would post to ${destination}:`);
|
|
@@ -158,14 +312,23 @@ async function handleRun(run, options, api, log, sleep) {
|
|
|
158
312
|
log(` ! could not mark the run complete (HTTP ${completed.status})`);
|
|
159
313
|
return 'unreported';
|
|
160
314
|
}
|
|
315
|
+
if (result.sessionId) {
|
|
316
|
+
runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
|
|
317
|
+
}
|
|
161
318
|
return 'succeeded';
|
|
162
319
|
}
|
|
163
|
-
const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id);
|
|
320
|
+
const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
|
|
164
321
|
if (!posted.ok) {
|
|
165
322
|
log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
|
|
166
323
|
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
|
|
167
324
|
return reported.ok ? 'failed' : 'unreported';
|
|
168
325
|
}
|
|
326
|
+
// Advance continuity only after the answer is durable on its reply surface.
|
|
327
|
+
// Remembering at process exit would strand the conversation behind an answer
|
|
328
|
+
// it never received whenever this POST is rejected.
|
|
329
|
+
if (result.sessionId) {
|
|
330
|
+
runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
|
|
331
|
+
}
|
|
169
332
|
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
170
333
|
if (!completed.ok) {
|
|
171
334
|
// The reply is already in the conversation, so this is a bookkeeping
|
|
@@ -189,8 +352,17 @@ export async function runAgentHarness(options) {
|
|
|
189
352
|
const Ctor = options.webSocketCtor ?? WebSocket;
|
|
190
353
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
191
354
|
const machine = options.machine ?? os.hostname();
|
|
192
|
-
const url = options.url ?? options.credentials
|
|
355
|
+
const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
|
|
193
356
|
const metrics = newMetrics();
|
|
357
|
+
const runtime = buildRuntime(options, log);
|
|
358
|
+
// A credential file written by 0.1.0 stores a socket URL on a host that only
|
|
359
|
+
// ever answers 403. Upgrading the CLI would not have fixed those installs on
|
|
360
|
+
// its own — the stored value wins over the constant — so say what happened
|
|
361
|
+
// rather than silently using a different URL than the file names.
|
|
362
|
+
if (!options.url && options.credentials.ws_url && url !== options.credentials.ws_url) {
|
|
363
|
+
log(`${timestamp()} ! stored stream URL ${options.credentials.ws_url} predates this build ` +
|
|
364
|
+
`and is not used; connecting to ${url} instead (re-pair to refresh the file)`);
|
|
365
|
+
}
|
|
194
366
|
const api = {
|
|
195
367
|
baseUrl: options.credentials.base_url,
|
|
196
368
|
apiKey: options.credentials.api_key,
|
|
@@ -274,7 +446,14 @@ export async function runAgentHarness(options) {
|
|
|
274
446
|
if (f.type === 'auth_ok') {
|
|
275
447
|
authed = true;
|
|
276
448
|
const name = options.credentials.agent?.name ?? 'agent';
|
|
277
|
-
|
|
449
|
+
// Names the event, not "mentions". A mention is one of several things
|
|
450
|
+
// that make the server request a run — a 1:1 DM to this agent is
|
|
451
|
+
// another, and it carries no mention at all — so "waiting for
|
|
452
|
+
// mentions" describes a narrower trigger than the one this harness
|
|
453
|
+
// actually waits on, and sends anyone debugging a silent agent to
|
|
454
|
+
// read mention code instead of the run dispatcher.
|
|
455
|
+
log(`${timestamp()} ● connected as "${name}" — waiting for ` +
|
|
456
|
+
`${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
|
|
278
457
|
return;
|
|
279
458
|
}
|
|
280
459
|
if (f.type === 'auth_error') {
|
|
@@ -307,7 +486,12 @@ export async function runAgentHarness(options) {
|
|
|
307
486
|
};
|
|
308
487
|
if (frame.event !== EVENT_AGENT_RUN_REQUESTED) {
|
|
309
488
|
if (frame.event === EVENT_MENTION_CREATED || frame.event === EVENT_MESSAGE_TEAM_CHAT) {
|
|
310
|
-
|
|
489
|
+
// "runs are triggered by mentions" was wrong twice over: the gate
|
|
490
|
+
// is the `agent.run_requested` event, and this very frame can BE a
|
|
491
|
+
// mention — so the line claimed a mention triggers a run while
|
|
492
|
+
// ignoring a mention. State the gate instead.
|
|
493
|
+
log(`${timestamp()} ◦ ${frame.event} (no action — this harness runs on ` +
|
|
494
|
+
`${EVENT_AGENT_RUN_REQUESTED}, which the server sends separately)`);
|
|
311
495
|
}
|
|
312
496
|
metrics.ignored += 1;
|
|
313
497
|
ack(ACK_IGNORED);
|
|
@@ -370,7 +554,7 @@ export async function runAgentHarness(options) {
|
|
|
370
554
|
metrics.runsFailed += 1;
|
|
371
555
|
return;
|
|
372
556
|
}
|
|
373
|
-
const outcome = await handleRun(run, options, api, log, sleep).catch((err) => {
|
|
557
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep).catch((err) => {
|
|
374
558
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
375
559
|
return 'failed';
|
|
376
560
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent session belongs to which conversation.
|
|
3
|
+
*
|
|
4
|
+
* Every run spawns a fresh one-shot process, so without this the agent meets
|
|
5
|
+
* each message as its first: someone answers a clarifying question and gets a
|
|
6
|
+
* reply that has forgotten the question. Server-supplied thread history
|
|
7
|
+
* (`recent_messages`) narrows that gap but does not close it — history is what
|
|
8
|
+
* was *said*, not what the agent worked out, looked up, or decided on the last
|
|
9
|
+
* turn.
|
|
10
|
+
*
|
|
11
|
+
* Keyed by surface rather than by run: a run is one turn, and continuity is a
|
|
12
|
+
* property of the thread the turns are in.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately a separate ledger from `runs.json`. That one is a safety
|
|
15
|
+
* mechanism whose entries must never be lost; this one is an optimisation whose
|
|
16
|
+
* worst failure is an agent starting fresh. Mixing them would give the
|
|
17
|
+
* important file the churn rate of the unimportant one.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentRunRequest } from './contract.js';
|
|
20
|
+
export interface SessionRecord {
|
|
21
|
+
/** Surface key — see {@link sessionKeyFor}. */
|
|
22
|
+
key: string;
|
|
23
|
+
session_id: string;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
}
|
|
26
|
+
/** Cap on retained sessions. Oldest are dropped first (insertion order). */
|
|
27
|
+
export declare const SESSION_LEDGER_LIMIT = 200;
|
|
28
|
+
export declare function sessionLedgerPath(env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
/**
|
|
30
|
+
* The thread a run belongs to, or ``undefined`` when it belongs to none.
|
|
31
|
+
*
|
|
32
|
+
* A trigger fan-out (`reservation.created` and friends) has no surface and no
|
|
33
|
+
* predecessor, so it deliberately gets no key: resuming an unrelated session
|
|
34
|
+
* into it would carry over context that has nothing to do with the event, which
|
|
35
|
+
* is worse than starting clean.
|
|
36
|
+
*
|
|
37
|
+
* Keyed by agent as well as surface. The ledger outlives a pairing — re-pair
|
|
38
|
+
* this machine to a different AI employee and the file is still there — so a
|
|
39
|
+
* surface-only key would have the new agent resume the old one's session and
|
|
40
|
+
* answer in its predecessor's voice, carrying its reasoning and tool state.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sessionKeyFor(run: AgentRunRequest): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Surface → session id, backed by a JSON file.
|
|
45
|
+
*
|
|
46
|
+
* Like {@link RunStore}, every persistence failure degrades to memory-only
|
|
47
|
+
* rather than throwing: a read-only home directory should cost continuity, not
|
|
48
|
+
* the ability to answer.
|
|
49
|
+
*/
|
|
50
|
+
export declare class SessionStore {
|
|
51
|
+
private readonly file;
|
|
52
|
+
private readonly onWarn;
|
|
53
|
+
private records;
|
|
54
|
+
private constructor();
|
|
55
|
+
/** Read the ledger from disk. A missing or corrupt file starts empty. */
|
|
56
|
+
static load(env?: NodeJS.ProcessEnv, onWarn?: (line: string) => void): SessionStore;
|
|
57
|
+
/** The session to resume for a surface, if one is remembered. */
|
|
58
|
+
get(key: string | undefined): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Remember the session a surface's last turn ran in.
|
|
61
|
+
*
|
|
62
|
+
* Re-inserted rather than mutated in place so the map's insertion order stays
|
|
63
|
+
* a real least-recently-used order — {@link trim} drops from the front, and
|
|
64
|
+
* mutating in place would make the busiest thread the first one evicted.
|
|
65
|
+
*/
|
|
66
|
+
remember(key: string | undefined, sessionId: string): void;
|
|
67
|
+
/** Drop a surface's session — used when the agent rejected it as unresumable. */
|
|
68
|
+
forget(key: string | undefined): void;
|
|
69
|
+
private trim;
|
|
70
|
+
private persist;
|
|
71
|
+
}
|