exponential-cli 1.19.0 → 1.20.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/program.ts
4
- import { Command as Command25 } from "commander";
4
+ import { Command as Command26 } from "commander";
5
5
 
6
6
  // src/commands/auth.ts
7
7
  import { Command } from "commander";
@@ -5950,8 +5950,571 @@ Re-run with --force to delete it anyway.`
5950
5950
  return projects;
5951
5951
  }
5952
5952
 
5953
- // src/commands/search.ts
5953
+ // src/commands/runner.ts
5954
5954
  import { Command as Command20 } from "commander";
5955
+ import { hostname, homedir as homedir2, tmpdir as tmpdir2 } from "os";
5956
+ import { execFileSync } from "child_process";
5957
+ import { mkdirSync as mkdirSync3 } from "fs";
5958
+ import { fileURLToPath } from "url";
5959
+ import { resolve } from "path";
5960
+
5961
+ // src/runner/config.ts
5962
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
5963
+ import { dirname, join } from "path";
5964
+ function runnerConfigPath() {
5965
+ return join(dirname(getConfigPath()), "runner.json");
5966
+ }
5967
+ function loadRunnerConfig(path = runnerConfigPath()) {
5968
+ if (!existsSync(path)) return {};
5969
+ try {
5970
+ return JSON.parse(readFileSync2(path, "utf-8"));
5971
+ } catch {
5972
+ throw new Error(`Could not parse ${path}; fix or delete it`);
5973
+ }
5974
+ }
5975
+ function saveRunnerConfig(values, path = runnerConfigPath()) {
5976
+ const next = { ...loadRunnerConfig(path), ...values };
5977
+ mkdirSync(dirname(path), { recursive: true });
5978
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n");
5979
+ return next;
5980
+ }
5981
+
5982
+ // src/runner/stream.ts
5983
+ var MAX_PAYLOAD_TEXT = 2e3;
5984
+ function clip(value, max = MAX_PAYLOAD_TEXT) {
5985
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
5986
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
5987
+ }
5988
+ var StreamParser = class {
5989
+ nextSeq;
5990
+ events = [];
5991
+ resultText = null;
5992
+ isError = false;
5993
+ errorMessage = null;
5994
+ usage = null;
5995
+ sessionId = null;
5996
+ /** The session called ask_owner: the run is parked by the app; the runner must not finish it. */
5997
+ askedOwner = false;
5998
+ /** The session called finish_run itself: the runner's final finish is a no-op. */
5999
+ finishedViaTool = false;
6000
+ constructor(firstSeq = 1) {
6001
+ this.nextSeq = firstSeq;
6002
+ }
6003
+ push(kind, payload) {
6004
+ const event = { seq: this.nextSeq++, kind, payload };
6005
+ this.events.push(event);
6006
+ return event;
6007
+ }
6008
+ /** Feed one line; returns the events it produced (possibly none). */
6009
+ feed(line) {
6010
+ const trimmed = line.trim();
6011
+ if (!trimmed) return [];
6012
+ let parsed;
6013
+ try {
6014
+ parsed = JSON.parse(trimmed);
6015
+ } catch {
6016
+ return [];
6017
+ }
6018
+ const before = this.events.length;
6019
+ switch (parsed.type) {
6020
+ case "system":
6021
+ if (parsed.subtype === "init" && typeof parsed.session_id === "string") this.sessionId = parsed.session_id;
6022
+ break;
6023
+ case "assistant": {
6024
+ const content = parsed.message?.content;
6025
+ if (typeof content === "string") {
6026
+ if (content.trim()) this.push("text", { text: clip(content) });
6027
+ } else if (Array.isArray(content)) {
6028
+ for (const block of content) {
6029
+ if (block.type === "tool_use") {
6030
+ const name = block.name ?? "tool";
6031
+ const input = block.input ?? {};
6032
+ if (name.endsWith("__report_progress") && typeof input.text === "string") {
6033
+ this.push("text", { text: clip(input.text, 500) });
6034
+ } else if (name.endsWith("__ask_owner")) {
6035
+ this.askedOwner = true;
6036
+ this.push("tool_call", { tool: "ask-owner", snippet: clip(input.question, 200) });
6037
+ } else if (name.endsWith("__finish_run")) {
6038
+ this.finishedViaTool = true;
6039
+ this.push("tool_call", { tool: "finish-run", readyToClose: input.readyToClose === true });
6040
+ } else {
6041
+ this.push("tool_call", { tool: name, input: clip(block.input, 500) });
6042
+ }
6043
+ } else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
6044
+ this.push("text", { text: clip(block.text) });
6045
+ }
6046
+ }
6047
+ }
6048
+ break;
6049
+ }
6050
+ case "user": {
6051
+ const content = parsed.message?.content;
6052
+ if (Array.isArray(content)) {
6053
+ for (const block of content) {
6054
+ if (block.type === "tool_result") {
6055
+ this.push(block.is_error ? "error" : "tool_result", {
6056
+ toolUseId: block.tool_use_id ?? null,
6057
+ output: clip(block.content, 500)
6058
+ });
6059
+ }
6060
+ }
6061
+ }
6062
+ break;
6063
+ }
6064
+ case "result":
6065
+ this.resultText = typeof parsed.result === "string" ? parsed.result : null;
6066
+ this.isError = parsed.is_error === true || (parsed.subtype ?? "").startsWith("error");
6067
+ this.errorMessage = this.isError ? parsed.error ?? parsed.result ?? parsed.subtype ?? "error" : null;
6068
+ this.usage = parsed.usage ?? null;
6069
+ if (typeof parsed.total_cost_usd === "number") {
6070
+ this.usage = { ...this.usage ?? {}, totalCostUsd: parsed.total_cost_usd };
6071
+ }
6072
+ break;
6073
+ default:
6074
+ break;
6075
+ }
6076
+ return this.events.slice(before);
6077
+ }
6078
+ /** Events not yet handed out by a previous `drain`. */
6079
+ drained = 0;
6080
+ drain() {
6081
+ const batch = this.events.slice(this.drained);
6082
+ this.drained = this.events.length;
6083
+ return batch;
6084
+ }
6085
+ snapshot() {
6086
+ return {
6087
+ events: [...this.events],
6088
+ resultText: this.resultText,
6089
+ isError: this.isError,
6090
+ errorMessage: this.errorMessage,
6091
+ usage: this.usage,
6092
+ sessionId: this.sessionId,
6093
+ askedOwner: this.askedOwner,
6094
+ finishedViaTool: this.finishedViaTool
6095
+ };
6096
+ }
6097
+ };
6098
+
6099
+ // src/runner/mcp.ts
6100
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
6101
+ import { tmpdir } from "os";
6102
+ import { join as join2 } from "path";
6103
+ var MCP_SERVER_NAME = "exponential";
6104
+ var RUN_TOOL_NAMES = ["report_progress", "ask_owner", "finish_run"];
6105
+ function buildMcpConfig(input) {
6106
+ const [command, ...args] = input.command ?? ["npx", "-y", "exponential-mcp"];
6107
+ return {
6108
+ mcpServers: {
6109
+ [MCP_SERVER_NAME]: {
6110
+ command,
6111
+ args,
6112
+ env: {
6113
+ EXPONENTIAL_API_KEY: input.token,
6114
+ EXPONENTIAL_API_URL: input.apiUrl,
6115
+ EXPONENTIAL_RUN_ID: input.runId,
6116
+ EXPONENTIAL_RUNNER_ID: input.runnerId
6117
+ }
6118
+ }
6119
+ }
6120
+ };
6121
+ }
6122
+ function allowedRunTools(extra = []) {
6123
+ return [...RUN_TOOL_NAMES.map((t) => `mcp__${MCP_SERVER_NAME}__${t}`), ...extra];
6124
+ }
6125
+ function writeMcpSession(input, extraAllowed = []) {
6126
+ const dir = mkdtempSync(join2(tmpdir(), "exponential-runner-"));
6127
+ const file = join2(dir, "mcp.json");
6128
+ writeFileSync2(file, JSON.stringify(buildMcpConfig(input), null, 2), { mode: 384 });
6129
+ return {
6130
+ args: ["--mcp-config", file, "--allowedTools", allowedRunTools(extraAllowed).join(",")],
6131
+ cleanup: () => rmSync(dir, { recursive: true, force: true })
6132
+ };
6133
+ }
6134
+
6135
+ // src/runner/retry.ts
6136
+ var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
6137
+ function isRetryableError(error) {
6138
+ const code = error?.data?.code;
6139
+ if (!code) return true;
6140
+ return !["FORBIDDEN", "UNAUTHORIZED", "NOT_FOUND", "PRECONDITION_FAILED", "BAD_REQUEST"].includes(code);
6141
+ }
6142
+ async function withRetry(fn, options = {}) {
6143
+ const attempts = options.attempts ?? 5;
6144
+ const baseMs = options.baseMs ?? 1e3;
6145
+ const maxMs = options.maxMs ?? 3e4;
6146
+ const retryable = options.retryable ?? isRetryableError;
6147
+ const sleep = options.sleep ?? defaultSleep;
6148
+ const jitter = options.jitter ?? (() => 0.5 + Math.random());
6149
+ let lastError;
6150
+ for (let attempt = 1; attempt <= attempts; attempt++) {
6151
+ try {
6152
+ return await fn();
6153
+ } catch (error) {
6154
+ lastError = error;
6155
+ if (attempt === attempts || !retryable(error)) throw error;
6156
+ const delay = Math.min(maxMs, baseMs * 2 ** (attempt - 1)) * jitter();
6157
+ options.log?.(`retry ${attempt}/${attempts - 1} in ${Math.round(delay)}ms: ${error instanceof Error ? error.message : String(error)}`);
6158
+ await sleep(delay);
6159
+ }
6160
+ }
6161
+ throw lastError;
6162
+ }
6163
+ var Backoff = class {
6164
+ constructor(baseMs, maxMs = 5 * 6e4) {
6165
+ this.baseMs = baseMs;
6166
+ this.maxMs = maxMs;
6167
+ }
6168
+ failures = 0;
6169
+ /** Current wait, before recording an outcome. */
6170
+ get delayMs() {
6171
+ return Math.min(this.maxMs, this.baseMs * 2 ** this.failures);
6172
+ }
6173
+ success() {
6174
+ this.failures = 0;
6175
+ }
6176
+ failure() {
6177
+ this.failures = Math.min(this.failures + 1, 16);
6178
+ }
6179
+ };
6180
+
6181
+ // src/runner/loop.ts
6182
+ var DEFAULT_HEARTBEAT_MS = 6e4;
6183
+ var DEFAULT_FLUSH_MS = 5e3;
6184
+ var MAX_BATCH = 200;
6185
+ function promptsFor(run) {
6186
+ const system = run.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
6187
+ const user = run.messages.filter((m) => m.role === "user").map((m) => m.content).join("\n\n");
6188
+ return { systemPrompt: system, prompt: user || `You have been assigned the action "${run.action.name}".` };
6189
+ }
6190
+ async function runOnce(options) {
6191
+ const { client, runnerId, cwd, spawn: spawn2 } = options;
6192
+ const log = options.log ?? (() => void 0);
6193
+ const run = await client.agentRuns.claim(runnerId);
6194
+ if (!run) return null;
6195
+ log(`claimed run ${run.id} for action "${run.action.name}"`);
6196
+ const parser = new StreamParser(1);
6197
+ let eventsSent = 0;
6198
+ let cancelled = false;
6199
+ const retry = { log, sleep: options.sleep, attempts: options.retryAttempts };
6200
+ let pending = [];
6201
+ const flush = async () => {
6202
+ pending.push(...parser.drain());
6203
+ while (pending.length > 0) {
6204
+ const slice = pending.slice(0, MAX_BATCH);
6205
+ await withRetry(() => client.agentRuns.appendEvents(run.id, slice, runnerId), retry);
6206
+ pending = pending.slice(slice.length);
6207
+ eventsSent += slice.length;
6208
+ }
6209
+ };
6210
+ const { systemPrompt, prompt } = promptsFor(run);
6211
+ let mcp = null;
6212
+ if (options.mcp) {
6213
+ mcp = writeMcpSession(
6214
+ { token: options.mcp.token, apiUrl: options.mcp.apiUrl, runId: run.id, runnerId, command: options.mcp.command },
6215
+ options.mcp.allowedTools
6216
+ );
6217
+ }
6218
+ const session = spawn2({ prompt, systemPrompt, cwd, extraArgs: [...options.extraArgs ?? [], ...mcp?.args ?? []] });
6219
+ const heartbeat = setInterval(() => {
6220
+ void client.agentRuns.heartbeat(run.id, runnerId).then((h) => {
6221
+ if (!h.ok) {
6222
+ cancelled = true;
6223
+ log(`run ${run.id} left RUNNING (cancelled) \u2014 stopping the session`);
6224
+ session.kill();
6225
+ }
6226
+ }).catch((err) => log(`heartbeat failed: ${err instanceof Error ? err.message : String(err)}`));
6227
+ }, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
6228
+ const flusher = setInterval(() => {
6229
+ void flush().catch((err) => log(`append failed: ${err instanceof Error ? err.message : String(err)}`));
6230
+ }, options.flushMs ?? DEFAULT_FLUSH_MS);
6231
+ let exitCode = 1;
6232
+ let spawnError = null;
6233
+ try {
6234
+ for await (const line of session.lines) {
6235
+ parser.feed(line);
6236
+ }
6237
+ exitCode = await session.exit;
6238
+ } catch (err) {
6239
+ spawnError = err instanceof Error ? err.message : String(err);
6240
+ } finally {
6241
+ clearInterval(heartbeat);
6242
+ clearInterval(flusher);
6243
+ mcp?.cleanup();
6244
+ }
6245
+ await flush().catch((err) => log(`final append failed: ${err instanceof Error ? err.message : String(err)}`));
6246
+ if (cancelled) return { runId: run.id, status: "FAILED", eventsSent };
6247
+ const snap = parser.snapshot();
6248
+ if (snap.askedOwner) {
6249
+ log(`run ${run.id} is waiting on the owner (${eventsSent} events)`);
6250
+ return { runId: run.id, status: "WAITING_ON_OWNER", eventsSent };
6251
+ }
6252
+ if (snap.finishedViaTool) {
6253
+ log(`run ${run.id} finished by the session (${eventsSent} events)`);
6254
+ return { runId: run.id, status: "SUCCEEDED", eventsSent };
6255
+ }
6256
+ let outcome;
6257
+ if (spawnError) {
6258
+ outcome = { status: "FAILED", error: spawnError };
6259
+ } else if (snap.isError || exitCode !== 0) {
6260
+ outcome = {
6261
+ status: "FAILED",
6262
+ error: snap.errorMessage ?? `claude exited with code ${exitCode}`,
6263
+ summary: snap.resultText ?? void 0,
6264
+ usage: snap.usage ?? void 0
6265
+ };
6266
+ } else {
6267
+ outcome = {
6268
+ status: "SUCCEEDED",
6269
+ summary: snap.resultText ?? void 0,
6270
+ usage: snap.usage ?? void 0
6271
+ };
6272
+ }
6273
+ await withRetry(() => client.agentRuns.finish(run.id, outcome, runnerId), retry);
6274
+ log(`run ${run.id} ${outcome.status.toLowerCase()} (${eventsSent} events)`);
6275
+ return { runId: run.id, status: outcome.status, eventsSent };
6276
+ }
6277
+ async function runForever(options) {
6278
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
6279
+ const log = options.log ?? (() => void 0);
6280
+ const backoff = new Backoff(options.intervalMs);
6281
+ while (!options.shouldStop()) {
6282
+ let outcome = null;
6283
+ let failed = false;
6284
+ try {
6285
+ outcome = await runOnce(options);
6286
+ backoff.success();
6287
+ } catch (err) {
6288
+ failed = true;
6289
+ backoff.failure();
6290
+ log(`runner error: ${err instanceof Error ? err.message : String(err)} \u2014 next poll in ${Math.round(backoff.delayMs / 1e3)}s`);
6291
+ }
6292
+ if ((!outcome || failed) && !options.shouldStop()) await sleep(backoff.delayMs);
6293
+ }
6294
+ }
6295
+
6296
+ // src/runner/spawn.ts
6297
+ import { spawn } from "child_process";
6298
+ import { createInterface } from "readline";
6299
+ var spawnClaude = ({ prompt, systemPrompt, cwd, extraArgs = [] }) => {
6300
+ const child = spawn(
6301
+ "claude",
6302
+ ["-p", prompt, "--output-format", "stream-json", "--verbose", "--append-system-prompt", systemPrompt, ...extraArgs],
6303
+ { cwd, stdio: ["ignore", "pipe", "pipe"], env: process.env }
6304
+ );
6305
+ const stderr = [];
6306
+ child.stderr?.on("data", (chunk) => stderr.push(chunk.toString()));
6307
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
6308
+ const exit = new Promise((resolve2, reject) => {
6309
+ child.on("error", (err) => reject(new Error(`could not start claude: ${err.message}`)));
6310
+ child.on("close", (code) => {
6311
+ if (code !== 0 && stderr.length) {
6312
+ process.stderr.write(stderr.join("").slice(-2e3));
6313
+ }
6314
+ resolve2(code ?? 1);
6315
+ });
6316
+ });
6317
+ return { lines, exit, kill: () => child.kill("SIGTERM") };
6318
+ };
6319
+
6320
+ // src/runner/install.ts
6321
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, existsSync as existsSync2, rmSync as rmSync2 } from "fs";
6322
+ import { homedir, platform } from "os";
6323
+ import { join as join3 } from "path";
6324
+ var SERVICE_LABEL = "im.exponential.runner";
6325
+ function xmlEscape(value) {
6326
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6327
+ }
6328
+ function launchdPlist(input) {
6329
+ const args = [input.nodeBin, input.exponentialBin, "runner", "start", "--runner-id", input.runnerId, "--cwd", input.cwd];
6330
+ return `<?xml version="1.0" encoding="UTF-8"?>
6331
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6332
+ <plist version="1.0">
6333
+ <dict>
6334
+ <key>Label</key><string>${SERVICE_LABEL}</string>
6335
+ <key>ProgramArguments</key>
6336
+ <array>
6337
+ ${args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
6338
+ </array>
6339
+ <key>EnvironmentVariables</key>
6340
+ <dict>
6341
+ <key>PATH</key><string>${xmlEscape(input.path)}</string>
6342
+ <key>HOME</key><string>${xmlEscape(input.home)}</string>
6343
+ </dict>
6344
+ <key>WorkingDirectory</key><string>${xmlEscape(input.cwd)}</string>
6345
+ <key>RunAtLoad</key><true/>
6346
+ <key>KeepAlive</key><true/>
6347
+ <key>StandardOutPath</key><string>${xmlEscape(join3(input.logDir, "runner.log"))}</string>
6348
+ <key>StandardErrorPath</key><string>${xmlEscape(join3(input.logDir, "runner.log"))}</string>
6349
+ </dict>
6350
+ </plist>
6351
+ `;
6352
+ }
6353
+ function systemdUnit(input) {
6354
+ const q = (s) => `"${s.replace(/"/g, '\\"')}"`;
6355
+ return `[Unit]
6356
+ Description=Exponential Assistant runner
6357
+ After=network-online.target
6358
+
6359
+ [Service]
6360
+ ExecStart=${q(input.nodeBin)} ${q(input.exponentialBin)} runner start --runner-id ${q(input.runnerId)} --cwd ${q(input.cwd)}
6361
+ WorkingDirectory=${input.cwd}
6362
+ Environment=PATH=${input.path}
6363
+ Environment=HOME=${input.home}
6364
+ Restart=always
6365
+ RestartSec=10
6366
+
6367
+ [Install]
6368
+ WantedBy=default.target
6369
+ `;
6370
+ }
6371
+ function planService(input, os = platform(), home = homedir()) {
6372
+ if (os === "darwin") {
6373
+ const file = join3(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
6374
+ return {
6375
+ kind: "launchd",
6376
+ file,
6377
+ contents: launchdPlist(input),
6378
+ load: ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? 501}`, file],
6379
+ unload: ["launchctl", "bootout", `gui/${process.getuid?.() ?? 501}/${SERVICE_LABEL}`]
6380
+ };
6381
+ }
6382
+ if (os === "linux") {
6383
+ const file = join3(home, ".config", "systemd", "user", "exponential-runner.service");
6384
+ return {
6385
+ kind: "systemd",
6386
+ file,
6387
+ contents: systemdUnit(input),
6388
+ load: ["systemctl", "--user", "enable", "--now", "exponential-runner.service"],
6389
+ unload: ["systemctl", "--user", "disable", "--now", "exponential-runner.service"]
6390
+ };
6391
+ }
6392
+ throw new Error(`runner install is not supported on ${os}; run "exponential runner start" in a terminal instead`);
6393
+ }
6394
+ function writeServiceFile(plan) {
6395
+ mkdirSync2(join3(plan.file, ".."), { recursive: true });
6396
+ writeFileSync3(plan.file, plan.contents, { mode: 420 });
6397
+ }
6398
+ function removeServiceFile(plan) {
6399
+ if (!existsSync2(plan.file)) return false;
6400
+ rmSync2(plan.file);
6401
+ return true;
6402
+ }
6403
+
6404
+ // src/commands/runner.ts
6405
+ function createRunnerCommand() {
6406
+ const cmd = new Command20("runner").description(
6407
+ "Run your Assistant locally: claim its queued runs and work them with Claude Code on this machine"
6408
+ );
6409
+ cmd.command("start").description("Poll for queued runs and work them until stopped (Ctrl-C)").option("--runner-id <id>", "Name of this runner, recorded on each run", hostname()).option("--cwd <dir>", "Working directory for the spawned CLI (overrides runner config; never from the server)").option("--interval <seconds>", "Seconds between polls when nothing is queued", "15").option("--once", "Claim at most one run, work it, and exit").option("--no-mcp", "Spawn without the Exponential MCP run tools (progress still streams; the session cannot ask the owner)").action(async (opts) => {
6410
+ try {
6411
+ const client = getClient();
6412
+ const settings = loadRunnerConfig();
6413
+ const cwd = resolve(opts.cwd ?? settings.cwd ?? process.cwd());
6414
+ const auth = getConfig();
6415
+ const log = (line) => console.error(`[runner ${opts.runnerId}] ${line}`);
6416
+ const base = {
6417
+ client,
6418
+ runnerId: opts.runnerId,
6419
+ cwd,
6420
+ spawn: spawnClaude,
6421
+ log,
6422
+ mcp: opts.mcp ? { token: auth.token, apiUrl: auth.apiUrl, command: settings.mcpCommand, allowedTools: settings.allowedTools } : void 0
6423
+ };
6424
+ if (opts.once) {
6425
+ const outcome = await runOnce(base);
6426
+ console.log(JSON.stringify(outcome ?? { runId: null, status: "IDLE", eventsSent: 0 }));
6427
+ return;
6428
+ }
6429
+ let stop = false;
6430
+ const onSignal = () => {
6431
+ stop = true;
6432
+ log("stopping after the current run");
6433
+ };
6434
+ process.on("SIGINT", onSignal);
6435
+ process.on("SIGTERM", onSignal);
6436
+ log(`polling every ${opts.interval}s in ${cwd}`);
6437
+ await runForever({ ...base, intervalMs: Number(opts.interval) * 1e3, shouldStop: () => stop });
6438
+ } catch (error) {
6439
+ handleError(error);
6440
+ }
6441
+ });
6442
+ cmd.command("install").description("Keep the runner running as a user service (launchd on macOS, systemd --user on Linux)").option("--runner-id <id>", "Name of this runner", hostname()).option("--cwd <dir>", "Working directory for the spawned CLI (defaults to runner config, then the current directory)").option("--dry-run", "Print the service file and commands without installing").action((opts) => {
6443
+ try {
6444
+ getClient();
6445
+ const settings = loadRunnerConfig();
6446
+ const cwd = resolve(opts.cwd ?? settings.cwd ?? process.cwd());
6447
+ const logDir = resolve(homedir2(), "Library", "Logs", "exponential-runner");
6448
+ const plan = planService({
6449
+ exponentialBin: resolve(fileURLToPath(import.meta.url), "..", "..", "bin", "exponential.js"),
6450
+ nodeBin: process.execPath,
6451
+ runnerId: opts.runnerId,
6452
+ cwd,
6453
+ logDir: process.platform === "darwin" ? logDir : resolve(tmpdir2(), "exponential-runner"),
6454
+ path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
6455
+ home: homedir2()
6456
+ });
6457
+ if (opts.dryRun) {
6458
+ console.log(`# ${plan.file}
6459
+ ${plan.contents}
6460
+ # load: ${plan.load.join(" ")}
6461
+ # unload: ${plan.unload.join(" ")}`);
6462
+ return;
6463
+ }
6464
+ if (plan.kind === "launchd") mkdirSync3(logDir, { recursive: true });
6465
+ saveRunnerConfig({ cwd });
6466
+ writeServiceFile(plan);
6467
+ try {
6468
+ execFileSync(plan.unload[0], plan.unload.slice(1), { stdio: "ignore" });
6469
+ } catch {
6470
+ }
6471
+ execFileSync(plan.load[0], plan.load.slice(1), { stdio: "inherit" });
6472
+ console.error(`Installed ${plan.kind} service ${plan.file}; the runner works in ${cwd}.`);
6473
+ } catch (error) {
6474
+ handleError(error);
6475
+ }
6476
+ });
6477
+ cmd.command("uninstall").description("Stop and remove the runner user service").action(() => {
6478
+ try {
6479
+ const plan = planService({
6480
+ exponentialBin: "",
6481
+ nodeBin: "",
6482
+ runnerId: "",
6483
+ cwd: "",
6484
+ logDir: "",
6485
+ path: "",
6486
+ home: homedir2()
6487
+ });
6488
+ try {
6489
+ execFileSync(plan.unload[0], plan.unload.slice(1), { stdio: "ignore" });
6490
+ } catch {
6491
+ }
6492
+ console.error(removeServiceFile(plan) ? `Removed ${plan.file}` : `No service file at ${plan.file}`);
6493
+ } catch (error) {
6494
+ handleError(error);
6495
+ }
6496
+ });
6497
+ const config = cmd.command("config").description(`Runner settings on this machine (${runnerConfigPath()})`);
6498
+ config.command("show").description("Print the runner settings").action(() => {
6499
+ console.log(JSON.stringify({ path: runnerConfigPath(), ...loadRunnerConfig() }, null, 2));
6500
+ });
6501
+ config.command("set").description("Set a runner setting: cwd <dir> | allowed-tools <a,b,c> | mcp-command <cmd ...>").argument("<key>", "cwd | allowed-tools | mcp-command").argument("<value...>", "The value").action((key, value) => {
6502
+ try {
6503
+ let next;
6504
+ if (key === "cwd") next = saveRunnerConfig({ cwd: resolve(value.join(" ")) });
6505
+ else if (key === "allowed-tools") next = saveRunnerConfig({ allowedTools: value.join(" ").split(",").map((t) => t.trim()).filter(Boolean) });
6506
+ else if (key === "mcp-command") next = saveRunnerConfig({ mcpCommand: value });
6507
+ else throw new Error(`Unknown setting "${key}". Use cwd, allowed-tools or mcp-command.`);
6508
+ console.log(JSON.stringify(next, null, 2));
6509
+ } catch (error) {
6510
+ handleError(error);
6511
+ }
6512
+ });
6513
+ return cmd;
6514
+ }
6515
+
6516
+ // src/commands/search.ts
6517
+ import { Command as Command21 } from "commander";
5955
6518
  import chalk8 from "chalk";
