@prohost/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +161 -18
- package/dist/agent/api.d.ts +6 -1
- package/dist/agent/api.js +10 -2
- package/dist/agent/claude.d.ts +161 -0
- package/dist/agent/claude.js +336 -0
- package/dist/agent/command.js +169 -19
- package/dist/agent/contract.d.ts +57 -4
- package/dist/agent/contract.js +71 -0
- package/dist/agent/credentials.d.ts +40 -1
- package/dist/agent/credentials.js +76 -1
- package/dist/agent/daemon.d.ts +131 -0
- package/dist/agent/daemon.js +274 -0
- package/dist/agent/pair.d.ts +5 -1
- package/dist/agent/pair.js +41 -1
- package/dist/agent/prompt.d.ts +17 -2
- package/dist/agent/prompt.js +55 -3
- package/dist/agent/run.d.ts +13 -1
- package/dist/agent/run.js +211 -27
- package/dist/agent/sessionstore.d.ts +71 -0
- package/dist/agent/sessionstore.js +138 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -5
- package/dist/listen.d.ts +13 -1
- package/dist/listen.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows anything about Claude Code specifically.
|
|
3
|
+
*
|
|
4
|
+
* `exec.ts` is deliberately agent-agnostic — prompt on stdin, reply on stdout,
|
|
5
|
+
* the lowest common denominator every CLI agent already speaks — and it stays
|
|
6
|
+
* that way. But that contract costs two things Claude Code can actually
|
|
7
|
+
* provide: a run has no memory of the previous one, and it has no tools for
|
|
8
|
+
* looking anything up in the workspace it is answering about. Both are fixed
|
|
9
|
+
* with flags, and flags are not portable. So the knowledge lives here, behind a
|
|
10
|
+
* detection function, and every other exec command keeps the exact behaviour it
|
|
11
|
+
* had before this module existed.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in here is load-bearing for correctness: if detection misses, if the
|
|
14
|
+
* session is unresumable, if the JSON is not what we expect — the run still
|
|
15
|
+
* happens and still completes. These are enhancements that must fail soft.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { prohostHome } from './credentials.js';
|
|
20
|
+
/**
|
|
21
|
+
* Name the ProhostAI MCP server is registered under, which is also what makes
|
|
22
|
+
* its tools appear as `mcp__prohost__<tool>`. Referenced in the prompt, in the
|
|
23
|
+
* `--allowedTools` pattern, and in the config file — keep them in sync from
|
|
24
|
+
* here rather than by hand.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_SERVER_NAME = 'prohost';
|
|
27
|
+
/**
|
|
28
|
+
* What goes to `--allowedTools` so the ProhostAI tools are callable.
|
|
29
|
+
*
|
|
30
|
+
* Two spellings, comma-joined. `mcp__<server>` is the documented way to approve
|
|
31
|
+
* a whole MCP server and is the one expected to do the work; `mcp__<server>__*`
|
|
32
|
+
* is a pattern people write by analogy with `Bash(git *)`. Sending both costs
|
|
33
|
+
* a few bytes and removes an entire class of silent failure: an unapproved tool
|
|
34
|
+
* in print mode is not an error the agent reports, it is a tool the agent
|
|
35
|
+
* quietly never calls — and the visible symptom is an answer that sounds like
|
|
36
|
+
* the model just didn't feel like looking anything up.
|
|
37
|
+
*
|
|
38
|
+
* Comma-joined into ONE argument rather than passed as two, because
|
|
39
|
+
* `--allowedTools` is variadic (`<tools...>`) and would otherwise keep eating
|
|
40
|
+
* whatever followed it on the command line.
|
|
41
|
+
*/
|
|
42
|
+
export const MCP_ALLOWED_TOOLS = `mcp__${MCP_SERVER_NAME},mcp__${MCP_SERVER_NAME}__*`;
|
|
43
|
+
/** Filename of the generated MCP config, inside `$PROHOST_HOME`. */
|
|
44
|
+
export const MCP_CONFIG_FILENAME = 'mcp.json';
|
|
45
|
+
/**
|
|
46
|
+
* Is this exec command Claude Code in print mode?
|
|
47
|
+
*
|
|
48
|
+
* A **heuristic**, and intentionally a narrow one: it matches when the first
|
|
49
|
+
* word of the command is `claude` (or a path ending in `/claude`) *and* the
|
|
50
|
+
* command asks for print mode. Both halves matter. The first because the whole
|
|
51
|
+
* point of `--exec` is that it takes an arbitrary command, and the second
|
|
52
|
+
* because `--output-format` is a print-mode flag — appending it to an
|
|
53
|
+
* interactive invocation would break a command that works today.
|
|
54
|
+
*
|
|
55
|
+
* Things this deliberately does **not** match: `env X=1 claude -p`,
|
|
56
|
+
* `npx claude -p`, a wrapper script that calls Claude Code internally,
|
|
57
|
+
* `sh -c 'claude -p'`. Each of those keeps the plain stdin/stdout behaviour —
|
|
58
|
+
* which is correct output, just without session continuity or MCP tools. The
|
|
59
|
+
* failure direction is what was chosen here: a missed detection costs features,
|
|
60
|
+
* while a false positive would append flags to somebody's shell script and
|
|
61
|
+
* break their agent outright.
|
|
62
|
+
*
|
|
63
|
+
* Nor does it match a command containing shell control operators, even one that
|
|
64
|
+
* starts with `claude`. Flags are appended to the *end* of the string, so for
|
|
65
|
+
* `claude -p | jq -r .result` they would land on `jq` — breaking a command that
|
|
66
|
+
* works today, which is precisely the outcome the narrowness is for.
|
|
67
|
+
*/
|
|
68
|
+
export function isClaudeExec(command) {
|
|
69
|
+
if (SHELL_CONTROL_OPERATORS.test(command))
|
|
70
|
+
return false;
|
|
71
|
+
const words = command.trim().split(/\s+/);
|
|
72
|
+
const first = words[0] ?? '';
|
|
73
|
+
const base = first.split('/').pop() ?? '';
|
|
74
|
+
if (base !== 'claude' && base !== 'claude.cmd' && base !== 'claude.exe')
|
|
75
|
+
return false;
|
|
76
|
+
return words.some((word) => word === '-p' || word === '--print');
|
|
77
|
+
}
|
|
78
|
+
/** Pipes, lists, redirections, subshells — anything that makes "append to the end" wrong. */
|
|
79
|
+
const SHELL_CONTROL_OPERATORS = /[|&;<>(){}`]|\$\(/;
|
|
80
|
+
/**
|
|
81
|
+
* Quote a value for a `shell: true` command line on this platform.
|
|
82
|
+
*
|
|
83
|
+
* Every value that goes through here is ours or the operator's — a session id
|
|
84
|
+
* we stored, a path under `$PROHOST_HOME` — never event-derived text. It is
|
|
85
|
+
* quoted anyway: a home directory with a space in it is ordinary, and "the
|
|
86
|
+
* inputs are trusted" is the assumption that stops being true the first time
|
|
87
|
+
* someone extends this.
|
|
88
|
+
*
|
|
89
|
+
* Windows needs its own branch. `shell: true` there runs `cmd.exe`, where a
|
|
90
|
+
* single quote is an ordinary character rather than a quoting one — POSIX
|
|
91
|
+
* quoting would hand Claude Code a config path with literal apostrophes around
|
|
92
|
+
* it, and the tools would simply never load.
|
|
93
|
+
*/
|
|
94
|
+
function shellQuote(value, platform) {
|
|
95
|
+
if (platform === 'win32')
|
|
96
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
97
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Append the flags this harness needs to a Claude Code command.
|
|
101
|
+
*
|
|
102
|
+
* `--dangerously-skip-permissions` is on by default, by explicit product
|
|
103
|
+
* decision: a paired brain is hired to *do* things, and in print mode there is
|
|
104
|
+
* nobody to approve a tool call — so without it the agent silently declines to
|
|
105
|
+
* act and the feature is a chat box. The trust model this assumes is stated
|
|
106
|
+
* plainly in the README: the brain runs as the host user with that user's full
|
|
107
|
+
* local permissions, on prompts written by workspace teammates. `--safe-tools`
|
|
108
|
+
* opts out for hosts who want prompt-gated behaviour instead.
|
|
109
|
+
*
|
|
110
|
+
* `--strict-mcp-config` is not optional decoration, and matters *more* under
|
|
111
|
+
* skipped permissions rather than less. Without it Claude Code merges the
|
|
112
|
+
* servers in the operator's own configuration with ours, so a run answering a
|
|
113
|
+
* customer's question would hold whatever MCP servers they use for their day
|
|
114
|
+
* job — with permission checks off. Ours is the only one this run is entitled
|
|
115
|
+
* to.
|
|
116
|
+
*/
|
|
117
|
+
export function claudeCommand(base, flags = {}) {
|
|
118
|
+
const platform = flags.platform ?? process.platform;
|
|
119
|
+
const quote = (value) => shellQuote(value, platform);
|
|
120
|
+
const skipPermissions = flags.skipPermissions !== false;
|
|
121
|
+
const parts = [base.trim(), '--output-format', 'json'];
|
|
122
|
+
if (skipPermissions)
|
|
123
|
+
parts.push('--dangerously-skip-permissions');
|
|
124
|
+
if (flags.sessionId)
|
|
125
|
+
parts.push('--resume', quote(flags.sessionId));
|
|
126
|
+
if (flags.mcpConfigPath) {
|
|
127
|
+
parts.push('--mcp-config', quote(flags.mcpConfigPath), '--strict-mcp-config');
|
|
128
|
+
// Redundant once permissions are bypassed — nothing is gated, so there is
|
|
129
|
+
// nothing to pre-approve. Kept for the `--safe-tools` path, where it is the
|
|
130
|
+
// only thing that makes the MCP tools callable at all: in print mode a tool
|
|
131
|
+
// awaiting approval is never called and the turn just succeeds more quietly.
|
|
132
|
+
if (!skipPermissions) {
|
|
133
|
+
// Last on the line on purpose: both this and `--mcp-config` are variadic,
|
|
134
|
+
// so anything appended after them would be swallowed as another value.
|
|
135
|
+
parts.push('--allowedTools', quote(MCP_ALLOWED_TOOLS));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return parts.join(' ');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Tool names the run wanted and was refused.
|
|
142
|
+
*
|
|
143
|
+
* Worth pulling out because a denial is otherwise invisible: in print mode
|
|
144
|
+
* there is nobody to approve anything, so a tool the agent is not allowed to
|
|
145
|
+
* call simply doesn't get called, the turn succeeds, and the reply reads like
|
|
146
|
+
* the model chose not to look something up. Logging the denial is what turns
|
|
147
|
+
* "the agent seems dumb" into "the agent asked for WebSearch and was refused".
|
|
148
|
+
*/
|
|
149
|
+
function denialNames(value) {
|
|
150
|
+
if (!Array.isArray(value))
|
|
151
|
+
return [];
|
|
152
|
+
const names = [];
|
|
153
|
+
for (const entry of value) {
|
|
154
|
+
if (typeof entry === 'string')
|
|
155
|
+
names.push(entry);
|
|
156
|
+
else if (entry && typeof entry === 'object') {
|
|
157
|
+
const name = entry.tool_name;
|
|
158
|
+
if (typeof name === 'string' && name)
|
|
159
|
+
names.push(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...new Set(names)];
|
|
163
|
+
}
|
|
164
|
+
function asObject(candidate) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(candidate);
|
|
167
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Find the result envelope in stdout.
|
|
178
|
+
*
|
|
179
|
+
* Normally stdout is exactly one JSON object and the first branch takes it.
|
|
180
|
+
* The line scan is for the case where something else got a word in first — a
|
|
181
|
+
* Node deprecation warning, a corporate-proxy notice, an npm banner. Those go
|
|
182
|
+
* to stderr *usually*, and "usually" is not good enough when the consequence is
|
|
183
|
+
* that every single run fails.
|
|
184
|
+
*
|
|
185
|
+
* The scan runs backwards and requires the object to look like a result
|
|
186
|
+
* envelope, so it can only ever match the thing we asked for. Anything it
|
|
187
|
+
* doesn't recognise still falls through to being reported as a failure, which
|
|
188
|
+
* is the safe direction: unparseable output must never be posted as a reply.
|
|
189
|
+
*/
|
|
190
|
+
function resultEnvelope(stdout) {
|
|
191
|
+
const trimmed = stdout.trim();
|
|
192
|
+
if (!trimmed)
|
|
193
|
+
return null;
|
|
194
|
+
const whole = asObject(trimmed);
|
|
195
|
+
if (whole)
|
|
196
|
+
return whole;
|
|
197
|
+
const lines = trimmed.split('\n');
|
|
198
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
199
|
+
const candidate = asObject(lines[i].trim());
|
|
200
|
+
if (candidate && (candidate.type === 'result' || 'result' in candidate || 'is_error' in candidate)) {
|
|
201
|
+
return candidate;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function text(value) {
|
|
207
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Read the reply and the session id out of `--output-format json` stdout.
|
|
211
|
+
*
|
|
212
|
+
* Unparseable output is a **failure**, not a fallback to posting the raw text.
|
|
213
|
+
* We asked for JSON; stdout that isn't JSON means something went wrong that we
|
|
214
|
+
* do not understand — a crash banner, a login prompt, an npm warning — and the
|
|
215
|
+
* one thing that must not happen is that being posted verbatim into a customer's
|
|
216
|
+
* conversation. Reporting the run failed is recoverable; sending a stack trace
|
|
217
|
+
* to a guest is not.
|
|
218
|
+
*/
|
|
219
|
+
export function parseClaudeOutput(stdout) {
|
|
220
|
+
const parsed = resultEnvelope(stdout);
|
|
221
|
+
if (!parsed) {
|
|
222
|
+
const sample = stdout.trim().slice(0, 200);
|
|
223
|
+
return {
|
|
224
|
+
denials: [],
|
|
225
|
+
ok: false,
|
|
226
|
+
error: sample
|
|
227
|
+
? `claude did not return JSON (asked for --output-format json). First 200 chars: ${sample}`
|
|
228
|
+
: 'claude returned no output',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const sessionId = text(parsed.session_id);
|
|
232
|
+
const result = text(parsed.result);
|
|
233
|
+
const denials = denialNames(parsed.permission_denials);
|
|
234
|
+
// A run can fail while still exiting 0 — a hit turn limit, a refusal, an API
|
|
235
|
+
// error mid-turn — and the `result` field then holds the explanation rather
|
|
236
|
+
// than an answer. Posting that as the agent's reply would put "Error:
|
|
237
|
+
// exceeded max turns" in front of a customer.
|
|
238
|
+
if (parsed.is_error === true) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
sessionId,
|
|
242
|
+
denials,
|
|
243
|
+
error: result ?? `claude reported an error (${String(parsed.subtype ?? 'unknown')})`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (!result) {
|
|
247
|
+
return { ok: false, sessionId, denials, error: 'claude returned JSON with no result text' };
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, text: result, sessionId, denials };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Does this look like the resume having been rejected, rather than the agent
|
|
253
|
+
* having failed at its actual work?
|
|
254
|
+
*
|
|
255
|
+
* Sessions do not live forever: the transcript is on disk and gets cleaned up,
|
|
256
|
+
* and the ledger has no way to know. A stale entry must not turn into a
|
|
257
|
+
* permanently broken surface, so the caller retries once without `--resume`.
|
|
258
|
+
*
|
|
259
|
+
* The observed failure is exit 1, empty stdout, and
|
|
260
|
+
* `No conversation found with session ID: <uuid>` on stderr. Matching is
|
|
261
|
+
* broader than that string because the wording is not ours to depend on, and it
|
|
262
|
+
* is gated by the caller on the run having actually failed — so a *successful*
|
|
263
|
+
* run whose reply text happens to quote a session id is never mistaken for one.
|
|
264
|
+
*/
|
|
265
|
+
export function looksLikeResumeFailure(output, sessionId) {
|
|
266
|
+
if (!sessionId)
|
|
267
|
+
return false;
|
|
268
|
+
if (output.includes(sessionId))
|
|
269
|
+
return true;
|
|
270
|
+
return /no (conversation|session)[^\n]{0,40}found|session[^\n]{0,40}not found|--resume|resume[^\n]{0,20}(failed|invalid|expired)/i.test(output);
|
|
271
|
+
}
|
|
272
|
+
/** Absolute path of the generated MCP config. */
|
|
273
|
+
export function mcpConfigPath(env = process.env) {
|
|
274
|
+
return path.join(prohostHome(env), MCP_CONFIG_FILENAME);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The config object handed to `--mcp-config`.
|
|
278
|
+
*
|
|
279
|
+
* Split out from the write so a test can assert on the shape without going near
|
|
280
|
+
* the filesystem, and so the header scheme is stated in exactly one place.
|
|
281
|
+
*/
|
|
282
|
+
export function buildMcpConfig(options) {
|
|
283
|
+
return {
|
|
284
|
+
mcpServers: {
|
|
285
|
+
[MCP_SERVER_NAME]: {
|
|
286
|
+
type: 'http',
|
|
287
|
+
// Verbatim, exactly as pairing reported it. An earlier revision appended
|
|
288
|
+
// a trailing slash on the theory that `/mcp` is a mounted sub-app and
|
|
289
|
+
// would 308 to `/mcp/`. That premise is false for this server:
|
|
290
|
+
// `api_public/main.py` registers a `_McpNoSlashDelegator` on the exact
|
|
291
|
+
// path `/mcp` *before* the mount, precisely so there is no redirect —
|
|
292
|
+
// because Claude Code's SDK drops the auth header across one. `/mcp` is
|
|
293
|
+
// also the form the public docs and `pairing_endpoints()` both use, so
|
|
294
|
+
// rewriting a server-supplied URL here buys nothing and risks diverging
|
|
295
|
+
// from wherever the server points next.
|
|
296
|
+
url: options.mcpUrl,
|
|
297
|
+
headers: {
|
|
298
|
+
// `X-API-Key` and ONLY `X-API-Key`.
|
|
299
|
+
//
|
|
300
|
+
// The server accepts two credentials on this endpoint, and they are
|
|
301
|
+
// not interchangeable: `Authorization: Bearer` is validated as an
|
|
302
|
+
// **OAuth access token**, while `X-API-Key` is hashed and matched
|
|
303
|
+
// against `AccountApiKey`. A pairing hands out the latter.
|
|
304
|
+
//
|
|
305
|
+
// Sending both would be worse than sending the wrong one. Both the
|
|
306
|
+
// middleware (`api_public/middleware/mcp_auth.py`) and the tool layer
|
|
307
|
+
// (`api_public/mcp/server.py::_resolve_auth_full`) check
|
|
308
|
+
// `Authorization` **first** and do not fall through to `X-API-Key`
|
|
309
|
+
// when it fails — so a `ph_live_…` key in the bearer slot is looked
|
|
310
|
+
// up as an OAuth token, misses, and 401s the whole session while a
|
|
311
|
+
// perfectly good API key sits in the next header down.
|
|
312
|
+
'X-API-Key': options.apiKey,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Write the MCP config and return its path.
|
|
320
|
+
*
|
|
321
|
+
* The file holds a live API key, so it gets the same treatment as
|
|
322
|
+
* `agent.json`: `0600` inside a `0700` directory, written with the mode set at
|
|
323
|
+
* create time rather than chmod'ed afterwards — and chmod'ed anyway, because
|
|
324
|
+
* `writeFileSync`'s mode is masked by the umask and is ignored outright when
|
|
325
|
+
* the file already exists.
|
|
326
|
+
*/
|
|
327
|
+
export function writeMcpConfig(options) {
|
|
328
|
+
const env = options.env ?? process.env;
|
|
329
|
+
const dir = prohostHome(env);
|
|
330
|
+
const file = mcpConfigPath(env);
|
|
331
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
332
|
+
chmodSync(dir, 0o700);
|
|
333
|
+
writeFileSync(file, `${JSON.stringify(buildMcpConfig(options), null, 2)}\n`, { mode: 0o600 });
|
|
334
|
+
chmodSync(file, 0o600);
|
|
335
|
+
return file;
|
|
336
|
+
}
|
package/dist/agent/command.js
CHANGED
|
@@ -4,26 +4,184 @@
|
|
|
4
4
|
* Kept separate from the harness itself so `pair.ts` / `run.ts` stay callable
|
|
5
5
|
* (and testable) as plain async functions with no argv or process concerns.
|
|
6
6
|
*/
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
7
10
|
import process from 'node:process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
|
|
8
13
|
import { PairError, runPair } from './pair.js';
|
|
9
|
-
import { CredentialsMissingError, loadCredentials } from './credentials.js';
|
|
14
|
+
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
|
|
10
15
|
import { DEFAULT_EXEC_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
11
16
|
function flagString(flags, name) {
|
|
12
17
|
const value = flags[name];
|
|
13
18
|
return typeof value === 'string' ? value : undefined;
|
|
14
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Working directory the operator asked for, if any.
|
|
22
|
+
*
|
|
23
|
+
* ``--cwd`` is the 0.1.x spelling and still honoured — it is in the published
|
|
24
|
+
* README and in whatever scripts people wrote against it — but ``--workdir`` is
|
|
25
|
+
* the documented name now. The *default* when neither is given is the harness's
|
|
26
|
+
* to pick, not this function's: see {@link workspacePath}.
|
|
27
|
+
*/
|
|
28
|
+
function requestedWorkdir(flags) {
|
|
29
|
+
return flagString(flags, 'workdir') ?? flagString(flags, 'cwd');
|
|
30
|
+
}
|
|
31
|
+
/** Returned by {@link parseTimeoutSeconds} for a value that isn't a timeout. */
|
|
32
|
+
const INVALID_TIMEOUT = Symbol('invalid-timeout');
|
|
33
|
+
/**
|
|
34
|
+
* Read `--timeout`, complaining once for both commands that take it.
|
|
35
|
+
*
|
|
36
|
+
* Returns ``undefined`` when the flag was absent (the harness applies its own
|
|
37
|
+
* default) and {@link INVALID_TIMEOUT} after printing the error.
|
|
38
|
+
*/
|
|
39
|
+
function parseTimeoutSeconds(flags) {
|
|
40
|
+
const raw = flagString(flags, 'timeout');
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const seconds = Number(raw);
|
|
44
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
45
|
+
process.stderr.write('--timeout must be a positive number of seconds.\n');
|
|
46
|
+
return INVALID_TIMEOUT;
|
|
47
|
+
}
|
|
48
|
+
return seconds;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Environment the daemon must be given explicitly.
|
|
52
|
+
*
|
|
53
|
+
* launchd starts a job with almost nothing — notably a minimal `PATH` of
|
|
54
|
+
* `/usr/bin:/bin:/usr/sbin:/sbin`, which contains no `claude`. Installed from
|
|
55
|
+
* npm, Homebrew, or the official installer it lives in `~/.local/bin`,
|
|
56
|
+
* `/opt/homebrew/bin`, or `/usr/local/bin`, so without this the daemon
|
|
57
|
+
* installs cleanly, reports success, and then crash-loops on
|
|
58
|
+
* `claude: command not found` in a log nobody is watching.
|
|
59
|
+
*
|
|
60
|
+
* The `PATH` captured is the one in effect when the operator ran
|
|
61
|
+
* `install-daemon` — i.e. the one in which their `--exec` command actually
|
|
62
|
+
* works. `PROHOST_HOME` is pinned for the same reason it is used for the label:
|
|
63
|
+
* a daemon must keep serving the home it was installed from, whatever the
|
|
64
|
+
* environment of a later login happens to say.
|
|
65
|
+
*/
|
|
66
|
+
function daemonEnvironment() {
|
|
67
|
+
const env = { PROHOST_HOME: prohostHome() };
|
|
68
|
+
if (process.env.PATH)
|
|
69
|
+
env.PATH = process.env.PATH;
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
/** Absolute path of this CLI's entrypoint, for a daemon to exec. */
|
|
73
|
+
function cliEntrypoint() {
|
|
74
|
+
// Resolved from this module rather than from `process.argv[1]`, which under
|
|
75
|
+
// `npx`/`npm i -g` is a symlink in a bin directory. launchd does not care,
|
|
76
|
+
// but a plist naming a path that a later npm operation replaces would leave a
|
|
77
|
+
// daemon pointed at nothing.
|
|
78
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
|
|
79
|
+
}
|
|
15
80
|
/** Dispatch an ``agent`` subcommand. Returns the process exit code. */
|
|
16
81
|
export async function runAgent(subcommand, flags, printHelp) {
|
|
17
82
|
if (subcommand === 'pair')
|
|
18
83
|
return pairCommand(flags);
|
|
19
84
|
if (subcommand === 'run')
|
|
20
85
|
return runCommand(flags);
|
|
86
|
+
if (subcommand === 'install-daemon')
|
|
87
|
+
return installDaemonCommand(flags);
|
|
88
|
+
if (subcommand === 'uninstall-daemon')
|
|
89
|
+
return uninstallDaemonCommand();
|
|
21
90
|
process.stderr.write(subcommand
|
|
22
91
|
? `Unknown agent subcommand: ${subcommand}\n\n`
|
|
23
|
-
: 'Missing agent subcommand — expected `pair` or `
|
|
92
|
+
: 'Missing agent subcommand — expected `pair`, `run`, `install-daemon`, or `uninstall-daemon`.\n\n');
|
|
24
93
|
printHelp();
|
|
25
94
|
return 1;
|
|
26
95
|
}
|
|
96
|
+
/** Load credentials, reporting the "you haven't paired" case as an exit code. */
|
|
97
|
+
function loadOrExplain() {
|
|
98
|
+
try {
|
|
99
|
+
return loadCredentials();
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err instanceof CredentialsMissingError) {
|
|
103
|
+
process.stderr.write(`✗ ${err.message}\n`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function installDaemonCommand(flags) {
|
|
110
|
+
const exec = flagString(flags, 'exec');
|
|
111
|
+
if (!exec) {
|
|
112
|
+
process.stderr.write("Missing required flag: --exec. Example: prohost agent install-daemon --exec 'claude -p'\n");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
const credentials = loadOrExplain();
|
|
116
|
+
if (!credentials)
|
|
117
|
+
return 1;
|
|
118
|
+
// Validated here and not only in `agent run`, because the consequence differs:
|
|
119
|
+
// a bad --timeout typed at a terminal prints an error, while one baked into a
|
|
120
|
+
// plist makes every launch exit on that same validation and KeepAlive restart
|
|
121
|
+
// it forever.
|
|
122
|
+
const timeoutSeconds = parseTimeoutSeconds(flags);
|
|
123
|
+
if (timeoutSeconds === INVALID_TIMEOUT)
|
|
124
|
+
return 1;
|
|
125
|
+
// Absolute, and created now. A supervisor does not inherit the installing
|
|
126
|
+
// shell's current directory, so a relative `--workdir .` copied verbatim into
|
|
127
|
+
// the plist names a directory that either doesn't exist or isn't the one the
|
|
128
|
+
// operator meant — and launchd reports that as a job that won't start, long
|
|
129
|
+
// after this command said it succeeded.
|
|
130
|
+
const workdir = path.resolve(requestedWorkdir(flags) ?? ensureWorkspace());
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(workdir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
process.stderr.write(`✗ Cannot use ${workdir} as the working directory ` +
|
|
136
|
+
`(${err instanceof Error ? err.message : String(err)}).\n`);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const invocation = {
|
|
140
|
+
execPath: process.execPath,
|
|
141
|
+
scriptPath: cliEntrypoint(),
|
|
142
|
+
exec,
|
|
143
|
+
workdir,
|
|
144
|
+
timeoutSeconds,
|
|
145
|
+
machine: flagString(flags, 'machine'),
|
|
146
|
+
url: flagString(flags, 'url'),
|
|
147
|
+
allowUnverified: flags['allow-unverified'] === true,
|
|
148
|
+
safeTools: flags['safe-tools'] === true || flags['no-skip-permissions'] === true,
|
|
149
|
+
};
|
|
150
|
+
const spec = {
|
|
151
|
+
label: launchdLabel(prohostHome()),
|
|
152
|
+
programArguments: harnessArguments(invocation),
|
|
153
|
+
workdir,
|
|
154
|
+
logPath: agentLogPath(),
|
|
155
|
+
env: daemonEnvironment(),
|
|
156
|
+
};
|
|
157
|
+
if (os.platform() !== 'darwin') {
|
|
158
|
+
// Unit name derived from the same per-home label as the macOS one. A fixed
|
|
159
|
+
// `prohost-agent.service` would have a second $PROHOST_HOME overwrite and
|
|
160
|
+
// restart the first agent's unit — the exact collision the per-home
|
|
161
|
+
// scoping exists to prevent.
|
|
162
|
+
process.stdout.write(`Automatic install is macOS-only. On Linux, save this as ` +
|
|
163
|
+
`~/.config/systemd/user/${spec.label}.service and run:\n` +
|
|
164
|
+
` systemctl --user daemon-reload && systemctl --user enable --now ${spec.label}\n` +
|
|
165
|
+
' loginctl enable-linger "$USER" # so it survives logout\n\n' +
|
|
166
|
+
`${buildSystemdUnit(spec)}\n`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
const result = installLaunchdDaemon(spec);
|
|
170
|
+
process.stdout.write(`${result.message}\n`);
|
|
171
|
+
return result.ok ? 0 : 1;
|
|
172
|
+
}
|
|
173
|
+
async function uninstallDaemonCommand() {
|
|
174
|
+
// Deliberately does not require credentials: the most likely reason to want
|
|
175
|
+
// this is that pairing was deleted or revoked and a daemon is still running.
|
|
176
|
+
const label = launchdLabel(prohostHome());
|
|
177
|
+
if (os.platform() !== 'darwin') {
|
|
178
|
+
process.stdout.write(`Automatic removal is macOS-only. On Linux:\n systemctl --user disable --now ${label}\n`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
const result = uninstallLaunchdDaemon(label);
|
|
182
|
+
process.stdout.write(`${result.message}\n`);
|
|
183
|
+
return result.ok ? 0 : 1;
|
|
184
|
+
}
|
|
27
185
|
async function pairCommand(flags) {
|
|
28
186
|
const code = flagString(flags, 'code') ?? process.env.PROHOST_PAIRING_CODE;
|
|
29
187
|
if (!code) {
|
|
@@ -53,23 +211,12 @@ async function runCommand(flags) {
|
|
|
53
211
|
process.stderr.write("Missing required flag: --exec. Example: prohost agent run --exec 'claude -p'\n");
|
|
54
212
|
return 1;
|
|
55
213
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (err instanceof CredentialsMissingError) {
|
|
62
|
-
process.stderr.write(`✗ ${err.message}\n`);
|
|
63
|
-
return 1;
|
|
64
|
-
}
|
|
65
|
-
throw err;
|
|
66
|
-
}
|
|
67
|
-
const timeoutSeconds = flagString(flags, 'timeout');
|
|
68
|
-
const parsedTimeout = timeoutSeconds === undefined ? undefined : Number(timeoutSeconds);
|
|
69
|
-
if (parsedTimeout !== undefined && (!Number.isFinite(parsedTimeout) || parsedTimeout <= 0)) {
|
|
70
|
-
process.stderr.write('--timeout must be a positive number of seconds.\n');
|
|
214
|
+
const credentials = loadOrExplain();
|
|
215
|
+
if (!credentials)
|
|
216
|
+
return 1;
|
|
217
|
+
const parsedTimeout = parseTimeoutSeconds(flags);
|
|
218
|
+
if (parsedTimeout === INVALID_TIMEOUT)
|
|
71
219
|
return 1;
|
|
72
|
-
}
|
|
73
220
|
// First signal stops accepting work and lets the in-flight run finish
|
|
74
221
|
// reporting itself (so the server doesn't wait on a run we abandoned); a
|
|
75
222
|
// second one means the operator wants out now.
|
|
@@ -90,10 +237,13 @@ async function runCommand(flags) {
|
|
|
90
237
|
credentials,
|
|
91
238
|
url: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
|
|
92
239
|
machine: flagString(flags, 'machine'),
|
|
93
|
-
cwd:
|
|
240
|
+
cwd: requestedWorkdir(flags),
|
|
94
241
|
timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
|
|
95
242
|
dryRun: flags['dry-run'] === true,
|
|
96
243
|
allowUnverified: flags['allow-unverified'] === true,
|
|
244
|
+
// Two spellings for the same opt-out: `--safe-tools` says what you get,
|
|
245
|
+
// `--no-skip-permissions` says what it turns off. Both were asked for.
|
|
246
|
+
safeTools: flags['safe-tools'] === true || flags['no-skip-permissions'] === true,
|
|
97
247
|
signal: controller.signal,
|
|
98
248
|
});
|
|
99
249
|
process.stdout.write(`\nRuns: ${metrics.runsSucceeded} succeeded, ${metrics.runsFailed} failed, ` +
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -34,6 +34,38 @@ export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
|
|
|
34
34
|
export declare function conversationMessagesPath(conversationId: string): string;
|
|
35
35
|
/** `POST` — close out a run. Must be called for every run, success or not. */
|
|
36
36
|
export declare function agentRunCompletePath(runId: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* One earlier message on the surface the run is about.
|
|
39
|
+
*
|
|
40
|
+
* Only `text` is required. Everything else is decoration the server may or may
|
|
41
|
+
* not have — an older server sends none of this at all, and a newer one may add
|
|
42
|
+
* fields this build has never heard of.
|
|
43
|
+
*/
|
|
44
|
+
export interface RunMessage {
|
|
45
|
+
/** Display name of whoever wrote it. */
|
|
46
|
+
author?: string;
|
|
47
|
+
/** The writer's part in the thread — `user`, `agent`, `guest`, … */
|
|
48
|
+
role?: string;
|
|
49
|
+
/** ISO-8601 timestamp. */
|
|
50
|
+
at?: string;
|
|
51
|
+
text: string;
|
|
52
|
+
}
|
|
53
|
+
/** One memory the account has about this agent's work. */
|
|
54
|
+
export interface RunMemory {
|
|
55
|
+
/** What the memory is scoped to — `account`, `listing`, `guest`, … */
|
|
56
|
+
scope?: string;
|
|
57
|
+
content: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Ceiling on how many context entries are kept from one payload.
|
|
61
|
+
*
|
|
62
|
+
* The server bounds these, but the prompt is written to a child process's
|
|
63
|
+
* stdin and the harness is the last thing standing between a pathological
|
|
64
|
+
* payload and an agent's context window. Entries past the limit are dropped
|
|
65
|
+
* from the *front*, because both lists are oldest-first and the recent end is
|
|
66
|
+
* the part worth spending prompt on.
|
|
67
|
+
*/
|
|
68
|
+
export declare const MAX_CONTEXT_ENTRIES = 50;
|
|
37
69
|
/** The `agent.run_requested` payload, as far as the harness cares about it. */
|
|
38
70
|
export interface AgentRunRequest {
|
|
39
71
|
run_id: string;
|
|
@@ -57,10 +89,11 @@ export interface AgentRunRequest {
|
|
|
57
89
|
trigger_entity_id?: string;
|
|
58
90
|
/**
|
|
59
91
|
* What the run is about, exactly as the server declared it. This is NOT the
|
|
60
|
-
* same question as "can the reply be posted": a task run is a task run
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* `reply_body_key
|
|
92
|
+
* same question as "can the reply be posted": a task run is a task run
|
|
93
|
+
* whether or not the server gave it somewhere to post, and the agent needs to
|
|
94
|
+
* be told which it is to answer sensibly. Postability is `reply_path` +
|
|
95
|
+
* `reply_body_key`, both taken from the event — so a task surface gaining a
|
|
96
|
+
* real comment route server-side needs no change here.
|
|
64
97
|
*/
|
|
65
98
|
reply_surface: ReplySurface;
|
|
66
99
|
/** Server-supplied path to post the reply to. Absent when there is nowhere. */
|
|
@@ -72,8 +105,28 @@ export interface AgentRunRequest {
|
|
|
72
105
|
* (`shared/comments/schemas.py`).
|
|
73
106
|
*/
|
|
74
107
|
reply_body_key?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Extra fields the server wants merged into the reply body alongside
|
|
110
|
+
* ``reply_body_key``. First use: ``{reply_to_message_id: "<uuid>"}`` so a
|
|
111
|
+
* team-chat reply lands in the right sub-thread instead of at the bottom of
|
|
112
|
+
* the channel.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately opaque — the harness merges whatever it is given rather than
|
|
115
|
+
* knowing the keys, so the server can add threading, labelling or routing
|
|
116
|
+
* fields without a CLI release.
|
|
117
|
+
*/
|
|
118
|
+
reply_body_extra?: Record<string, unknown>;
|
|
75
119
|
/** Server-supplied path to complete the run at. */
|
|
76
120
|
completion_path: string;
|
|
121
|
+
/**
|
|
122
|
+
* Earlier messages on this surface, oldest first. Absent on a server that
|
|
123
|
+
* predates the field — which is the case the harness shipped with, so an
|
|
124
|
+
* empty history has to keep meaning "answer from the trigger alone" rather
|
|
125
|
+
* than being treated as an error.
|
|
126
|
+
*/
|
|
127
|
+
recent_messages?: RunMessage[];
|
|
128
|
+
/** What the account remembers that bears on this run. Absent on older servers. */
|
|
129
|
+
memories?: RunMemory[];
|
|
77
130
|
}
|
|
78
131
|
/** Reply destinations the server can bind a run to, including future surfaces. */
|
|
79
132
|
export type ReplySurface = 'conversation' | 'task' | 'cleaning' | 'none' | (string & {});
|