@prohost/cli 0.1.2 → 0.2.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 +65 -0
- package/README.md +151 -13
- 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 +15 -0
- package/dist/agent/credentials.js +21 -0
- package/dist/agent/daemon.d.ts +131 -0
- package/dist/agent/daemon.js +274 -0
- package/dist/agent/prompt.d.ts +17 -2
- package/dist/agent/prompt.js +61 -3
- package/dist/agent/run.d.ts +13 -1
- package/dist/agent/run.js +188 -25
- 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 +18 -4
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,11 +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';
|
|
24
|
-
import { streamUrlFor } from './credentials.js';
|
|
25
|
+
import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
|
|
25
26
|
import { execAgent } from './exec.js';
|
|
26
27
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
27
28
|
import { RunStore } from './runstore.js';
|
|
29
|
+
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
28
30
|
import { verifySignature } from './signature.js';
|
|
29
31
|
import { isWebhookEvent } from '../forward.js';
|
|
30
32
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
@@ -85,34 +87,192 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
|
85
87
|
}
|
|
86
88
|
return result;
|
|
87
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
|
+
}
|
|
88
253
|
/**
|
|
89
254
|
* Handle one verified run end-to-end.
|
|
90
255
|
*
|
|
91
256
|
* Every path except a dry run — including every failure — calls the completion
|
|
92
257
|
* endpoint before returning.
|
|
93
258
|
*/
|
|
94
|
-
async function handleRun(run, options, api, log, sleep) {
|
|
259
|
+
async function handleRun(run, options, runtime, api, log, sleep) {
|
|
95
260
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
96
261
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
97
262
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
98
263
|
const prompt = buildPrompt(run, {
|
|
99
264
|
agentName: options.credentials.agent?.name ?? 'your agent',
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
106
|
-
cwd: options.cwd,
|
|
107
|
-
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,
|
|
108
270
|
});
|
|
271
|
+
const result = await runTurn(run, prompt, options, runtime, log);
|
|
109
272
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
110
273
|
if (!result.ok) {
|
|
111
|
-
const reason = result
|
|
112
|
-
|
|
113
|
-
? 'agent command did not run'
|
|
114
|
-
: `agent command exited ${result.exitCode}`);
|
|
115
|
-
const detail = result.stderr.trim().slice(-500);
|
|
274
|
+
const { reason } = result;
|
|
275
|
+
const detail = result.detail ?? '';
|
|
116
276
|
log(`${timestamp()} ✗ ${label} failed after ${seconds}s — ${reason}`);
|
|
117
277
|
if (detail)
|
|
118
278
|
log(` stderr: ${detail}`);
|
|
@@ -127,14 +287,7 @@ async function handleRun(run, options, api, log, sleep) {
|
|
|
127
287
|
}
|
|
128
288
|
return 'failed';
|
|
129
289
|
}
|
|
130
|
-
const reply =
|
|
131
|
-
if (!reply) {
|
|
132
|
-
log(`${timestamp()} ✗ ${label} produced no output after ${seconds}s`);
|
|
133
|
-
if (options.dryRun)
|
|
134
|
-
return 'skipped';
|
|
135
|
-
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: 'agent produced no output' }, log, sleep);
|
|
136
|
-
return reported.ok ? 'failed' : 'unreported';
|
|
137
|
-
}
|
|
290
|
+
const { reply } = result;
|
|
138
291
|
if (options.dryRun) {
|
|
139
292
|
const destination = run.reply_surface === 'none' ? '(no reply surface)' : `${run.reply_surface} ${run.reply_path}`;
|
|
140
293
|
log(`${timestamp()} ◦ ${label} dry-run — would post to ${destination}:`);
|
|
@@ -159,14 +312,23 @@ async function handleRun(run, options, api, log, sleep) {
|
|
|
159
312
|
log(` ! could not mark the run complete (HTTP ${completed.status})`);
|
|
160
313
|
return 'unreported';
|
|
161
314
|
}
|
|
315
|
+
if (result.sessionId) {
|
|
316
|
+
runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
|
|
317
|
+
}
|
|
162
318
|
return 'succeeded';
|
|
163
319
|
}
|
|
164
|
-
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);
|
|
165
321
|
if (!posted.ok) {
|
|
166
322
|
log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
|
|
167
323
|
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
|
|
168
324
|
return reported.ok ? 'failed' : 'unreported';
|
|
169
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
|
+
}
|
|
170
332
|
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
171
333
|
if (!completed.ok) {
|
|
172
334
|
// The reply is already in the conversation, so this is a bookkeeping
|
|
@@ -192,6 +354,7 @@ export async function runAgentHarness(options) {
|
|
|
192
354
|
const machine = options.machine ?? os.hostname();
|
|
193
355
|
const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
|
|
194
356
|
const metrics = newMetrics();
|
|
357
|
+
const runtime = buildRuntime(options, log);
|
|
195
358
|
// A credential file written by 0.1.0 stores a socket URL on a host that only
|
|
196
359
|
// ever answers 403. Upgrading the CLI would not have fixed those installs on
|
|
197
360
|
// its own — the stored value wins over the constant — so say what happened
|
|
@@ -391,7 +554,7 @@ export async function runAgentHarness(options) {
|
|
|
391
554
|
metrics.runsFailed += 1;
|
|
392
555
|
return;
|
|
393
556
|
}
|
|
394
|
-
const outcome = await handleRun(run, options, api, log, sleep).catch((err) => {
|
|
557
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep).catch((err) => {
|
|
395
558
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
396
559
|
return 'failed';
|
|
397
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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { prohostHome } from './credentials.js';
|
|
22
|
+
/** Cap on retained sessions. Oldest are dropped first (insertion order). */
|
|
23
|
+
export const SESSION_LEDGER_LIMIT = 200;
|
|
24
|
+
export function sessionLedgerPath(env = process.env) {
|
|
25
|
+
return path.join(prohostHome(env), 'sessions.json');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The thread a run belongs to, or ``undefined`` when it belongs to none.
|
|
29
|
+
*
|
|
30
|
+
* A trigger fan-out (`reservation.created` and friends) has no surface and no
|
|
31
|
+
* predecessor, so it deliberately gets no key: resuming an unrelated session
|
|
32
|
+
* into it would carry over context that has nothing to do with the event, which
|
|
33
|
+
* is worse than starting clean.
|
|
34
|
+
*
|
|
35
|
+
* Keyed by agent as well as surface. The ledger outlives a pairing — re-pair
|
|
36
|
+
* this machine to a different AI employee and the file is still there — so a
|
|
37
|
+
* surface-only key would have the new agent resume the old one's session and
|
|
38
|
+
* answer in its predecessor's voice, carrying its reasoning and tool state.
|
|
39
|
+
*/
|
|
40
|
+
export function sessionKeyFor(run) {
|
|
41
|
+
const surface = run.conversation_id
|
|
42
|
+
? `conversation:${run.conversation_id}`
|
|
43
|
+
: run.task_id
|
|
44
|
+
? `task:${run.task_id}`
|
|
45
|
+
: run.cleaning_id
|
|
46
|
+
? `cleaning:${run.cleaning_id}`
|
|
47
|
+
: undefined;
|
|
48
|
+
if (!surface)
|
|
49
|
+
return undefined;
|
|
50
|
+
return `${run.agent_id ?? 'agent'}/${surface}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Surface → session id, backed by a JSON file.
|
|
54
|
+
*
|
|
55
|
+
* Like {@link RunStore}, every persistence failure degrades to memory-only
|
|
56
|
+
* rather than throwing: a read-only home directory should cost continuity, not
|
|
57
|
+
* the ability to answer.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionStore {
|
|
60
|
+
file;
|
|
61
|
+
onWarn;
|
|
62
|
+
records = new Map();
|
|
63
|
+
constructor(file, onWarn) {
|
|
64
|
+
this.file = file;
|
|
65
|
+
this.onWarn = onWarn;
|
|
66
|
+
}
|
|
67
|
+
/** Read the ledger from disk. A missing or corrupt file starts empty. */
|
|
68
|
+
static load(env = process.env, onWarn = () => { }) {
|
|
69
|
+
const store = new SessionStore(sessionLedgerPath(env), onWarn);
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(readFileSync(store.file, 'utf8'));
|
|
72
|
+
if (Array.isArray(parsed)) {
|
|
73
|
+
for (const entry of parsed) {
|
|
74
|
+
const record = entry;
|
|
75
|
+
if (typeof record?.key === 'string' && record.key && typeof record.session_id === 'string' && record.session_id) {
|
|
76
|
+
store.records.set(record.key, {
|
|
77
|
+
key: record.key,
|
|
78
|
+
session_id: record.session_id,
|
|
79
|
+
updated_at: typeof record.updated_at === 'string' ? record.updated_at : '',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// No ledger yet, or an unreadable one — either way, start clean.
|
|
87
|
+
}
|
|
88
|
+
return store;
|
|
89
|
+
}
|
|
90
|
+
/** The session to resume for a surface, if one is remembered. */
|
|
91
|
+
get(key) {
|
|
92
|
+
if (!key)
|
|
93
|
+
return undefined;
|
|
94
|
+
return this.records.get(key)?.session_id;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Remember the session a surface's last turn ran in.
|
|
98
|
+
*
|
|
99
|
+
* Re-inserted rather than mutated in place so the map's insertion order stays
|
|
100
|
+
* a real least-recently-used order — {@link trim} drops from the front, and
|
|
101
|
+
* mutating in place would make the busiest thread the first one evicted.
|
|
102
|
+
*/
|
|
103
|
+
remember(key, sessionId) {
|
|
104
|
+
if (!key || !sessionId)
|
|
105
|
+
return;
|
|
106
|
+
this.records.delete(key);
|
|
107
|
+
this.records.set(key, { key, session_id: sessionId, updated_at: new Date().toISOString() });
|
|
108
|
+
this.trim();
|
|
109
|
+
this.persist();
|
|
110
|
+
}
|
|
111
|
+
/** Drop a surface's session — used when the agent rejected it as unresumable. */
|
|
112
|
+
forget(key) {
|
|
113
|
+
if (!key)
|
|
114
|
+
return;
|
|
115
|
+
if (this.records.delete(key))
|
|
116
|
+
this.persist();
|
|
117
|
+
}
|
|
118
|
+
trim() {
|
|
119
|
+
while (this.records.size > SESSION_LEDGER_LIMIT) {
|
|
120
|
+
const oldest = this.records.keys().next().value;
|
|
121
|
+
if (oldest === undefined)
|
|
122
|
+
return;
|
|
123
|
+
this.records.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
persist() {
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
129
|
+
writeFileSync(this.file, `${JSON.stringify([...this.records.values()], null, 2)}\n`, {
|
|
130
|
+
mode: 0o600,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
this.onWarn(` ! could not write the session ledger (${err instanceof Error ? err.message : String(err)}); ` +
|
|
135
|
+
'the agent will start a fresh session each run until this is fixed');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `@prohost/cli` — entrypoint.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Commands:
|
|
6
6
|
*
|
|
7
7
|
* npx @prohost/cli listen --forward-to http://localhost:3000/webhook
|
|
8
8
|
* npx @prohost/cli agent pair --code <pairing-code>
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
|
+
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
10
11
|
*
|
|
11
12
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
12
13
|
* binary stays small and starts fast.
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `@prohost/cli` — entrypoint.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Commands:
|
|
6
6
|
*
|
|
7
7
|
* npx @prohost/cli listen --forward-to http://localhost:3000/webhook
|
|
8
8
|
* npx @prohost/cli agent pair --code <pairing-code>
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
|
+
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
10
11
|
*
|
|
11
12
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
12
13
|
* binary stays small and starts fast.
|
|
@@ -24,7 +25,9 @@ function printHelp() {
|
|
|
24
25
|
'Usage:',
|
|
25
26
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
26
27
|
' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
|
|
27
|
-
" prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--
|
|
28
|
+
" prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
29
|
+
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] Keep the agent running",
|
|
30
|
+
' prohost agent uninstall-daemon',
|
|
28
31
|
'',
|
|
29
32
|
'listen flags:',
|
|
30
33
|
' --forward-to Local URL to POST each webhook payload to (required)',
|
|
@@ -42,10 +45,21 @@ function printHelp() {
|
|
|
42
45
|
' --exec Command to run for each mention; the prompt arrives on stdin (required)',
|
|
43
46
|
' --timeout Seconds one run may take before it is killed. Defaults to 300.',
|
|
44
47
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
45
|
-
' --
|
|
48
|
+
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
49
|
+
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
|
50
|
+
' and .mcp.json from their working directory and will inherit them.',
|
|
51
|
+
' --cwd Deprecated alias for --workdir.',
|
|
46
52
|
' --machine Machine name displayed in the UI. Defaults to the OS hostname.',
|
|
47
53
|
' --url Websocket endpoint. Defaults to the paired value.',
|
|
48
54
|
' --allow-unverified Run events whose signature could not be verified (not recommended).',
|
|
55
|
+
' --safe-tools Gate the agent\'s tools behind permission prompts instead of bypassing them.',
|
|
56
|
+
' Claude Code runs default to --dangerously-skip-permissions so the agent can',
|
|
57
|
+
' act; with --safe-tools expect it to answer rather than do. Alias:',
|
|
58
|
+
' --no-skip-permissions.',
|
|
59
|
+
'',
|
|
60
|
+
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
61
|
+
' --exec The same command you would pass to `agent run` (required).',
|
|
62
|
+
' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools — passed to `agent run`.',
|
|
49
63
|
'',
|
|
50
64
|
'Global flags:',
|
|
51
65
|
' --help, -h Show this help and exit.',
|
|
@@ -55,7 +69,7 @@ function printHelp() {
|
|
|
55
69
|
' PROHOST_API_KEY Used by `listen` when --api-key is not provided.',
|
|
56
70
|
' PROHOST_WS_URL Used when --url is not provided.',
|
|
57
71
|
' PROHOST_PAIRING_CODE Used by `agent pair` when --code is not provided.',
|
|
58
|
-
' PROHOST_HOME Credential directory. Defaults to ~/.prohost.',
|
|
72
|
+
' PROHOST_HOME Credential, ledger, workspace and log directory. Defaults to ~/.prohost.',
|
|
59
73
|
];
|
|
60
74
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
61
75
|
}
|
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.1
|
|
10
|
+
export declare const CLI_VERSION = "0.2.1";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.1
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.2.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.1
|
|
10
|
+
export const CLI_VERSION = '0.2.1';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|