5956
6519
  var TYPE_LABELS = {
5957
6520
  workspace: "Workspaces",
@@ -5970,7 +6533,7 @@ var TYPE_LABELS = {
5970
6533
  product: "Products"
5971
6534
  };
5972
6535
  function createSearchCommand() {
5973
- const search = new Command20("search").description(
6536
+ const search = new Command21("search").description(
5974
6537
  "Global search across projects, actions, goals, workspaces and more \u2014 same coverage as the app's Cmd+K palette"
5975
6538
  ).argument("<query>", "Text to search for").option("-w, --workspace <slug>", "Restrict results to one workspace (slug or ID)").option("-l, --limit <n>", "Max results per entity type (1-25, default 10)").action(async (query, options, cmd) => {
5976
6539
  const globalOpts = cmd.optsWithGlobals();
@@ -6029,7 +6592,7 @@ ${TYPE_LABELS[type] ?? type}`));
6029
6592
  }
6030
6593
 
6031
6594
  // src/commands/tickets.ts
6032
- import { Command as Command21 } from "commander";
6595
+ import { Command as Command22 } from "commander";
6033
6596
  function collectRepeatable(value, previous) {
6034
6597
  return [...previous, value];
6035
6598
  }
@@ -6072,7 +6635,7 @@ function validateTicketType(value) {
6072
6635
  return value;
6073
6636
  }
6074
6637
  function createTicketsCommand() {
6075
- const tickets = new Command21("tickets").description(
6638
+ const tickets = new Command22("tickets").description(
6076
6639
  "Manage product backlog tickets. Tickets live under a product, link to features/epics, depend on each other, and can be implemented via actions."
6077
6640
  );
6078
6641
  tickets.command("list").description(
@@ -6441,7 +7004,7 @@ function createTicketsCommand() {
6441
7004
  handleError(error, useJson);
6442
7005
  }
6443
7006
  });
6444
- const comment = new Command21("comment").description("Manage comments on a ticket");
7007
+ const comment = new Command22("comment").description("Manage comments on a ticket");
6445
7008
  comment.command("list").description("List comments on a ticket").requiredOption("--id <id>", "Ticket CUID").action(async (options, cmd) => {
6446
7009
  const globalOpts = cmd.optsWithGlobals();
6447
7010
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
@@ -6533,7 +7096,7 @@ function createTicketsCommand() {
6533
7096
  }
6534
7097
 
6535
7098
  // src/commands/time.ts
6536
- import { Command as Command22 } from "commander";
7099
+ import { Command as Command23 } from "commander";
6537
7100
  import { segmentConversation } from "exponential-sdk";
6538
7101
  var SOURCES2 = ["manual", "claude-desktop", "agent-run"];
6539
7102
  var STATUSES2 = ["PROPOSED", "CONFIRMED"];
@@ -6623,7 +7186,7 @@ function batchItemToInput(item, index, defaults) {
6623
7186
  };
6624
7187
  }
6625
7188
  function createTimeCommand() {
6626
- const time = new Command22("time").description(
7189
+ const time = new Command23("time").description(
6627
7190
  `Time entries \u2014 log the day's work and list it. ${OWNERSHIP_NOTE}`
6628
7191
  );
6629
7192
  time.command("log").description(
@@ -6793,10 +7356,10 @@ function parseMessagesFile(raw) {
6793
7356
  }
6794
7357
 
6795
7358
  // src/commands/workspaces.ts
6796
- import { Command as Command23 } from "commander";
7359
+ import { Command as Command24 } from "commander";
6797
7360
  import chalk9 from "chalk";
6798
7361
  function createWorkspacesCommand() {
6799
- const workspaces = new Command23("workspaces").description("Manage workspaces");
7362
+ const workspaces = new Command24("workspaces").description("Manage workspaces");
6800
7363
  workspaces.command("list").description("List all workspaces").action(async (_options, cmd) => {
6801
7364
  const globalOpts = cmd.optsWithGlobals();
6802
7365
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
@@ -6886,7 +7449,7 @@ Members (${members.length})
6886
7449
  }
6887
7450
 
6888
7451
  // src/commands/goals.ts
6889
- import { Command as Command24 } from "commander";
7452
+ import { Command as Command25 } from "commander";
6890
7453
  var GOAL_STATUSES = [
6891
7454
  "planned",
6892
7455
  "active",
@@ -6998,7 +7561,7 @@ function validateKeyResultUnit(value) {
6998
7561
  return value;
6999
7562
  }
7000
7563
  function createKeyResultsCommand() {
7001
- const kr = new Command24("kr").description(
7564
+ const kr = new Command25("kr").description(
7002
7565
  "Key results on an objective. Ids are CUIDs; --goal takes the objective's integer id."
7003
7566
  );
7004
7567
  kr.command("list").description("List key results").option("--goal <id>", "Objective ID (a number)").option("-w, --workspace <slug|id>", "Workspace (workspace-wide: every member's KRs)").option("--period <period>", "Period, e.g. Q3-2026").option("--status <status>", `Filter by status: ${KEY_RESULT_STATUSES.join(", ")}`).option("--mine", "Narrow a workspace list to key results you own").action(
@@ -7201,7 +7764,7 @@ function createKeyResultsCommand() {
7201
7764
  return kr;
7202
7765
  }
7203
7766
  function createGoalCommentsCommand() {
7204
- const comment = new Command24("comment").description(
7767
+ const comment = new Command25("comment").description(
7205
7768
  "Read and post comments on a goal. Mention teammates with --mention."
7206
7769
  );
7207
7770
  comment.command("list").description("List comments on a goal").requiredOption("--goal <id>", "Goal ID (a number)").action(async (options, cmd) => {
@@ -7296,7 +7859,7 @@ function createGoalCommentsCommand() {
7296
7859
  return comment;
7297
7860
  }
7298
7861
  function createGoalsCommand() {
7299
- const goals = new Command24("goals").description(
7862
+ const goals = new Command25("goals").description(
7300
7863
  "Manage goals (objectives) and their key results. Objectives have integer ids; key results are CUIDs."
7301
7864
  );
7302
7865
  goals.command("list").description(
@@ -7583,7 +8146,7 @@ Re-run with --with-key-results to go ahead, or move them with \`goals kr update
7583
8146
  return goals;
7584
8147
  }
7585
8148
  function createOkrsCommand() {
7586
- const okrs = new Command24("okrs").description(
8149
+ const okrs = new Command25("okrs").description(
7587
8150
  "OKRs \u2014 objectives with their key results nested. Manage the parts with `goals` and `goals kr`."
7588
8151
  );
7589
8152
  okrs.command("list").description(
@@ -7661,18 +8224,18 @@ function createOkrsCommand() {
7661
8224
  }
7662
8225
 
7663
8226
  // src/program.ts
7664
- import { readFileSync as readFileSync2 } from "fs";
7665
- import { dirname, join } from "path";
7666
- import { fileURLToPath } from "url";
8227
+ import { readFileSync as readFileSync3 } from "fs";
8228
+ import { dirname as dirname2, join as join4 } from "path";
8229
+ import { fileURLToPath as fileURLToPath2 } from "url";
7667
8230
  var PKG_VERSION = JSON.parse(
7668
- readFileSync2(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")
8231
+ readFileSync3(join4(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json"), "utf-8")
7669
8232
  ).version;
7670
8233
  var CLI_VERSION_FLAGS = "-V, --cli-version";
7671
8234
  function isBareVersionRequest(args) {
7672
8235
  return args.includes("--version") && args.every((arg) => arg.startsWith("-"));
7673
8236
  }
7674
8237
  function buildProgram() {
7675
- const program = new Command25();
8238
+ const program = new Command26();
7676
8239
  program.name("exponential").description(
7677
8240
  [
7678
8241
  "CLI to interact with Exponential productivity app.",
@@ -7695,6 +8258,7 @@ function buildProgram() {
7695
8258
  program.addCommand(createPagesCommand());
7696
8259
  program.addCommand(createProductsCommand());
7697
8260
  program.addCommand(createProjectsCommand());
8261
+ program.addCommand(createRunnerCommand());
7698
8262
  program.addCommand(createSearchCommand());
7699
8263
  program.addCommand(createTicketsCommand());
7700
8264
  program.addCommand(createTimeCommand());