Skip to content

Commit 2448ec0

Browse files
tps-flintclaude
andauthored
feat(cli): retire serve — unify on run, lifespan is the only knob (#44)
Per the Phase-1 spec ("serve/live retired — it's all the agent running; lifespan is the only distinction") + Nathan: the persistent entrypoint was still a separate `bob serve` command. Fold it into `run`: - `bob run <name>` → PERSISTENT/on-duty (what `serve` did): one warm session, loads capabilities (discord gateway, cron), blocks until SIGTERM. The service unit runs this. - `bob run <name> "<task>"` → MINIMAL one-shot (claude -p style): ephemeral, no gateway (BOB_PERSISTENT unset, #43), prints the response, exits. - `bob serve` deleted; service.ts ExecStart is now `bob run <name>`. So "run minimal by default" (a task prompt is the minimal path) and the spec's "persistent default" (no prompt = on-duty) both hold — prompt-presence is the lifespan knob. Help + tests updated. NOTE: Pulse's hand-rolled systemd unit still has `ExecStart=… bob serve pulse` — I update it to `bob run pulse` in the same deploy so she doesn't break. Co-authored-by: Flint <263629284+tps-flint@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d74d062 commit 2448ec0

4 files changed

Lines changed: 36 additions & 49 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,12 @@ Commands:
6262
--dry-run --force --no-interactive
6363
align <name> Recurring check-in to refine an existing agent
6464
Flags: --provider <p> --model <m> --agent-dir <dir>
65-
run <name> <prompt> Run one short-lived task for the named agent (embeds pi
66-
via its SDK — fresh session, one prompt, exit)
65+
run <name> Run the agent PERSISTENTLY (on-duty) — one warm pi session
66+
that stays up, loading bob.yaml capabilities (discord
67+
gateway, cron). This is what the service unit runs.
68+
run <name> <prompt> Run ONE short-lived task (claude -p style) — minimal +
69+
ephemeral, no gateway. Prints the response, exits.
6770
Flags: --model <m> (--interactive: coming in a later PR)
68-
serve <name> Run the agent PERSISTENTLY — one warm pi session that stays
69-
up, loading the agent's bob.yaml capabilities (incl. discord).
70-
This is what the service unit runs. Flags: --model <m>
7171
install-service <n> Write the agent's service unit (launchd on macOS / systemd
7272
user unit on Linux) so it self-runs. Flags: --bob-bin <abs path> --model <m>
7373
up <name> Load + start the agent's service unit
@@ -167,44 +167,37 @@ async function run(
167167
flags: Record<string, string | boolean>,
168168
): Promise<number> {
169169
const model = flags.model !== undefined && flags.model !== true ? String(flags.model) : undefined;
170-
// PR1 migrated `bob run` to pi's embedded SDK for the non-interactive prompt
171-
// path only. The interactive REPL on the SDK lands in a later phase-1 PR.
170+
// The interactive REPL on the SDK lands in a later phase-1 PR.
172171
if (flags.interactive === true) {
173172
console.error(
174-
"bob run: --interactive is not yet supported on the embedded-SDK path (use a prompt for now)",
173+
"bob run: --interactive is not yet supported on the embedded-SDK path (give a task prompt for now)",
175174
);
176175
return 2;
177176
}
177+
// Lifespan is the only knob (`serve` is retired): a task prompt = a MINIMAL
178+
// one-shot; NO prompt = the agent runs PERSISTENTLY (on-duty).
178179
if (prompt === undefined) {
179-
console.error('bob run: a prompt is required, e.g. `bob run <name> "summarize my inbox"`');
180-
return 2;
180+
// PERSISTENT: one warm pi session that stays up, loading the agent's
181+
// bob.yaml capabilities (discord's inbound gateway, cron, …) and posting
182+
// back via them. This is what the service unit invokes. Blocks until
183+
// SIGTERM/SIGINT — runPersistent disposes the session gracefully (await
184+
// in-flight turn → dispose → exit 0); KeepAlive/Restart relaunches it.
185+
await runPersistent({ name, model });
186+
return 0;
181187
}
182-
// captureStdout collects the assistant's final text into result.stdout.
183-
// runAgent itself is silent (it accumulates deltas, never writes to console),
184-
// so without this `bob run` printed nothing — print the response here.
188+
// ONE-SHOT TASK (claude -p style) — minimal + ephemeral (no gateway; see
189+
// BOB_PERSISTENT in run.ts). captureStdout collects the assistant's final
190+
// text (runAgent is otherwise silent), so we print the response.
185191
const result = await runAgent({ name, prompt, model, captureStdout: true });
186192
if (result.stdout && result.stdout.trim().length > 0) {
187193
console.log(result.stdout);
188194
}
189195
return result.exitCode;
190196
}
191197

192-
// `bob serve <name>` — run the agent PERSISTENTLY. ONE warm pi AgentSession that
193-
// stays up, loading the agent's bob.yaml `capabilities:` (incl. discord, whose
194-
// gateway listener feeds inbound messages via pi.sendUserMessage and routes
195-
// replies back to the originating channel). This is the entrypoint the launchd
196-
// unit runs. Discord is no longer a CLI flag — it's a declared capability.
197-
//
198-
// Blocks until SIGTERM/SIGINT, which runPersistent handles gracefully (await
199-
// in-flight turn → session.dispose() → exit 0). KeepAlive relaunches it.
200-
async function serve(name: string, flags: Record<string, string | boolean>): Promise<void> {
201-
const model = flags.model !== undefined && flags.model !== true ? String(flags.model) : undefined;
202-
await runPersistent({ name, model });
203-
}
204-
205198
// `bob install-service <name>` — write the agent's launchd plist so it self-runs
206199
// (KeepAlive + RunAtLoad). Does NOT start it — that's `bob up`. The plist runs
207-
// `bob serve <name>`; it embeds NO secrets (the discord token is read from the
200+
// `bob run <name>`; it embeds NO secrets (the discord token is read from the
208201
// file path in bob.yaml at runtime).
209202
async function installServiceCmd(
210203
name: string,
@@ -219,7 +212,7 @@ async function installServiceCmd(
219212
const model = flags.model !== undefined && flags.model !== true ? String(flags.model) : undefined;
220213
const { path: written } = await installService({ name, bobBin, model });
221214
console.log(`[bob install-service] wrote ${written}`);
222-
console.log(` runs: ${bobBin} serve ${name}`);
215+
console.log(` runs: ${bobBin} run ${name}`);
223216
console.log(` next: bob up ${name} (load + start)`);
224217
if (bobBin === "bob") {
225218
console.error(
@@ -294,13 +287,6 @@ async function main(): Promise<number> {
294287
const prompt = args.positional.slice(1).join(" ") || undefined;
295288
return await run(args.positional[0], prompt, args.flags);
296289
}
297-
case "serve":
298-
if (!args.positional[0]) {
299-
console.error("bob serve: missing <name>");
300-
return 2;
301-
}
302-
await serve(args.positional[0], args.flags);
303-
return 0;
304290
case "install-service":
305291
if (!args.positional[0]) {
306292
console.error("bob install-service: missing <name>");

packages/cli/test/cli.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,17 @@ describe("bob CLI", () => {
5555

5656
it("help advertises persistent run + lifecycle commands", () => {
5757
const out = execSync(`node ${CLI} help`, { encoding: "utf8" });
58-
expect(out).toContain("serve <name>");
59-
expect(out).toContain("PERSISTENTLY");
58+
expect(out).toContain("run <name>");
59+
expect(out).toContain("PERSISTENTLY"); // run-with-no-prompt = persistent on-duty
60+
expect(out).not.toContain("serve <name>"); // serve is retired
6061
expect(out).toContain("install-service");
6162
expect(out).toContain("up <name>");
6263
expect(out).toContain("down <name>");
6364
expect(out).toContain("restart <name>");
6465
});
6566

6667
it("lifecycle commands require a <name>", () => {
67-
for (const cmd of ["up", "down", "restart", "install-service", "serve"]) {
68+
for (const cmd of ["up", "down", "restart", "install-service"]) {
6869
try {
6970
execSync(`node ${CLI} ${cmd} 2>&1`, { encoding: "utf8" });
7071
throw new Error(`expected non-zero exit for bare '${cmd}'`);

packages/shell/src/service.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Service install + lifecycle (`bob install-service` / `up` / `down` / `restart`).
22
//
33
// The agent owns its runtime (spec §3): each agent runs as its OWN OS service
4-
// unit hosting the persistent process (`bob serve <agent>` → runPersistent). Bob
4+
// unit hosting the persistent process (`bob run <agent>` → runPersistent). Bob
55
// installs the unit; Bob does NOT babysit the process — the init system restarts
66
// it on crash (launchd KeepAlive / systemd Restart=always) and starts it on
77
// login/boot.
@@ -55,7 +55,7 @@ export interface RenderServiceOptions {
5555
// Absolute path to the `bob` binary the unit runs. Both init systems use a
5656
// minimal PATH, so this MUST be absolute (the caller resolves it). Required.
5757
bobBin: string;
58-
// Optional model override passed through to `bob serve` (→ runPersistent).
58+
// Optional model override passed through to `bob run` (→ runPersistent).
5959
model?: string;
6060
// The agent's home dir for log paths + WorkingDirectory. Defaults to ~. The
6161
// agent's working dir is <home>/agents/<name>/work.
@@ -84,15 +84,15 @@ export function plistPath(name: string, home: string = homedir()): string {
8484
}
8585

8686
// Render the per-agent launchd plist. KeepAlive (restart on crash) + RunAtLoad
87-
// (start on login). ProgramArguments run `bob serve <name>`. NO secret.
87+
// (start on login). ProgramArguments run `bob run <name>`. NO secret.
8888
export function renderPlist(opts: RenderServiceOptions): string {
8989
assertName(opts.name);
9090
const home = opts.home ?? homedir();
9191
const label = serviceLabel(opts.name);
9292
const workDir = join(home, "agents", opts.name, "work");
9393
const logDir = join(home, "agents", opts.name);
9494

95-
const args = [opts.bobBin, "serve", opts.name];
95+
const args = [opts.bobBin, "run", opts.name];
9696
if (opts.model) args.push("--model", opts.model);
9797
const programArgs = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
9898

@@ -153,7 +153,7 @@ export function systemdUnitPath(name: string, home: string = homedir()): string
153153

154154
// Render the per-agent systemd USER unit. Restart=always (restart on crash);
155155
// WantedBy=default.target (start on login/boot when enabled). ExecStart runs
156-
// `bob serve <name>`. NO secret env, NO inline token — see the security note.
156+
// `bob run <name>`. NO secret env, NO inline token — see the security note.
157157
// `name` already passed the strict regex, so ExecStart has no whitespace/newline
158158
// injection surface; bobBin/model are trusted (resolved binary path + a flag).
159159
export function renderSystemdUnit(opts: RenderServiceOptions): string {
@@ -162,7 +162,7 @@ export function renderSystemdUnit(opts: RenderServiceOptions): string {
162162
const workDir = join(home, "agents", opts.name, "work");
163163
const logDir = join(home, "agents", opts.name);
164164

165-
const exec = [opts.bobBin, "serve", opts.name];
165+
const exec = [opts.bobBin, "run", opts.name];
166166
if (opts.model) exec.push("--model", opts.model);
167167

168168
return `# Generated by 'bob install-service ${opts.name}'. Don't edit — re-run to update.

packages/shell/test/service.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ function captureRunner(): { runner: LaunchctlRunner; calls: string[][] } {
2828
}
2929

3030
describe("renderPlist", () => {
31-
it("references the PERSISTENT entrypoint (bob serve <name>)", () => {
31+
it("references the PERSISTENT entrypoint (bob run <name>)", () => {
3232
const xml = renderPlist({ name: "pulse", bobBin: "/usr/local/bin/bob", home: HOME });
3333
expect(xml).toContain("<string>/usr/local/bin/bob</string>");
34-
expect(xml).toContain("<string>serve</string>");
34+
expect(xml).toContain("<string>run</string>");
3535
expect(xml).toContain("<string>pulse</string>");
3636
});
3737

@@ -159,7 +159,7 @@ describe("lifecycle (launchd) — up / down / restart invoke the right launchctl
159159
describe("systemd backend", () => {
160160
it("renderSystemdUnit runs the persistent entrypoint + Restart=always, no secret", () => {
161161
const unit = renderSystemdUnit({ name: "pulse", bobBin: "/usr/local/bin/bob", home: HOME });
162-
expect(unit).toContain("ExecStart=/usr/local/bin/bob serve pulse");
162+
expect(unit).toContain("ExecStart=/usr/local/bin/bob run pulse");
163163
expect(unit).toContain("Restart=always");
164164
expect(unit).toContain("WantedBy=default.target");
165165
expect(unit).toContain(`WorkingDirectory=${HOME}/agents/pulse/work`);
@@ -175,7 +175,7 @@ describe("systemd backend", () => {
175175
model: "claude-fast",
176176
home: HOME,
177177
});
178-
expect(unit).toContain("ExecStart=/usr/local/bin/bob serve pulse --model claude-fast");
178+
expect(unit).toContain("ExecStart=/usr/local/bin/bob run pulse --model claude-fast");
179179
expect(() => renderSystemdUnit({ name: "../evil", bobBin: "/bin/bob" })).toThrow(
180180
/invalid agent name/,
181181
);
@@ -202,7 +202,7 @@ describe("systemd backend", () => {
202202
});
203203
expect(res.path).toBe(`${HOME}/.config/systemd/user/bob-pulse.service`);
204204
expect(written[0].path).toBe(res.path);
205-
expect(written[0].contents).toContain("ExecStart=/usr/local/bin/bob serve pulse");
205+
expect(written[0].contents).toContain("ExecStart=/usr/local/bin/bob run pulse");
206206
expect(calls).toEqual([["--user", "daemon-reload"]]);
207207
});
208208

0 commit comments

Comments
 (0)