@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.
@@ -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, '&amp;')
70
+ .replace(/</g, '&lt;')
71
+ .replace(/>/g, '&gt;')
72
+ .replace(/"/g, '&quot;')
73
+ .replace(/'/g, '&apos;');
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
+ }
@@ -10,8 +10,23 @@ import type { AgentRunRequest } from './contract.js';
10
10
  export interface PromptContext {
11
11
  /** Display name of the paired AI employee, e.g. "Ada". */
12
12
  agentName: string;
13
- /** MCP endpoint the agent can use to act back into the account. */
14
- mcpUrl?: string;
13
+ /**
14
+ * MCP server name the run's tools are registered under, e.g. `prohost`.
15
+ *
16
+ * Set **only** when the harness actually wired those tools into this
17
+ * invocation. Naming tools the child cannot call is worse than naming
18
+ * nothing: the agent reports having looked something up when it did not.
19
+ */
20
+ mcpServerName?: string;
21
+ /**
22
+ * Tools are behind permission prompts this run (`--safe-tools`).
23
+ *
24
+ * Changes what is true, so it has to change what the prompt says. Gated, a
25
+ * tool needing approval will never run and the agent must plan around that.
26
+ * Ungated — the default — it can act, and telling it otherwise would make it
27
+ * decline work it is perfectly able to do.
28
+ */
29
+ toolsGated?: boolean;
15
30
  }
16
31
  /** Build the stdin prompt for one run. */
17
32
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
@@ -6,6 +6,24 @@
6
6
  * a shell script that greps it. The reply contract is stated explicitly so an
7
7
  * agent that would otherwise narrate its reasoning returns something sendable.
8
8
  */
9
+ /**
10
+ * Render one line of thread history.
11
+ *
12
+ * The role is a parenthetical *qualifier* on a name, not a substitute for one:
13
+ * a row with a role and no author — which is what a system row like
14
+ * `{role: "event", text: "Reservation confirmed"}` looks like — reads as
15
+ * `event: …` rather than the nonsense `someone (event): …`.
16
+ */
17
+ function renderMessage(message) {
18
+ const who = message.author ?? message.role ?? 'someone';
19
+ const role = message.author && message.role ? ` (${message.role})` : '';
20
+ const when = message.at ? `[${message.at}] ` : '';
21
+ return `${when}${who}${role}: ${message.text}`;
22
+ }
23
+ /** Render one remembered fact. */
24
+ function renderMemory(memory) {
25
+ return memory.scope ? `- (${memory.scope}) ${memory.content}` : `- ${memory.content}`;
26
+ }
9
27
  /** Build the stdin prompt for one run. */
10
28
  export function buildPrompt(run, context) {
11
29
  const name = run.agent_name ?? context.agentName;
@@ -42,12 +60,18 @@ export function buildPrompt(run, context) {
42
60
  // only handle the agent has on what was actually said.
43
61
  if (run.trigger_message_id)
44
62
  facts.push(`Comment/message id: ${run.trigger_message_id}`);
45
- if (context.mcpUrl) {
46
- facts.push(`ProhostAI MCP endpoint (for looking things up): ${context.mcpUrl}`);
47
- }
48
63
  if (facts.length > 0) {
49
64
  lines.push('Context:', ...facts.map((f) => `- ${f}`), '');
50
65
  }
66
+ // Memories first, then the thread, then the message that woke the agent:
67
+ // oldest and most general context at the top, the thing being answered last
68
+ // and closest to the instruction.
69
+ if (run.memories?.length) {
70
+ lines.push('Things you remember (from earlier work in this workspace):', ...run.memories.map(renderMemory), '');
71
+ }
72
+ if (run.recent_messages?.length) {
73
+ lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
74
+ }
51
75
  if (run.trigger_text) {
52
76
  // The server caps this excerpt, so say so rather than let an agent assume
53
77
  // it has the whole message.
@@ -56,6 +80,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.