@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.
@@ -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
@@ -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
- /** MCP endpoint the agent can use to act back into the account. */
14
- mcpUrl?: string;
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;
@@ -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.
@@ -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
- /** Working directory for the agent command. */
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
- mcpUrl: options.credentials.mcp_url,
100
- });
101
- const result = await execAgent({
102
- command: options.exec,
103
- prompt,
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.error ??
111
- (result.exitCode === null
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 = replyFromStdout(result.stdout);
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.ws_url ?? DEFAULT_WS_URL;
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
- log(`${timestamp()} ● connected as "${name}" — waiting for mentions (exec: ${options.exec})`);
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
- log(`${timestamp()} ◦ ${frame.event} (no action — runs are triggered by mentions)`);
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
+ }