@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.19.2",
3
+ "version": "0.19.4",
4
4
  "description": "Ownable CLI — set up Ownable, connect it to your coding agent, and read the Context API from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
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
- export const WORKERS = 3;
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);