@opennous/cli 0.19.2 → 0.19.4
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/CHANGELOG.md +25 -0
- package/package.json +1 -1
- package/src/index.js +8 -1
- package/src/runner.js +97 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.4
|
|
4
|
+
|
|
5
|
+
### Added: turn reading off, or remove a runner, from the app
|
|
6
|
+
|
|
7
|
+
- Reading turned off in Settings → AI subscription: the runner reads nothing on your plan until
|
|
8
|
+
it's back on, and says so once in its log (GET-1356).
|
|
9
|
+
- A runner removed in the app while it runs uninstalls itself on its next check-in: it stops, and
|
|
10
|
+
won't start at login again.
|
|
11
|
+
|
|
12
|
+
## 0.19.3
|
|
13
|
+
|
|
14
|
+
### Changed: history reads go faster
|
|
15
|
+
|
|
16
|
+
- While your history is read, the runner keeps your Mac from idle-sleeping (closing the lid still
|
|
17
|
+
sleeps it), and lets go five minutes after the reading stops (GET-1353).
|
|
18
|
+
- A call cut off by the machine sleeping is read again instead of failing.
|
|
19
|
+
- Six calls are answered at once instead of three. `NOUS_RUNNER_WORKERS` sets it (1 to 8).
|
|
20
|
+
|
|
21
|
+
## 0.19.2
|
|
22
|
+
|
|
23
|
+
### Fixed: `runner install` keeps `--agent` and `--workspace`
|
|
24
|
+
|
|
25
|
+
`runner install --agent codex --workspace <id>` installed a runner for whichever agent you were
|
|
26
|
+
signed in to and the workspace of your saved key (GET-1345).
|
|
27
|
+
|
|
3
28
|
## 0.19.1
|
|
4
29
|
|
|
5
30
|
### Changed: the first history read goes as far back as your workspace chose
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -664,7 +664,7 @@ const runnerCmd = program
|
|
|
664
664
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
665
665
|
.action(async (opts) => {
|
|
666
666
|
const { detectAgents, installedAgents, liveRun, NO_AGENT } = await import("./backfill.js");
|
|
667
|
-
const { runRunner } = await import("./runner.js");
|
|
667
|
+
const { runRunner, keepAwake, removeSelf } = await import("./runner.js");
|
|
668
668
|
const agent = await pickAgent(opts.agent, opts.apiKey ? installedAgents : detectAgents, NO_AGENT);
|
|
669
669
|
const apiKey = opts.apiKey ? savedModelKey(agent) : null;
|
|
670
670
|
if (opts.apiKey && !apiKey) {
|
|
@@ -679,7 +679,14 @@ const runnerCmd = program
|
|
|
679
679
|
console.log(stamp(`Reading on your ${apiKey ? `${agent === "codex" ? "OpenAI" : "Anthropic"} API key` : `${agent} plan`}. ${service ? "" : "Stop with Ctrl-C; `nous runner install` keeps it going on its own."}`.trim()));
|
|
680
680
|
await runRunner({
|
|
681
681
|
agent, host, version, auth: apiKey ? "api_key" : "plan", run: liveRun({ agent, apiKey }), log: (l) => console.log(stamp(l)),
|
|
682
|
+
awake: keepAwake({ log: (l) => console.log(stamp(l)) }),
|
|
682
683
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
684
|
+
// Removed in the app (GET-1356): the server forgets this machine, then it uninstalls itself.
|
|
685
|
+
onRemoved: async () => {
|
|
686
|
+
await api.softDelete("/v2/runner", { host }).catch(() => {});
|
|
687
|
+
removeSelf();
|
|
688
|
+
process.exit(0);
|
|
689
|
+
},
|
|
683
690
|
api: {
|
|
684
691
|
poll: (body) => api.softPost("/v2/runner/poll", body),
|
|
685
692
|
answer: (id, body) => api.softPost(`/v2/runner/calls/${id}`, body),
|
package/src/runner.js
CHANGED
|
@@ -4,13 +4,60 @@
|
|
|
4
4
|
// a call's prompt holds other people's words), and posts the answer back. The server decides what to
|
|
5
5
|
// read; this only answers. `nous runner install` starts it at login, so it keeps going on its own.
|
|
6
6
|
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
8
8
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir, hostname, platform } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { commandFor, parseAnswer, troubleOf, resetAt } from "./backfill.js";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Calls answered at once (GET-1353). NOUS_RUNNER_WORKERS overrides; the server hands out at most 8 a poll. */
|
|
14
|
+
export const WORKERS = workersFrom(process.env.NOUS_RUNNER_WORKERS);
|
|
15
|
+
/** How long without work before the Mac may sleep again. */
|
|
16
|
+
export const AWAKE_IDLE_MS = 5 * 60_000;
|
|
17
|
+
/** A call whose wall clock ran this much past the machine's own clock spanned a sleep. */
|
|
18
|
+
export const SLEPT_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
/** NOUS_RUNNER_WORKERS as a count: 1 to 8, else 6. Pure. */
|
|
21
|
+
export function workersFrom(value) {
|
|
22
|
+
const n = Math.floor(Number(value));
|
|
23
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(n, 8) : 6;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keeps a Mac from idle-sleeping while there is reading to do (GET-1353): a sleeping Mac answers no
|
|
28
|
+
* calls, and a three-month read took hours of naps. `caffeinate -i -w <pid>` holds off idle sleep
|
|
29
|
+
* only (closing the lid still sleeps) and lets go by itself when this process ends. `touch()` on
|
|
30
|
+
* work, `tick()` every loop: after AWAKE_IDLE_MS with no work it lets go. Not a Mac: does nothing.
|
|
31
|
+
*/
|
|
32
|
+
export function keepAwake({ os = platform(), spawn = spawnChild, pid = process.pid, log = () => {}, now = Date.now, idleMs = AWAKE_IDLE_MS } = {}) {
|
|
33
|
+
let child = null, lastWork = 0;
|
|
34
|
+
const release = () => {
|
|
35
|
+
if (!child) return;
|
|
36
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
37
|
+
child = null;
|
|
38
|
+
log("Reading is idle. This Mac may sleep again.");
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
touch() {
|
|
42
|
+
lastWork = now();
|
|
43
|
+
if (child || os !== "darwin") return;
|
|
44
|
+
try {
|
|
45
|
+
child = spawn("/usr/bin/caffeinate", ["-i", "-w", String(pid)], { stdio: "ignore" });
|
|
46
|
+
child.on?.("error", () => { child = null; });
|
|
47
|
+
child.unref?.();
|
|
48
|
+
log("Keeping this Mac awake while your history is read.");
|
|
49
|
+
} catch { child = null; }
|
|
50
|
+
},
|
|
51
|
+
tick() { if (child && now() - lastWork >= idleMs) release(); },
|
|
52
|
+
release,
|
|
53
|
+
get holding() { return !!child; },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Is there reading to do: calls in hand, or a history read under way. Pure. */
|
|
58
|
+
export function hasWork(page) {
|
|
59
|
+
return (page?.calls?.length ?? 0) > 0 || !!(page?.reading && !page.reading.live && !page.reading.finished);
|
|
60
|
+
}
|
|
14
61
|
/**
|
|
15
62
|
* How long the server may hold a poll open waiting for a call (GET-1307). Idle, the runner checks in
|
|
16
63
|
* about once a minute and still picks up a new call within seconds.
|
|
@@ -76,10 +123,13 @@ export function pauseLine(agent, reason, until, now = Date.now()) {
|
|
|
76
123
|
* `stop()` → true ends it (tests; the real one runs until the process is stopped).
|
|
77
124
|
*/
|
|
78
125
|
export async function runRunner({ api, agent, host = hostname(), version = null, auth = "plan", run, sleep, log, now = Date.now,
|
|
79
|
-
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
|
|
80
|
-
let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, backfillAt = -Infinity;
|
|
126
|
+
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false, awake = null, clock = () => performance.now(), onRemoved = null }) {
|
|
127
|
+
let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, backfillAt = -Infinity, sleptNoted = false, offNoted = false;
|
|
81
128
|
const answerOne = async (call, models) => {
|
|
82
129
|
for (let attempt = 0; ; attempt++) {
|
|
130
|
+
// performance.now() stops while a Mac sleeps; the wall clock doesn't. A call that failed across
|
|
131
|
+
// a sleep (the CLI timed out, the network dropped) isn't the call's fault (GET-1353).
|
|
132
|
+
const wall0 = now(), mono0 = clock();
|
|
83
133
|
try {
|
|
84
134
|
const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
|
|
85
135
|
await api.answer(call.id, { answer: text, model: models[agent].model, usage });
|
|
@@ -90,6 +140,12 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
90
140
|
// The plan's limit, a sign-out or an outage (GET-1303): none of them is the call's fault, so
|
|
91
141
|
// it is dropped, not failed (its lease runs out and it comes back), and nothing more is taken
|
|
92
142
|
// until it passes. The heartbeat keeps going, so the app shows why.
|
|
143
|
+
if ((now() - wall0) - (clock() - mono0) > SLEPT_MS) {
|
|
144
|
+
// Dropped, not failed: its lease runs out and it comes back, the meeting stays unread.
|
|
145
|
+
if (!sleptNoted) log("This machine slept during a call. It's read again; nothing is lost.");
|
|
146
|
+
sleptNoted = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
93
149
|
const reason = troubleOf(err.message);
|
|
94
150
|
if (reason) {
|
|
95
151
|
const until = reason === "limit" ? (resetAt(err.message, now()) ?? now() + limitWaitMs) : now() + RECHECK_MS;
|
|
@@ -116,6 +172,22 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
116
172
|
await sleep(ERROR_BACKOFF_MS);
|
|
117
173
|
continue;
|
|
118
174
|
}
|
|
175
|
+
// Removed in the app (GET-1356): this machine stops for good, and won't start at login.
|
|
176
|
+
if (page.uninstall) {
|
|
177
|
+
log("Removed in Ownable. This machine stops reading and won't start at login again.");
|
|
178
|
+
awake?.release();
|
|
179
|
+
if (onRemoved) await onRemoved();
|
|
180
|
+
return { answered, removed: true };
|
|
181
|
+
}
|
|
182
|
+
// Reading turned off in the app (GET-1356): nothing is read on this plan until it's back on.
|
|
183
|
+
if (page.reading_off) {
|
|
184
|
+
if (!offNoted) log("Reading is off in Ownable. Nothing is read on your plan until you turn it back on.");
|
|
185
|
+
offNoted = true;
|
|
186
|
+
awake?.release();
|
|
187
|
+
if (now() - asked < 5000) await sleep(IDLE_MS);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (offNoted) { log("Reading is on again."); offNoted = false; }
|
|
119
191
|
// One command sets everything up (GET-1307): with no history read yet, the runner starts that
|
|
120
192
|
// read itself, on this plan, and answers its calls like any other.
|
|
121
193
|
if (page.needs_backfill && api.startBackfill && now() - backfillAt >= BACKFILL_RETRY_MS) {
|
|
@@ -129,6 +201,7 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
129
201
|
log(`Your history read can't start yet (${err.message}). Trying again in ${BACKFILL_RETRY_MS / 60000} minutes.`);
|
|
130
202
|
}
|
|
131
203
|
}
|
|
204
|
+
if (awake) { if (hasWork(page)) awake.touch(); else awake.tick(); }
|
|
132
205
|
const line = readingLine(page.reading);
|
|
133
206
|
if (line && line !== lastLine) { log(line); }
|
|
134
207
|
lastLine = line;
|
|
@@ -140,7 +213,9 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
140
213
|
continue;
|
|
141
214
|
}
|
|
142
215
|
await Promise.all(calls.map((c) => answerOne(c, page.models)));
|
|
216
|
+
sleptNoted = false;
|
|
143
217
|
}
|
|
218
|
+
awake?.release();
|
|
144
219
|
return { answered };
|
|
145
220
|
}
|
|
146
221
|
|
|
@@ -252,6 +327,24 @@ export function uninstallService({ run = spawnSync, os = platform(), home = home
|
|
|
252
327
|
return true;
|
|
253
328
|
}
|
|
254
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Removed in the app (GET-1356), from inside the running service: the start-at-login file goes first,
|
|
332
|
+
* then the service manager stops this process (so last). Returns whether a service was there.
|
|
333
|
+
*/
|
|
334
|
+
export function removeSelf({ run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
335
|
+
const where = servicePaths(os, home);
|
|
336
|
+
if (!where || !existsSync(where.file)) return false;
|
|
337
|
+
if (where.kind === "launchd") {
|
|
338
|
+
rmSync(where.file, { force: true });
|
|
339
|
+
run("launchctl", ["remove", LABEL], { stdio: "ignore" });
|
|
340
|
+
} else {
|
|
341
|
+
run("systemctl", ["--user", "disable", "opennous-runner.service"], { stdio: "ignore" });
|
|
342
|
+
rmSync(where.file, { force: true });
|
|
343
|
+
run("systemctl", ["--user", "stop", "opennous-runner.service"], { stdio: "ignore" });
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
255
348
|
/** Whether the service is installed, and running as far as the OS says. */
|
|
256
349
|
export function serviceStatus({ run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
257
350
|
const where = servicePaths(os, home);
|