@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/contract.js
CHANGED
|
@@ -38,6 +38,16 @@ export function conversationMessagesPath(conversationId) {
|
|
|
38
38
|
export function agentRunCompletePath(runId) {
|
|
39
39
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Ceiling on how many context entries are kept from one payload.
|
|
43
|
+
*
|
|
44
|
+
* The server bounds these, but the prompt is written to a child process's
|
|
45
|
+
* stdin and the harness is the last thing standing between a pathological
|
|
46
|
+
* payload and an agent's context window. Entries past the limit are dropped
|
|
47
|
+
* from the *front*, because both lists are oldest-first and the recent end is
|
|
48
|
+
* the part worth spending prompt on.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_CONTEXT_ENTRIES = 50;
|
|
41
51
|
/**
|
|
42
52
|
* Fallback for a server that predates `reply_instructions.body_field`.
|
|
43
53
|
*
|
|
@@ -60,6 +70,60 @@ function obj(value) {
|
|
|
60
70
|
? value
|
|
61
71
|
: {};
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Keep the last {@link MAX_CONTEXT_ENTRIES} entries of a list the server sent.
|
|
75
|
+
*
|
|
76
|
+
* Everything about this is deliberately forgiving. The two context lists are
|
|
77
|
+
* additive fields on an event this CLI already had to parse without them, and
|
|
78
|
+
* they will keep changing shape after this build is published: a version that
|
|
79
|
+
* throws — or that drops the whole run — because a list arrived as `null`, held
|
|
80
|
+
* an entry with a new key, or carried one malformed row would turn a server-side
|
|
81
|
+
* improvement into a fleet-wide outage of laptops nobody can redeploy. A
|
|
82
|
+
* missing, null, or wrong-typed list degrades to "this server doesn't send it",
|
|
83
|
+
* and one bad entry costs that entry only.
|
|
84
|
+
*/
|
|
85
|
+
function contextList(value, parseEntry) {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
return undefined;
|
|
88
|
+
const entries = [];
|
|
89
|
+
for (const raw of value) {
|
|
90
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
91
|
+
continue;
|
|
92
|
+
const parsed = parseEntry(raw);
|
|
93
|
+
if (parsed !== null)
|
|
94
|
+
entries.push(parsed);
|
|
95
|
+
}
|
|
96
|
+
if (entries.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return entries.slice(-MAX_CONTEXT_ENTRIES);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate ``reply_instructions.body_extra``.
|
|
102
|
+
*
|
|
103
|
+
* Anything that isn't a plain object is treated as absent rather than as an
|
|
104
|
+
* error: these fields are additive, and a malformed one must not cost the run
|
|
105
|
+
* its reply. Values are passed through untouched — the server owns their
|
|
106
|
+
* meaning, and coercing them here would be this CLI inventing a schema it does
|
|
107
|
+
* not have.
|
|
108
|
+
*/
|
|
109
|
+
function bodyExtra(value) {
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
111
|
+
return undefined;
|
|
112
|
+
const entries = value;
|
|
113
|
+
return Object.keys(entries).length > 0 ? entries : undefined;
|
|
114
|
+
}
|
|
115
|
+
function parseMessage(entry) {
|
|
116
|
+
const text = str(entry.text);
|
|
117
|
+
if (!text)
|
|
118
|
+
return null;
|
|
119
|
+
return { text, author: str(entry.author), role: str(entry.role), at: str(entry.at) };
|
|
120
|
+
}
|
|
121
|
+
function parseMemory(entry) {
|
|
122
|
+
const content = str(entry.content);
|
|
123
|
+
if (!content)
|
|
124
|
+
return null;
|
|
125
|
+
return { content, scope: str(entry.scope) };
|
|
126
|
+
}
|
|
63
127
|
/**
|
|
64
128
|
* Extract the event body from a websocket frame's payload.
|
|
65
129
|
*
|
|
@@ -87,6 +151,7 @@ export function parseRunRequest(data) {
|
|
|
87
151
|
const trigger = obj(data.trigger);
|
|
88
152
|
const replyInstructions = obj(data.reply_instructions);
|
|
89
153
|
const completion = obj(data.completion);
|
|
154
|
+
const context = obj(data.context);
|
|
90
155
|
const declaredSurface = str(replyInstructions.surface);
|
|
91
156
|
const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
|
|
92
157
|
const taskId = str(data.task_id) ?? str(replyInstructions.task_id);
|
|
@@ -120,6 +185,12 @@ export function parseRunRequest(data) {
|
|
|
120
185
|
// without a CLI release. A path with no nameable field is not postable;
|
|
121
186
|
// keep the known-surface fallback only for old events.
|
|
122
187
|
reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
|
|
188
|
+
reply_body_extra: bodyExtra(replyInstructions.body_extra),
|
|
123
189
|
completion_path: str(completion.path) ?? agentRunCompletePath(runId),
|
|
190
|
+
// Accepted both at the top level and nested under `context`, because the
|
|
191
|
+
// server can move them without a CLI release either way — and a published
|
|
192
|
+
// CLI cannot be redeployed to follow.
|
|
193
|
+
recent_messages: contextList(data.recent_messages ?? context.recent_messages, parseMessage),
|
|
194
|
+
memories: contextList(data.memories ?? context.memories, parseMemory),
|
|
124
195
|
};
|
|
125
196
|
}
|
|
@@ -68,6 +68,21 @@ export declare function streamUrlFor(credentials: AgentCredentials, fallback: st
|
|
|
68
68
|
export declare function prohostHome(env?: NodeJS.ProcessEnv): string;
|
|
69
69
|
/** Absolute path of the credential file. */
|
|
70
70
|
export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
|
|
71
|
+
/**
|
|
72
|
+
* Default working directory for the agent command.
|
|
73
|
+
*
|
|
74
|
+
* A dedicated, boring directory — **not** whatever the operator happened to
|
|
75
|
+
* `cd` into. Coding agents read their working directory as configuration: a
|
|
76
|
+
* `CLAUDE.md`, a `.claude/settings.json` permission list, a project `.mcp.json`.
|
|
77
|
+
* Launched from a checkout of some unrelated service, the agent inherits that
|
|
78
|
+
* repo's instructions and denials and answers a customer's question as if it
|
|
79
|
+
* were working on that codebase — which is exactly what happened when this was
|
|
80
|
+
* first run in production from a dev checkout. Defaulting somewhere neutral
|
|
81
|
+
* makes the inherited-context case something you opt into with `--workdir`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function workspacePath(env?: NodeJS.ProcessEnv): string;
|
|
84
|
+
/** Create the workspace directory if it isn't there, and return its path. */
|
|
85
|
+
export declare function ensureWorkspace(env?: NodeJS.ProcessEnv): string;
|
|
71
86
|
/**
|
|
72
87
|
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
73
88
|
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
@@ -71,6 +71,27 @@ export function prohostHome(env = process.env) {
|
|
|
71
71
|
export function credentialsPath(env = process.env) {
|
|
72
72
|
return path.join(prohostHome(env), 'agent.json');
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Default working directory for the agent command.
|
|
76
|
+
*
|
|
77
|
+
* A dedicated, boring directory — **not** whatever the operator happened to
|
|
78
|
+
* `cd` into. Coding agents read their working directory as configuration: a
|
|
79
|
+
* `CLAUDE.md`, a `.claude/settings.json` permission list, a project `.mcp.json`.
|
|
80
|
+
* Launched from a checkout of some unrelated service, the agent inherits that
|
|
81
|
+
* repo's instructions and denials and answers a customer's question as if it
|
|
82
|
+
* were working on that codebase — which is exactly what happened when this was
|
|
83
|
+
* first run in production from a dev checkout. Defaulting somewhere neutral
|
|
84
|
+
* makes the inherited-context case something you opt into with `--workdir`.
|
|
85
|
+
*/
|
|
86
|
+
export function workspacePath(env = process.env) {
|
|
87
|
+
return path.join(prohostHome(env), 'workspace');
|
|
88
|
+
}
|
|
89
|
+
/** Create the workspace directory if it isn't there, and return its path. */
|
|
90
|
+
export function ensureWorkspace(env = process.env) {
|
|
91
|
+
const dir = workspacePath(env);
|
|
92
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
74
95
|
/**
|
|
75
96
|
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
76
97
|
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping `agent run` alive across logouts, crashes, and reboots.
|
|
3
|
+
*
|
|
4
|
+
* A paired agent that only runs while a terminal window is open is not a
|
|
5
|
+
* teammate — it is a demo. Someone @-mentions it at 2am, the laptop had
|
|
6
|
+
* rebooted for an update, and the run times out server-side with no indication
|
|
7
|
+
* that anything is wrong on this end. So the harness ships a way to hand itself
|
|
8
|
+
* to the OS supervisor.
|
|
9
|
+
*
|
|
10
|
+
* On macOS that is a per-user launchd agent: a plist in `~/Library/LaunchAgents`
|
|
11
|
+
* with `KeepAlive`, bootstrapped into the `gui/$UID` domain so it starts at
|
|
12
|
+
* login and is restarted whenever it exits. On Linux there is no equivalent
|
|
13
|
+
* this CLI can write safely (systemd-user layouts differ per distro and per
|
|
14
|
+
* init), so we print a unit file to install by hand rather than guess.
|
|
15
|
+
*
|
|
16
|
+
* Only plist *generation* is unit-tested. Asserting on `launchctl` would be
|
|
17
|
+
* testing Apple's binary, and doing it for real in CI would install a live
|
|
18
|
+
* daemon on the runner.
|
|
19
|
+
*/
|
|
20
|
+
/** Reverse-DNS prefix every generated label starts with. */
|
|
21
|
+
export declare const LAUNCHD_LABEL_PREFIX = "ai.prohost.agent";
|
|
22
|
+
export interface DaemonSpec {
|
|
23
|
+
/** launchd label, also the plist filename stem. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** argv[0] plus arguments, exactly as launchd will exec them. */
|
|
26
|
+
programArguments: string[];
|
|
27
|
+
/** Working directory for the harness process. */
|
|
28
|
+
workdir: string;
|
|
29
|
+
/** Combined stdout/stderr destination. */
|
|
30
|
+
logPath: string;
|
|
31
|
+
/** Extra environment launchd must set — launchd inherits almost nothing. */
|
|
32
|
+
env?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Label for the daemon serving one `$PROHOST_HOME`.
|
|
36
|
+
*
|
|
37
|
+
* Keyed on the home directory rather than on the agent id, because the home
|
|
38
|
+
* directory is what actually scopes an install: there is exactly one
|
|
39
|
+
* `agent.json` in it, so every daemon pointed at the same home runs the *same*
|
|
40
|
+
* agent no matter which agent was paired when it was installed. An agent-keyed
|
|
41
|
+
* label got that backwards — re-pairing and re-installing produced a second
|
|
42
|
+
* label whose plist loaded the same single credential, so both jobs came up as
|
|
43
|
+
* the new agent (two harnesses, duplicate work) and `uninstall-daemon` could no
|
|
44
|
+
* longer name the first one to remove it.
|
|
45
|
+
*
|
|
46
|
+
* Running two agents at once is still supported, and this is what makes it
|
|
47
|
+
* honest: give each one its own `$PROHOST_HOME`, and they get separate
|
|
48
|
+
* credentials, separate ledgers, and separate daemons.
|
|
49
|
+
*
|
|
50
|
+
* A digest rather than the path itself — a label is a filename, and home
|
|
51
|
+
* directories contain spaces, `~`, and non-ASCII.
|
|
52
|
+
*/
|
|
53
|
+
export declare function launchdLabel(home: string): string;
|
|
54
|
+
/** Where a label's plist lives. */
|
|
55
|
+
export declare function launchdPlistPath(label: string, home?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Render the launchd property list for a daemon spec.
|
|
58
|
+
*
|
|
59
|
+
* `KeepAlive` is unconditional `true` rather than `SuccessfulExit: false`:
|
|
60
|
+
* `agent run` exits 0 on a clean shutdown *and* on a fatal auth error, and a
|
|
61
|
+
* daemon that stops restarting after a revoked-then-restored credential is
|
|
62
|
+
* exactly the silent-agent failure this whole feature exists to prevent.
|
|
63
|
+
* Restarting into a rejected credential is loud and cheap; not restarting is
|
|
64
|
+
* quiet and expensive.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildLaunchdPlist(spec: DaemonSpec): string;
|
|
67
|
+
/**
|
|
68
|
+
* The systemd-user unit we print on Linux.
|
|
69
|
+
*
|
|
70
|
+
* Printed rather than installed. `systemctl --user` needs a running user
|
|
71
|
+
* manager and lingering enabled for it to survive logout, neither of which this
|
|
72
|
+
* CLI can arrange without making decisions about the machine that aren't its
|
|
73
|
+
* to make.
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildSystemdUnit(spec: DaemonSpec): string;
|
|
76
|
+
/** Reconstruct the `agent run` argv a daemon should launch. */
|
|
77
|
+
export interface HarnessInvocation {
|
|
78
|
+
/** Interpreter to exec — normally the Node binary running this process. */
|
|
79
|
+
execPath: string;
|
|
80
|
+
/** Absolute path of the CLI entrypoint script. */
|
|
81
|
+
scriptPath: string;
|
|
82
|
+
/** The `--exec` command, verbatim. */
|
|
83
|
+
exec: string;
|
|
84
|
+
workdir: string;
|
|
85
|
+
timeoutSeconds?: number;
|
|
86
|
+
machine?: string;
|
|
87
|
+
url?: string;
|
|
88
|
+
allowUnverified?: boolean;
|
|
89
|
+
/** Gate tools behind permission prompts (the `--safe-tools` opt-out). */
|
|
90
|
+
safeTools?: boolean;
|
|
91
|
+
}
|
|
92
|
+
export declare function harnessArguments(invocation: HarnessInvocation): string[];
|
|
93
|
+
/** Default log destination — alongside the credential and the run ledger. */
|
|
94
|
+
export declare function agentLogPath(env?: NodeJS.ProcessEnv): string;
|
|
95
|
+
export interface InstallResult {
|
|
96
|
+
ok: boolean;
|
|
97
|
+
/** Human-readable report, already formatted for the terminal. */
|
|
98
|
+
message: string;
|
|
99
|
+
/** Path written, when one was. */
|
|
100
|
+
plistPath?: string;
|
|
101
|
+
}
|
|
102
|
+
type Runner = (command: string, args: string[]) => {
|
|
103
|
+
status: number | null;
|
|
104
|
+
stderr: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Write the plist and hand it to launchd.
|
|
108
|
+
*
|
|
109
|
+
* `bootout` first, ignoring its result: bootstrapping a label that is already
|
|
110
|
+
* loaded fails outright, so a plain re-install of an agent whose `--exec`
|
|
111
|
+
* changed would otherwise keep running the old command and report success.
|
|
112
|
+
*/
|
|
113
|
+
export declare function installLaunchdDaemon(spec: DaemonSpec, options?: {
|
|
114
|
+
home?: string;
|
|
115
|
+
uid?: number;
|
|
116
|
+
run?: Runner;
|
|
117
|
+
}): InstallResult;
|
|
118
|
+
/**
|
|
119
|
+
* Unload the daemon and delete its plist. Missing pieces are not an error.
|
|
120
|
+
*
|
|
121
|
+
* Reports whether anything was actually there. "✓ Removed" printed over an
|
|
122
|
+
* empty home reads as confirmation that a daemon has been stopped — which is
|
|
123
|
+
* exactly the wrong thing to tell someone whose agent is still running from a
|
|
124
|
+
* *different* `$PROHOST_HOME` than the one they just pointed this at.
|
|
125
|
+
*/
|
|
126
|
+
export declare function uninstallLaunchdDaemon(label: string, options?: {
|
|
127
|
+
home?: string;
|
|
128
|
+
uid?: number;
|
|
129
|
+
run?: Runner;
|
|
130
|
+
}): InstallResult;
|
|
131
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping `agent run` alive across logouts, crashes, and reboots.
|
|
3
|
+
*
|
|
4
|
+
* A paired agent that only runs while a terminal window is open is not a
|
|
5
|
+
* teammate — it is a demo. Someone @-mentions it at 2am, the laptop had
|
|
6
|
+
* rebooted for an update, and the run times out server-side with no indication
|
|
7
|
+
* that anything is wrong on this end. So the harness ships a way to hand itself
|
|
8
|
+
* to the OS supervisor.
|
|
9
|
+
*
|
|
10
|
+
* On macOS that is a per-user launchd agent: a plist in `~/Library/LaunchAgents`
|
|
11
|
+
* with `KeepAlive`, bootstrapped into the `gui/$UID` domain so it starts at
|
|
12
|
+
* login and is restarted whenever it exits. On Linux there is no equivalent
|
|
13
|
+
* this CLI can write safely (systemd-user layouts differ per distro and per
|
|
14
|
+
* init), so we print a unit file to install by hand rather than guess.
|
|
15
|
+
*
|
|
16
|
+
* Only plist *generation* is unit-tested. Asserting on `launchctl` would be
|
|
17
|
+
* testing Apple's binary, and doing it for real in CI would install a live
|
|
18
|
+
* daemon on the runner.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { prohostHome } from './credentials.js';
|
|
27
|
+
/** Reverse-DNS prefix every generated label starts with. */
|
|
28
|
+
export const LAUNCHD_LABEL_PREFIX = 'ai.prohost.agent';
|
|
29
|
+
/** Hex characters of the home-directory digest that go into the label. */
|
|
30
|
+
const LABEL_DIGEST_CHARS = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Label for the daemon serving one `$PROHOST_HOME`.
|
|
33
|
+
*
|
|
34
|
+
* Keyed on the home directory rather than on the agent id, because the home
|
|
35
|
+
* directory is what actually scopes an install: there is exactly one
|
|
36
|
+
* `agent.json` in it, so every daemon pointed at the same home runs the *same*
|
|
37
|
+
* agent no matter which agent was paired when it was installed. An agent-keyed
|
|
38
|
+
* label got that backwards — re-pairing and re-installing produced a second
|
|
39
|
+
* label whose plist loaded the same single credential, so both jobs came up as
|
|
40
|
+
* the new agent (two harnesses, duplicate work) and `uninstall-daemon` could no
|
|
41
|
+
* longer name the first one to remove it.
|
|
42
|
+
*
|
|
43
|
+
* Running two agents at once is still supported, and this is what makes it
|
|
44
|
+
* honest: give each one its own `$PROHOST_HOME`, and they get separate
|
|
45
|
+
* credentials, separate ledgers, and separate daemons.
|
|
46
|
+
*
|
|
47
|
+
* A digest rather than the path itself — a label is a filename, and home
|
|
48
|
+
* directories contain spaces, `~`, and non-ASCII.
|
|
49
|
+
*/
|
|
50
|
+
export function launchdLabel(home) {
|
|
51
|
+
const digest = createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, LABEL_DIGEST_CHARS);
|
|
52
|
+
return `${LAUNCHD_LABEL_PREFIX}.${digest}`;
|
|
53
|
+
}
|
|
54
|
+
/** Where a label's plist lives. */
|
|
55
|
+
export function launchdPlistPath(label, home = os.homedir()) {
|
|
56
|
+
return path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* XML-escape a plist string value.
|
|
60
|
+
*
|
|
61
|
+
* Not optional politeness: an `--exec` command is arbitrary shell the operator
|
|
62
|
+
* typed, and `--exec 'sh -c "foo && bar"'` is a perfectly ordinary thing to
|
|
63
|
+
* want. Interpolated raw, its `&` alone produces a plist that `launchctl`
|
|
64
|
+
* rejects as malformed — with an error that points at the file, not at the
|
|
65
|
+
* flag that poisoned it.
|
|
66
|
+
*/
|
|
67
|
+
function xmlEscape(value) {
|
|
68
|
+
return value
|
|
69
|
+
.replace(/&/g, '&')
|
|
70
|
+
.replace(/</g, '<')
|
|
71
|
+
.replace(/>/g, '>')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/'/g, ''');
|
|
74
|
+
}
|
|
75
|
+
function stringEntries(values, indent) {
|
|
76
|
+
return Object.entries(values).flatMap(([key, value]) => [
|
|
77
|
+
`${indent}<key>${xmlEscape(key)}</key>`,
|
|
78
|
+
`${indent}<string>${xmlEscape(value)}</string>`,
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Render the launchd property list for a daemon spec.
|
|
83
|
+
*
|
|
84
|
+
* `KeepAlive` is unconditional `true` rather than `SuccessfulExit: false`:
|
|
85
|
+
* `agent run` exits 0 on a clean shutdown *and* on a fatal auth error, and a
|
|
86
|
+
* daemon that stops restarting after a revoked-then-restored credential is
|
|
87
|
+
* exactly the silent-agent failure this whole feature exists to prevent.
|
|
88
|
+
* Restarting into a rejected credential is loud and cheap; not restarting is
|
|
89
|
+
* quiet and expensive.
|
|
90
|
+
*/
|
|
91
|
+
export function buildLaunchdPlist(spec) {
|
|
92
|
+
const lines = [
|
|
93
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
94
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' +
|
|
95
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
96
|
+
'<plist version="1.0">',
|
|
97
|
+
'<dict>',
|
|
98
|
+
' <key>Label</key>',
|
|
99
|
+
` <string>${xmlEscape(spec.label)}</string>`,
|
|
100
|
+
' <key>ProgramArguments</key>',
|
|
101
|
+
' <array>',
|
|
102
|
+
...spec.programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`),
|
|
103
|
+
' </array>',
|
|
104
|
+
' <key>WorkingDirectory</key>',
|
|
105
|
+
` <string>${xmlEscape(spec.workdir)}</string>`,
|
|
106
|
+
' <key>RunAtLoad</key>',
|
|
107
|
+
' <true/>',
|
|
108
|
+
' <key>KeepAlive</key>',
|
|
109
|
+
' <true/>',
|
|
110
|
+
// Without this a crash-looping harness would be restarted as fast as it can
|
|
111
|
+
// fail, which on a bad credential means hammering the pairing endpoint.
|
|
112
|
+
' <key>ThrottleInterval</key>',
|
|
113
|
+
' <integer>10</integer>',
|
|
114
|
+
' <key>StandardOutPath</key>',
|
|
115
|
+
` <string>${xmlEscape(spec.logPath)}</string>`,
|
|
116
|
+
' <key>StandardErrorPath</key>',
|
|
117
|
+
` <string>${xmlEscape(spec.logPath)}</string>`,
|
|
118
|
+
];
|
|
119
|
+
if (spec.env && Object.keys(spec.env).length > 0) {
|
|
120
|
+
lines.push(' <key>EnvironmentVariables</key>', ' <dict>', ...stringEntries(spec.env, ' '), ' </dict>');
|
|
121
|
+
}
|
|
122
|
+
lines.push('</dict>', '</plist>');
|
|
123
|
+
return `${lines.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Quote one `ExecStart` word for systemd.
|
|
127
|
+
*
|
|
128
|
+
* systemd re-splits `ExecStart` on whitespace, so joining argv with spaces
|
|
129
|
+
* silently corrupts any argument that contains one — and the very first thing
|
|
130
|
+
* this CLI passes is `--exec 'claude -p'`. Unquoted, systemd hands `--exec` the
|
|
131
|
+
* value `claude` and treats `-p` as a flag of its own; `codex exec -` loses two
|
|
132
|
+
* words the same way. Quoting is not decoration here, it is the difference
|
|
133
|
+
* between a working unit and one that starts the wrong process.
|
|
134
|
+
*/
|
|
135
|
+
function systemdQuote(value) {
|
|
136
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The systemd-user unit we print on Linux.
|
|
140
|
+
*
|
|
141
|
+
* Printed rather than installed. `systemctl --user` needs a running user
|
|
142
|
+
* manager and lingering enabled for it to survive logout, neither of which this
|
|
143
|
+
* CLI can arrange without making decisions about the machine that aren't its
|
|
144
|
+
* to make.
|
|
145
|
+
*/
|
|
146
|
+
export function buildSystemdUnit(spec) {
|
|
147
|
+
const environment = Object.entries(spec.env ?? {})
|
|
148
|
+
.map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
|
|
149
|
+
.join('\n');
|
|
150
|
+
return [
|
|
151
|
+
'[Unit]',
|
|
152
|
+
'Description=ProhostAI paired agent',
|
|
153
|
+
'After=network-online.target',
|
|
154
|
+
'',
|
|
155
|
+
'[Service]',
|
|
156
|
+
'Type=simple',
|
|
157
|
+
`WorkingDirectory=${systemdQuote(spec.workdir)}`,
|
|
158
|
+
`ExecStart=${spec.programArguments.map(systemdQuote).join(' ')}`,
|
|
159
|
+
'Restart=always',
|
|
160
|
+
'RestartSec=10',
|
|
161
|
+
...(environment ? [environment] : []),
|
|
162
|
+
'',
|
|
163
|
+
'[Install]',
|
|
164
|
+
'WantedBy=default.target',
|
|
165
|
+
'',
|
|
166
|
+
].join('\n');
|
|
167
|
+
}
|
|
168
|
+
export function harnessArguments(invocation) {
|
|
169
|
+
const args = [
|
|
170
|
+
invocation.execPath,
|
|
171
|
+
invocation.scriptPath,
|
|
172
|
+
'agent',
|
|
173
|
+
'run',
|
|
174
|
+
'--exec',
|
|
175
|
+
invocation.exec,
|
|
176
|
+
'--workdir',
|
|
177
|
+
invocation.workdir,
|
|
178
|
+
];
|
|
179
|
+
if (invocation.timeoutSeconds !== undefined)
|
|
180
|
+
args.push('--timeout', String(invocation.timeoutSeconds));
|
|
181
|
+
if (invocation.machine)
|
|
182
|
+
args.push('--machine', invocation.machine);
|
|
183
|
+
if (invocation.url)
|
|
184
|
+
args.push('--url', invocation.url);
|
|
185
|
+
if (invocation.allowUnverified)
|
|
186
|
+
args.push('--allow-unverified');
|
|
187
|
+
if (invocation.safeTools)
|
|
188
|
+
args.push('--safe-tools');
|
|
189
|
+
return args;
|
|
190
|
+
}
|
|
191
|
+
/** Default log destination — alongside the credential and the run ledger. */
|
|
192
|
+
export function agentLogPath(env = process.env) {
|
|
193
|
+
return path.join(prohostHome(env), 'agent.log');
|
|
194
|
+
}
|
|
195
|
+
const defaultRunner = (command, args) => {
|
|
196
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
197
|
+
return { status: result.status, stderr: result.stderr ?? '' };
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Write the plist and hand it to launchd.
|
|
201
|
+
*
|
|
202
|
+
* `bootout` first, ignoring its result: bootstrapping a label that is already
|
|
203
|
+
* loaded fails outright, so a plain re-install of an agent whose `--exec`
|
|
204
|
+
* changed would otherwise keep running the old command and report success.
|
|
205
|
+
*/
|
|
206
|
+
export function installLaunchdDaemon(spec, options = {}) {
|
|
207
|
+
const home = options.home ?? os.homedir();
|
|
208
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
209
|
+
const run = options.run ?? defaultRunner;
|
|
210
|
+
const plistPath = launchdPlistPath(spec.label, home);
|
|
211
|
+
const domain = `gui/${uid}`;
|
|
212
|
+
mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
213
|
+
// 0600 rather than the usual 0644 for a LaunchAgent: this plist carries the
|
|
214
|
+
// installing shell's environment, and while today that is only PATH and
|
|
215
|
+
// PROHOST_HOME, a file whose whole job is "environment for a credentialed
|
|
216
|
+
// process" should not be readable by other local accounts. launchd runs it as
|
|
217
|
+
// the owning user, so nothing needs wider access.
|
|
218
|
+
writeFileSync(plistPath, buildLaunchdPlist(spec), { mode: 0o600 });
|
|
219
|
+
chmodSync(plistPath, 0o600);
|
|
220
|
+
run('launchctl', ['bootout', `${domain}/${spec.label}`]);
|
|
221
|
+
const bootstrap = run('launchctl', ['bootstrap', domain, plistPath]);
|
|
222
|
+
if (bootstrap.status !== 0) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
plistPath,
|
|
226
|
+
message: `Wrote ${plistPath}, but \`launchctl bootstrap ${domain}\` failed` +
|
|
227
|
+
`${bootstrap.stderr.trim() ? `: ${bootstrap.stderr.trim()}` : '.'}\n` +
|
|
228
|
+
`Load it by hand with: launchctl bootstrap ${domain} ${plistPath}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
run('launchctl', ['enable', `${domain}/${spec.label}`]);
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
plistPath,
|
|
235
|
+
message: `✓ Installed ${spec.label}\n` +
|
|
236
|
+
` plist: ${plistPath}\n` +
|
|
237
|
+
` logs: ${spec.logPath}\n` +
|
|
238
|
+
` The agent now starts at login and is restarted if it exits.\n` +
|
|
239
|
+
// Said at install time because the alternative is finding out from a
|
|
240
|
+
// crash-loop in a log nobody is watching: a supervised process does not
|
|
241
|
+
// inherit the shell you installed from, so an --exec that depends on an
|
|
242
|
+
// exported API key works interactively and fails here.
|
|
243
|
+
` It runs with only PATH and PROHOST_HOME from this shell. If your\n` +
|
|
244
|
+
` command needs other environment (an API key, a proxy setting), point\n` +
|
|
245
|
+
` --exec at a wrapper script that sets it.\n` +
|
|
246
|
+
` Stop it with: prohost agent uninstall-daemon`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Unload the daemon and delete its plist. Missing pieces are not an error.
|
|
251
|
+
*
|
|
252
|
+
* Reports whether anything was actually there. "✓ Removed" printed over an
|
|
253
|
+
* empty home reads as confirmation that a daemon has been stopped — which is
|
|
254
|
+
* exactly the wrong thing to tell someone whose agent is still running from a
|
|
255
|
+
* *different* `$PROHOST_HOME` than the one they just pointed this at.
|
|
256
|
+
*/
|
|
257
|
+
export function uninstallLaunchdDaemon(label, options = {}) {
|
|
258
|
+
const home = options.home ?? os.homedir();
|
|
259
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
260
|
+
const run = options.run ?? defaultRunner;
|
|
261
|
+
const plistPath = launchdPlistPath(label, home);
|
|
262
|
+
const existed = existsSync(plistPath);
|
|
263
|
+
run('launchctl', ['bootout', `gui/${uid}/${label}`]);
|
|
264
|
+
rmSync(plistPath, { force: true });
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
plistPath,
|
|
268
|
+
message: existed
|
|
269
|
+
? `✓ Removed ${label} (${plistPath})`
|
|
270
|
+
: `No daemon was installed for this PROHOST_HOME (looked for ${plistPath}).\n` +
|
|
271
|
+
` Nothing to remove. If an agent is still running, it belongs to a\n` +
|
|
272
|
+
` different PROHOST_HOME — check with: launchctl list | grep ${LAUNCHD_LABEL_PREFIX}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -10,8 +10,23 @@ import type { AgentRunRequest } from './contract.js';
|
|
|
10
10
|
export interface PromptContext {
|
|
11
11
|
/** Display name of the paired AI employee, e.g. "Ada". */
|
|
12
12
|
agentName: string;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* MCP server name the run's tools are registered under, e.g. `prohost`.
|
|
15
|
+
*
|
|
16
|
+
* Set **only** when the harness actually wired those tools into this
|
|
17
|
+
* invocation. Naming tools the child cannot call is worse than naming
|
|
18
|
+
* nothing: the agent reports having looked something up when it did not.
|
|
19
|
+
*/
|
|
20
|
+
mcpServerName?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Tools are behind permission prompts this run (`--safe-tools`).
|
|
23
|
+
*
|
|
24
|
+
* Changes what is true, so it has to change what the prompt says. Gated, a
|
|
25
|
+
* tool needing approval will never run and the agent must plan around that.
|
|
26
|
+
* Ungated — the default — it can act, and telling it otherwise would make it
|
|
27
|
+
* decline work it is perfectly able to do.
|
|
28
|
+
*/
|
|
29
|
+
toolsGated?: boolean;
|
|
15
30
|
}
|
|
16
31
|
/** Build the stdin prompt for one run. */
|
|
17
32
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
* a shell script that greps it. The reply contract is stated explicitly so an
|
|
7
7
|
* agent that would otherwise narrate its reasoning returns something sendable.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Render one line of thread history.
|
|
11
|
+
*
|
|
12
|
+
* The role is a parenthetical *qualifier* on a name, not a substitute for one:
|
|
13
|
+
* a row with a role and no author — which is what a system row like
|
|
14
|
+
* `{role: "event", text: "Reservation confirmed"}` looks like — reads as
|
|
15
|
+
* `event: …` rather than the nonsense `someone (event): …`.
|
|
16
|
+
*/
|
|
17
|
+
function renderMessage(message) {
|
|
18
|
+
const who = message.author ?? message.role ?? 'someone';
|
|
19
|
+
const role = message.author && message.role ? ` (${message.role})` : '';
|
|
20
|
+
const when = message.at ? `[${message.at}] ` : '';
|
|
21
|
+
return `${when}${who}${role}: ${message.text}`;
|
|
22
|
+
}
|
|
23
|
+
/** Render one remembered fact. */
|
|
24
|
+
function renderMemory(memory) {
|
|
25
|
+
return memory.scope ? `- (${memory.scope}) ${memory.content}` : `- ${memory.content}`;
|
|
26
|
+
}
|
|
9
27
|
/** Build the stdin prompt for one run. */
|
|
10
28
|
export function buildPrompt(run, context) {
|
|
11
29
|
const name = run.agent_name ?? context.agentName;
|
|
@@ -42,12 +60,18 @@ export function buildPrompt(run, context) {
|
|
|
42
60
|
// only handle the agent has on what was actually said.
|
|
43
61
|
if (run.trigger_message_id)
|
|
44
62
|
facts.push(`Comment/message id: ${run.trigger_message_id}`);
|
|
45
|
-
if (context.mcpUrl) {
|
|
46
|
-
facts.push(`ProhostAI MCP endpoint (for looking things up): ${context.mcpUrl}`);
|
|
47
|
-
}
|
|
48
63
|
if (facts.length > 0) {
|
|
49
64
|
lines.push('Context:', ...facts.map((f) => `- ${f}`), '');
|
|
50
65
|
}
|
|
66
|
+
// Memories first, then the thread, then the message that woke the agent:
|
|
67
|
+
// oldest and most general context at the top, the thing being answered last
|
|
68
|
+
// and closest to the instruction.
|
|
69
|
+
if (run.memories?.length) {
|
|
70
|
+
lines.push('Things you remember (from earlier work in this workspace):', ...run.memories.map(renderMemory), '');
|
|
71
|
+
}
|
|
72
|
+
if (run.recent_messages?.length) {
|
|
73
|
+
lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
|
|
74
|
+
}
|
|
51
75
|
if (run.trigger_text) {
|
|
52
76
|
// The server caps this excerpt, so say so rather than let an agent assume
|
|
53
77
|
// it has the whole message.
|
|
@@ -56,6 +80,40 @@ export function buildPrompt(run, context) {
|
|
|
56
80
|
else {
|
|
57
81
|
lines.push('The message text was not included in the event.', '');
|
|
58
82
|
}
|
|
83
|
+
// An agent that doesn't know it is headless narrates as if it were mid-
|
|
84
|
+
// session — "let me connect to that", "I'll check once the tools load" —
|
|
85
|
+
// and that text is what gets posted to a customer, describing a lookup that
|
|
86
|
+
// never happened. Saying the shape of the invocation out loud is the cheapest
|
|
87
|
+
// fix; the alternative is a reply that is confidently wrong about its own
|
|
88
|
+
// capabilities.
|
|
89
|
+
lines.push('How you are running:', '- This is a single non-interactive turn. Nobody is at a terminal, so', context.toolsGated
|
|
90
|
+
? ' any tool that would need approval will not run — plan around that\n' +
|
|
91
|
+
' rather than asking for permission, which gets no answer.'
|
|
92
|
+
: ' nothing will stop to ask you for permission: your tools run as soon\n' +
|
|
93
|
+
' as you call them. Do the work rather than proposing it — but that\n' +
|
|
94
|
+
' also means a mistaken action happens for real, so check before you\n' +
|
|
95
|
+
' change or delete anything.', '- Nothing about your access changes after this turn. Do not say a tool is', ' "connecting", "loading", or about to become available, and do not', ' promise to follow up.',
|
|
96
|
+
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
97
|
+
// them — so a run that inspects its loaded tools concludes it cannot
|
|
98
|
+
// browse and says so to a customer, when one ToolSearch call away it
|
|
99
|
+
// could. Field-observed on the first 0.2.0 deployment; phrased
|
|
100
|
+
// conditionally because other `--exec` brains have no ToolSearch.
|
|
101
|
+
'- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.', "- State limits plainly. If you cannot look something up, say what you don't", ' know and answer with what you do — a short honest answer beats a', ' confident invented one.',
|
|
102
|
+
// Deliberately does not list which domains are reachable. A paired
|
|
103
|
+
// credential holds a narrow, server-chosen scope set — the current default
|
|
104
|
+
// covers conversations, tasks, cleanings and approvals but NOT reservations
|
|
105
|
+
// or listings — and any list written here is a promise this CLI cannot
|
|
106
|
+
// keep across pairings or across changes to the default scopes. Naming
|
|
107
|
+
// domains the credential can't reach is the same failure as naming an MCP
|
|
108
|
+
// endpoint it can't call: the agent reports a lookup it never made.
|
|
109
|
+
context.mcpServerName
|
|
110
|
+
? `- You have ProhostAI tools (mcp__${context.mcpServerName}__*). They are the sanctioned ` +
|
|
111
|
+
'way to look up workspace data, so use them rather than guessing. Your credential is ' +
|
|
112
|
+
'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
|
|
113
|
+
'a permission error, that is a real limit rather than a glitch — say what you could not ' +
|
|
114
|
+
'look up instead of retrying it or answering as if you had.'
|
|
115
|
+
: '- You have no ProhostAI tools in this run. Everything you know about this ' +
|
|
116
|
+
'workspace is what is written above.', '');
|
|
59
117
|
// Postability is a separate question from what the run is about: a task run
|
|
60
118
|
// has no comment route in the external API yet, so the agent must be told its
|
|
61
119
|
// output won't be posted rather than left to assume it will.
|