@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.
@@ -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,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
- mcpUrl: options.credentials.mcp_url,
101
- });
102
- const result = await execAgent({
103
- command: options.exec,
104
- prompt,
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.error ??
112
- (result.exitCode === null
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 = replyFromStdout(result.stdout);
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
- * Two commands:
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
- * Two commands:
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] [--cwd <dir>]",
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
- ' --cwd Working directory for the agent command.',
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.2";
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.2";
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.2';
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",