@lumi.ai/runner 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/dist/cli.js +202 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ workspace.
|
|
|
5
5
|
|
|
6
6
|
Crew agents don't run in the cloud. They run **on your machine**, in your checkouts, with your
|
|
7
7
|
tools. This daemon is what makes that happen: it watches the job queues of the Ships you assign it
|
|
8
|
-
to, claims work
|
|
8
|
+
to, claims work oldest-first, and executes each job as a headless Claude session.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npm i -g @lumi.ai/runner
|
|
@@ -98,6 +98,24 @@ reachability, the background service, and whether that service still points at a
|
|
|
98
98
|
Your `PATH` is captured into the unit at install time. launchd and systemd start processes with a
|
|
99
99
|
minimal environment, so without that `claude`, `git` and `gh` would not be found.
|
|
100
100
|
|
|
101
|
+
## How many jobs at once
|
|
102
|
+
|
|
103
|
+
One, until you say otherwise — the same behaviour this daemon has always had.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
lumi-runner config set parallel 3 # this machine, across every Ship
|
|
107
|
+
lumi-runner config set parallel 1 --ship shp_abc123 # cap one Ship's share of it
|
|
108
|
+
lumi-runner config set parallel default --ship shp_abc123 # back to the machine number
|
|
109
|
+
lumi-runner config list # what is set, and what each Ship gets
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The machine number is a ceiling, not a total: three Ships at `2` each on a machine set to `3` will
|
|
113
|
+
never run more than three sessions. A Ship with no number of its own may use the whole machine.
|
|
114
|
+
|
|
115
|
+
Each job is a separate `claude` process with its own empty working directory, so they don't share
|
|
116
|
+
state — but they do share your CPU, your RAM and your Claude usage window. Start at 2 and watch a
|
|
117
|
+
real job before going higher. Restart the daemon (`lumi-runner service restart`) to pick up a change.
|
|
118
|
+
|
|
101
119
|
## While a job runs
|
|
102
120
|
|
|
103
121
|
- **Idle sleep is inhibited** (`caffeinate` / `systemd-inhibit`, best-effort on Windows), so a
|
|
@@ -105,9 +123,9 @@ minimal environment, so without that `claude`, `git` and `gh` would not be found
|
|
|
105
123
|
gets that veto on macOS, and it shouldn't.
|
|
106
124
|
- **A desktop notification** fires on job start, finish and terminal failure, and when the daemon is
|
|
107
125
|
stopped with work in flight. Silence it with `lumi-runner config set notifications off`.
|
|
108
|
-
- **SIGTERM releases the job.** The daemon aborts
|
|
109
|
-
its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
110
|
-
you an attempt.
|
|
126
|
+
- **SIGTERM releases the job.** The daemon aborts every running session, hands each job back to the
|
|
127
|
+
queue with its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
128
|
+
never costs you an attempt.
|
|
111
129
|
|
|
112
130
|
## Credentials
|
|
113
131
|
|
package/dist/cli.js
CHANGED
|
@@ -535,14 +535,24 @@ function saveConfig(config2) {
|
|
|
535
535
|
function forgetShip(config2, shipId) {
|
|
536
536
|
const shipKeys = { ...config2.shipKeys ?? {} };
|
|
537
537
|
delete shipKeys[shipId];
|
|
538
|
-
|
|
538
|
+
const next = {
|
|
539
|
+
...config2,
|
|
540
|
+
shipKeys,
|
|
541
|
+
ships: config2.ships.filter((id) => id !== shipId)
|
|
542
|
+
};
|
|
543
|
+
if (next.shipParallelJobs) {
|
|
544
|
+
const overrides = { ...next.shipParallelJobs };
|
|
545
|
+
delete overrides[shipId];
|
|
546
|
+
next.shipParallelJobs = overrides;
|
|
547
|
+
}
|
|
548
|
+
return next;
|
|
539
549
|
}
|
|
540
550
|
function mcpUrl(config2) {
|
|
541
551
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
542
552
|
}
|
|
543
553
|
|
|
544
554
|
// src/version.ts
|
|
545
|
-
var RUNNER_VERSION = true ? "0.
|
|
555
|
+
var RUNNER_VERSION = true ? "0.5.1" : "0.0.0-dev";
|
|
546
556
|
|
|
547
557
|
// src/auth.ts
|
|
548
558
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1658,6 +1668,35 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
|
|
|
1658
1668
|
);
|
|
1659
1669
|
}
|
|
1660
1670
|
|
|
1671
|
+
// src/jobs/capacity.ts
|
|
1672
|
+
var DEFAULT_PARALLEL_JOBS = 1;
|
|
1673
|
+
var PARALLEL_MAX = 8;
|
|
1674
|
+
var clamp = (n) => Math.min(PARALLEL_MAX, Math.max(1, Math.floor(n)));
|
|
1675
|
+
var readCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1 ? clamp(value) : null;
|
|
1676
|
+
function machineCap(config2) {
|
|
1677
|
+
return readCount(config2.maxParallelJobs) ?? DEFAULT_PARALLEL_JOBS;
|
|
1678
|
+
}
|
|
1679
|
+
function shipCap(config2, shipId) {
|
|
1680
|
+
const machine = machineCap(config2);
|
|
1681
|
+
const own = readCount(config2.shipParallelJobs?.[shipId]);
|
|
1682
|
+
return own === null ? machine : Math.min(own, machine);
|
|
1683
|
+
}
|
|
1684
|
+
function selectDispatch(input) {
|
|
1685
|
+
const free = input.machineCap - input.runningTotal;
|
|
1686
|
+
if (free <= 0) return [];
|
|
1687
|
+
const taken = new Map(input.runningByShip);
|
|
1688
|
+
const picked = [];
|
|
1689
|
+
for (const entry of [...input.pending].sort((a, b) => a.job.createdAt - b.job.createdAt)) {
|
|
1690
|
+
if (picked.length >= free) break;
|
|
1691
|
+
if (!input.eligible(entry)) continue;
|
|
1692
|
+
const running = taken.get(entry.shipId) ?? 0;
|
|
1693
|
+
if (running >= input.shipCap(entry.shipId)) continue;
|
|
1694
|
+
taken.set(entry.shipId, running + 1);
|
|
1695
|
+
picked.push(entry);
|
|
1696
|
+
}
|
|
1697
|
+
return picked;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1661
1700
|
// src/jobs/finish.ts
|
|
1662
1701
|
import {
|
|
1663
1702
|
addDoc,
|
|
@@ -1838,8 +1877,13 @@ async function startDaemon() {
|
|
|
1838
1877
|
};
|
|
1839
1878
|
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
1840
1879
|
const startedAt = Date.now();
|
|
1841
|
-
|
|
1842
|
-
|
|
1880
|
+
const running = /* @__PURE__ */ new Map();
|
|
1881
|
+
const runningByShip = () => {
|
|
1882
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1883
|
+
for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
|
|
1884
|
+
return counts;
|
|
1885
|
+
};
|
|
1886
|
+
const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
|
|
1843
1887
|
const approved = /* @__PURE__ */ new Map();
|
|
1844
1888
|
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
1845
1889
|
const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
@@ -1870,6 +1914,7 @@ async function startDaemon() {
|
|
|
1870
1914
|
warnedUnapproved.delete(shipId);
|
|
1871
1915
|
needsRefill.add(shipId);
|
|
1872
1916
|
}
|
|
1917
|
+
const live = liveJobsOn(shipId);
|
|
1873
1918
|
await setDoc2(
|
|
1874
1919
|
shipRunnerRef(shipId),
|
|
1875
1920
|
{
|
|
@@ -1878,7 +1923,9 @@ async function startDaemon() {
|
|
|
1878
1923
|
status: "online",
|
|
1879
1924
|
lastSeenAt: now,
|
|
1880
1925
|
startedAt,
|
|
1881
|
-
currentJob:
|
|
1926
|
+
currentJob: live[0] ?? null,
|
|
1927
|
+
currentJobs: live,
|
|
1928
|
+
parallelLimit: shipCap(config2, shipId),
|
|
1882
1929
|
lastLogLines: [...logLines]
|
|
1883
1930
|
},
|
|
1884
1931
|
{ merge: true }
|
|
@@ -2013,11 +2060,12 @@ async function startDaemon() {
|
|
|
2013
2060
|
log2(
|
|
2014
2061
|
`Ship ${shipId}: ${why}. Deleted its runner key, its assignment and its queued work from this machine. To serve it again, run \`lumi-runner login\` here and have a captain approve this machine in the browser.`
|
|
2015
2062
|
);
|
|
2016
|
-
|
|
2063
|
+
const stranded = [...running.values()].filter((r) => r.shipId === shipId);
|
|
2064
|
+
if (stranded.length > 0) {
|
|
2017
2065
|
log2(
|
|
2018
|
-
`Stopping the job it was running \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put
|
|
2066
|
+
`Stopping the ${stranded.length} job(s) it was running there \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put them back on the queue itself.`
|
|
2019
2067
|
);
|
|
2020
|
-
|
|
2068
|
+
for (const r of stranded) r.abort.abort();
|
|
2021
2069
|
}
|
|
2022
2070
|
if (serving.size === 0) {
|
|
2023
2071
|
log2(
|
|
@@ -2026,17 +2074,15 @@ async function startDaemon() {
|
|
|
2026
2074
|
}
|
|
2027
2075
|
}
|
|
2028
2076
|
const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
|
|
2029
|
-
let
|
|
2077
|
+
let dispatching = false;
|
|
2030
2078
|
let pokeRequested = false;
|
|
2031
2079
|
let shuttingDown = false;
|
|
2032
|
-
let sessionAbort = null;
|
|
2033
|
-
let inFlight = null;
|
|
2034
2080
|
function poke() {
|
|
2035
|
-
if (
|
|
2081
|
+
if (dispatching || shuttingDown) {
|
|
2036
2082
|
pokeRequested = !shuttingDown;
|
|
2037
2083
|
return;
|
|
2038
2084
|
}
|
|
2039
|
-
|
|
2085
|
+
dispatch();
|
|
2040
2086
|
}
|
|
2041
2087
|
let limitTimer = null;
|
|
2042
2088
|
function armLimitTimer() {
|
|
@@ -2062,30 +2108,51 @@ async function startDaemon() {
|
|
|
2062
2108
|
armLimitTimer();
|
|
2063
2109
|
if (cleared.length > 0) poke();
|
|
2064
2110
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2111
|
+
function dispatch() {
|
|
2112
|
+
dispatching = true;
|
|
2067
2113
|
try {
|
|
2068
2114
|
for (; ; ) {
|
|
2069
2115
|
pokeRequested = false;
|
|
2070
2116
|
if (shuttingDown) break;
|
|
2071
2117
|
const now = Date.now();
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2118
|
+
const picks = selectDispatch({
|
|
2119
|
+
pending: pending.values(),
|
|
2120
|
+
runningByShip: runningByShip(),
|
|
2121
|
+
runningTotal: running.size,
|
|
2122
|
+
machineCap: machineCap(config2),
|
|
2123
|
+
shipCap: (shipId) => shipCap(config2, shipId),
|
|
2124
|
+
// Only Ships that have approved this machine are eligible; claiming elsewhere would
|
|
2125
|
+
// just be denied by the rules, so skip rather than hammer the queue. Engine-generic:
|
|
2126
|
+
// the limit map is keyed by (Ship, engine) and the engine comes from the registry via
|
|
2127
|
+
// the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
|
|
2128
|
+
// `pending`, which is what lets a reset resume them with only a poke.
|
|
2129
|
+
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)
|
|
2130
|
+
});
|
|
2131
|
+
for (const pick of picks) {
|
|
2132
|
+
pending.delete(`${pick.shipId}/${pick.job.id}`);
|
|
2133
|
+
startJob(pick.shipId, pick.job);
|
|
2080
2134
|
}
|
|
2081
|
-
if (!pokeRequested
|
|
2135
|
+
if (!pokeRequested) break;
|
|
2082
2136
|
}
|
|
2083
2137
|
} finally {
|
|
2084
|
-
|
|
2085
|
-
if (pokeRequested) poke();
|
|
2138
|
+
dispatching = false;
|
|
2086
2139
|
}
|
|
2087
2140
|
}
|
|
2141
|
+
function startJob(shipId, job) {
|
|
2142
|
+
const entry = { shipId, abort: new AbortController(), promise: Promise.resolve() };
|
|
2143
|
+
running.set(job.id, entry);
|
|
2144
|
+
entry.promise = processJob(shipId, job, entry).catch((e) => {
|
|
2145
|
+
log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
|
|
2146
|
+
}).finally(() => {
|
|
2147
|
+
running.delete(job.id);
|
|
2148
|
+
if (!shuttingDown) poke();
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2088
2151
|
async function claim(shipId, job) {
|
|
2152
|
+
if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now())) {
|
|
2153
|
+
pending.set(`${shipId}/${job.id}`, { shipId, job });
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
2089
2156
|
const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
|
|
2090
2157
|
try {
|
|
2091
2158
|
let claimed = null;
|
|
@@ -2116,7 +2183,7 @@ async function startDaemon() {
|
|
|
2116
2183
|
async function loadSecrets(shipId) {
|
|
2117
2184
|
return loadRunnerSecrets(sess(shipId).fb.db, shipId);
|
|
2118
2185
|
}
|
|
2119
|
-
async function processJob(shipId, queuedJob) {
|
|
2186
|
+
async function processJob(shipId, queuedJob, slot) {
|
|
2120
2187
|
const job = await claim(shipId, queuedJob);
|
|
2121
2188
|
if (!job) return;
|
|
2122
2189
|
const target = jobTarget(job);
|
|
@@ -2136,12 +2203,13 @@ async function startDaemon() {
|
|
|
2136
2203
|
transcriptPath: "",
|
|
2137
2204
|
error: "Job names neither a task nor a chat, so there is nothing to run."
|
|
2138
2205
|
});
|
|
2139
|
-
currentJob = null;
|
|
2140
2206
|
return;
|
|
2141
2207
|
}
|
|
2142
2208
|
const targetLabel = target.kind === "task" ? `task ${target.taskId}` : `chat ${target.chatId}`;
|
|
2143
|
-
log2(
|
|
2144
|
-
|
|
2209
|
+
log2(
|
|
2210
|
+
`Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt}) \u2014 slot ${running.size}/${machineCap(config2)}`
|
|
2211
|
+
);
|
|
2212
|
+
slot.mirror = {
|
|
2145
2213
|
jobId: job.id,
|
|
2146
2214
|
// Conditional spread: Firestore rejects an explicit undefined in the mirror write.
|
|
2147
2215
|
...target.kind === "task" ? { taskId: target.taskId } : { chatId: target.chatId },
|
|
@@ -2150,11 +2218,9 @@ async function startDaemon() {
|
|
|
2150
2218
|
...job.workflowName ? { workflowName: job.workflowName } : {},
|
|
2151
2219
|
startedAt: Date.now()
|
|
2152
2220
|
};
|
|
2153
|
-
currentJobShip = shipId;
|
|
2154
2221
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2155
2222
|
void heartbeat();
|
|
2156
2223
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
2157
|
-
sessionAbort = new AbortController();
|
|
2158
2224
|
notify(
|
|
2159
2225
|
"Crew job started",
|
|
2160
2226
|
target.kind === "task" ? `Agent is working on task ${target.taskId}.` : "Agent is replying in a chat."
|
|
@@ -2214,7 +2280,7 @@ async function startDaemon() {
|
|
|
2214
2280
|
}
|
|
2215
2281
|
throw e;
|
|
2216
2282
|
}
|
|
2217
|
-
if (
|
|
2283
|
+
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
2218
2284
|
const session = await getDriver(engineId).run({
|
|
2219
2285
|
prompt,
|
|
2220
2286
|
agent,
|
|
@@ -2225,7 +2291,7 @@ async function startDaemon() {
|
|
|
2225
2291
|
secrets,
|
|
2226
2292
|
githubToken,
|
|
2227
2293
|
timeoutMs: JOB_TIMEOUT_MS,
|
|
2228
|
-
signal:
|
|
2294
|
+
signal: slot.abort.signal,
|
|
2229
2295
|
log: log2
|
|
2230
2296
|
});
|
|
2231
2297
|
transcript = redactTranscript(session.transcript, [
|
|
@@ -2251,7 +2317,6 @@ async function startDaemon() {
|
|
|
2251
2317
|
failure = e instanceof Error ? e.message : String(e);
|
|
2252
2318
|
}
|
|
2253
2319
|
wake?.release();
|
|
2254
|
-
sessionAbort = null;
|
|
2255
2320
|
try {
|
|
2256
2321
|
if (!serving.has(shipId)) {
|
|
2257
2322
|
log2(
|
|
@@ -2307,8 +2372,7 @@ async function startDaemon() {
|
|
|
2307
2372
|
} catch (e) {
|
|
2308
2373
|
log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
2309
2374
|
}
|
|
2310
|
-
|
|
2311
|
-
currentJobShip = null;
|
|
2375
|
+
slot.mirror = void 0;
|
|
2312
2376
|
if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
|
|
2313
2377
|
if (!shuttingDown) void heartbeat();
|
|
2314
2378
|
}
|
|
@@ -2326,16 +2390,22 @@ async function startDaemon() {
|
|
|
2326
2390
|
clearInterval(heartbeatTimer);
|
|
2327
2391
|
if (limitTimer) clearTimeout(limitTimer);
|
|
2328
2392
|
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2393
|
+
const inFlight = [...running.values()];
|
|
2394
|
+
if (inFlight.length > 0) {
|
|
2395
|
+
log2(
|
|
2396
|
+
`${signal} received with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
|
|
2397
|
+
);
|
|
2398
|
+
notify(
|
|
2399
|
+
"Crew runner stopping",
|
|
2400
|
+
`${inFlight.length} job(s) were in progress; they have been returned to the queue.`
|
|
2401
|
+
);
|
|
2402
|
+
for (const r of inFlight) r.abort.abort();
|
|
2333
2403
|
} else {
|
|
2334
2404
|
log2(`${signal} received \u2014 shutting down.`);
|
|
2335
2405
|
}
|
|
2336
|
-
if (inFlight) {
|
|
2406
|
+
if (inFlight.length > 0) {
|
|
2337
2407
|
await Promise.race([
|
|
2338
|
-
inFlight,
|
|
2408
|
+
Promise.all(inFlight.map((r) => r.promise)),
|
|
2339
2409
|
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
|
|
2340
2410
|
]);
|
|
2341
2411
|
}
|
|
@@ -2344,7 +2414,12 @@ async function startDaemon() {
|
|
|
2344
2414
|
for (const shipId of serving) {
|
|
2345
2415
|
await setDoc2(
|
|
2346
2416
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
2347
|
-
{
|
|
2417
|
+
{
|
|
2418
|
+
status: "offline",
|
|
2419
|
+
lastSeenAt: now,
|
|
2420
|
+
currentJob: deleteField(),
|
|
2421
|
+
currentJobs: deleteField()
|
|
2422
|
+
},
|
|
2348
2423
|
{ merge: true }
|
|
2349
2424
|
);
|
|
2350
2425
|
}
|
|
@@ -2354,7 +2429,10 @@ async function startDaemon() {
|
|
|
2354
2429
|
};
|
|
2355
2430
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2356
2431
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2357
|
-
|
|
2432
|
+
const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
|
|
2433
|
+
log2(
|
|
2434
|
+
`Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
|
|
2435
|
+
);
|
|
2358
2436
|
await new Promise(() => {
|
|
2359
2437
|
});
|
|
2360
2438
|
}
|
|
@@ -2491,6 +2569,9 @@ var TOGGLES = {
|
|
|
2491
2569
|
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
2492
2570
|
keepAwake: "Keep this machine awake while a job is running"
|
|
2493
2571
|
};
|
|
2572
|
+
var PARALLEL = "parallel";
|
|
2573
|
+
var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
|
|
2574
|
+
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
2494
2575
|
function isToggle(key) {
|
|
2495
2576
|
return Object.hasOwn(TOGGLES, key);
|
|
2496
2577
|
}
|
|
@@ -2499,13 +2580,29 @@ function parseBool(value) {
|
|
|
2499
2580
|
if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
2500
2581
|
throw new CliError(`Expected on/off, got "${value}".`);
|
|
2501
2582
|
}
|
|
2583
|
+
function parseParallel(value) {
|
|
2584
|
+
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
2585
|
+
const n = Number(value);
|
|
2586
|
+
if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
|
|
2587
|
+
throw new CliError(
|
|
2588
|
+
`Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
return n;
|
|
2592
|
+
}
|
|
2502
2593
|
async function runConfigList() {
|
|
2503
2594
|
const config2 = requireConfig();
|
|
2504
2595
|
const values = Object.fromEntries(
|
|
2505
2596
|
Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
|
|
2506
2597
|
);
|
|
2598
|
+
const machine = machineCap(config2);
|
|
2599
|
+
const ships = config2.ships.map((shipId) => ({
|
|
2600
|
+
shipId,
|
|
2601
|
+
parallel: shipCap(config2, shipId),
|
|
2602
|
+
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
2603
|
+
}));
|
|
2507
2604
|
if (isJson()) {
|
|
2508
|
-
emitJson({ configDir: configDir(), ...values });
|
|
2605
|
+
emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
|
|
2509
2606
|
return 0;
|
|
2510
2607
|
}
|
|
2511
2608
|
say.line(` ${configDir()}`);
|
|
@@ -2513,13 +2610,27 @@ async function runConfigList() {
|
|
|
2513
2610
|
for (const key of Object.keys(TOGGLES)) {
|
|
2514
2611
|
say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
|
|
2515
2612
|
}
|
|
2613
|
+
say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
|
|
2614
|
+
if (ships.length > 0) {
|
|
2615
|
+
say.line("");
|
|
2616
|
+
for (const ship2 of ships) {
|
|
2617
|
+
const note2 = ship2.explicit ? "" : " (machine default)";
|
|
2618
|
+
say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2516
2621
|
return 0;
|
|
2517
2622
|
}
|
|
2518
|
-
async function runConfigSet(key, value) {
|
|
2623
|
+
async function runConfigSet(key, value, options = {}) {
|
|
2624
|
+
const config2 = requireConfig();
|
|
2625
|
+
if (key === PARALLEL) return setParallel(config2, value, options.ship);
|
|
2519
2626
|
if (!isToggle(key)) {
|
|
2520
|
-
throw new CliError(
|
|
2627
|
+
throw new CliError(
|
|
2628
|
+
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL].join(", ")}.`
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
if (options.ship) {
|
|
2632
|
+
throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
|
|
2521
2633
|
}
|
|
2522
|
-
const config2 = requireConfig();
|
|
2523
2634
|
config2[key] = parseBool(value);
|
|
2524
2635
|
saveConfig(config2);
|
|
2525
2636
|
if (isJson()) {
|
|
@@ -2529,6 +2640,41 @@ async function runConfigSet(key, value) {
|
|
|
2529
2640
|
say.success(`${key} = ${config2[key] ? "on" : "off"}`);
|
|
2530
2641
|
return 0;
|
|
2531
2642
|
}
|
|
2643
|
+
function setParallel(config2, value, ship2) {
|
|
2644
|
+
const parsed = parseParallel(value);
|
|
2645
|
+
if (ship2) {
|
|
2646
|
+
if (!config2.ships.includes(ship2)) {
|
|
2647
|
+
throw new CliError(
|
|
2648
|
+
`This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
const overrides = { ...config2.shipParallelJobs ?? {} };
|
|
2652
|
+
if (parsed === null) delete overrides[ship2];
|
|
2653
|
+
else overrides[ship2] = parsed;
|
|
2654
|
+
config2.shipParallelJobs = overrides;
|
|
2655
|
+
} else if (parsed === null) {
|
|
2656
|
+
delete config2.maxParallelJobs;
|
|
2657
|
+
} else {
|
|
2658
|
+
config2.maxParallelJobs = parsed;
|
|
2659
|
+
}
|
|
2660
|
+
saveConfig(config2);
|
|
2661
|
+
const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
|
|
2662
|
+
if (isJson()) {
|
|
2663
|
+
emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
|
|
2664
|
+
return 0;
|
|
2665
|
+
}
|
|
2666
|
+
const machine = machineCap(config2);
|
|
2667
|
+
say.success(
|
|
2668
|
+
ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
|
|
2669
|
+
);
|
|
2670
|
+
if (ship2 && parsed !== null && parsed > machine) {
|
|
2671
|
+
say.line(
|
|
2672
|
+
` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
2676
|
+
return 0;
|
|
2677
|
+
}
|
|
2532
2678
|
|
|
2533
2679
|
// src/cli/commands/doctor.ts
|
|
2534
2680
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -3685,6 +3831,12 @@ service.command("status").description("Is the background service installed and r
|
|
|
3685
3831
|
program.command("uninstall").description("Remove the background service and print the final npm step").option("--purge", "also delete the config directory (loses this machine's runner id)").action(action(async (options) => runUninstall(options)));
|
|
3686
3832
|
var config = program.command("config").description("Local per-machine preferences");
|
|
3687
3833
|
config.command("list").description("Show current settings").action(action(runConfigList));
|
|
3688
|
-
config.command("set <key> <value>").description(
|
|
3834
|
+
config.command("set <key> <value>").description(
|
|
3835
|
+
'Change a setting (notifications | keepAwake \u2192 on/off, parallel \u2192 1-8 or "default")'
|
|
3836
|
+
).option("--ship <shipId>", "apply to one Ship only (parallel)").action(
|
|
3837
|
+
action(
|
|
3838
|
+
async (key, value, options) => runConfigSet(key, value, options)
|
|
3839
|
+
)
|
|
3840
|
+
);
|
|
3689
3841
|
await program.parseAsync(process.argv);
|
|
3690
3842
|
//# sourceMappingURL=cli.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
|
|
6
6
|
"//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",
|