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/README.md +23 -0
- package/dist/index.js +584 -20
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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/
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7359
|
+
import { Command as Command24 } from "commander";
|
|
6797
7360
|
import chalk9 from "chalk";
|
|
6798
7361
|
function createWorkspacesCommand() {
|
|
6799
|
-
const workspaces = new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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());
|