@opennous/cli 0.17.0 → 0.18.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @opennous/cli changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Changed: one command sets everything up, and the runner is quiet when idle
6
+
7
+ `runner install` is the only command you need: with no history read yet, the runner starts that read
8
+ itself (six months, on your plan), then keeps everything new current. Idle, it checks in about once a
9
+ minute and still picks up new work within seconds. `runner uninstall` tells Ownable this machine
10
+ stopped, so with no runner left your reading goes back to credits (GET-1307).
11
+
12
+ ## 0.17.1
13
+
14
+ ### Fixed: a sign-out or an outage no longer fails your reads
15
+
16
+ Signed out of Claude or Codex, or the model not answering: `runner` and `backfill` now wait instead
17
+ of marking every conversation unreadable, and tell you what to run. At your plan's limit they wait
18
+ until the reset time the limit message names, not a fixed 30 minutes (GET-1303).
19
+
3
20
  ## 0.17.0
4
21
 
5
22
  ### Added: `runner`, reading on your own plan all the time
package/README.md CHANGED
@@ -21,7 +21,7 @@ npx @opennous/cli@latest backfill # your history, once
21
21
  npx @opennous/cli@latest runner install # what's new, from now on; starts at login
22
22
  ```
23
23
 
24
- `runner status` says whether it's running, `runner uninstall` stops it. Settings → Your plan in the app shows each runner and what's waiting to be read.
24
+ `runner status` says whether it's running, `runner uninstall` stops it. Settings → AI subscription in the app shows each runner and what's waiting to be read.
25
25
 
26
26
  ## Event tracking
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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/backfill.js CHANGED
@@ -53,14 +53,16 @@ export function parseAnswer(agent, out) {
53
53
  if (j.is_error) throw new Error(String(j.result));
54
54
  return { text: j.result, usage: { input_tokens: j.usage?.input_tokens ?? 0, output_tokens: j.usage?.output_tokens ?? 0 } };
55
55
  }
56
- let text = "", usage = null;
56
+ let text = "", usage = null, error = null;
57
57
  for (const line of out.split("\n")) {
58
58
  let e;
59
59
  try { e = JSON.parse(line); } catch { continue; }
60
60
  if (e.item?.type === "agent_message") text = e.item.text;
61
61
  if (e.usage) usage = e.usage;
62
+ // An error event says why (a limit, a sign-out), which the runner needs (GET-1303).
63
+ if (e.type === "error" || e.type === "turn.failed") error = e.message ?? e.error?.message ?? error;
62
64
  }
63
- if (!text) throw new Error("codex gave no answer");
65
+ if (!text) throw new Error(error || "codex gave no answer");
64
66
  return { text, usage: { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0 } };
65
67
  }
66
68
 
@@ -69,6 +71,55 @@ export function isPlanLimit(message) {
69
71
  return /usage limit|rate.?limit|hit your .*limit|limit reached|too many requests|\b429\b|quota/i.test(String(message));
70
72
  }
71
73
 
74
+ /**
75
+ * What an error says about the plan, not the call (GET-1303): 'limit' (passes at its reset),
76
+ * 'signed_out' (passes when the person signs in again), 'outage' (the model is down; passes by
77
+ * itself), or null for a bad call. A call that fails for any of these is not unreadable, so it must
78
+ * not be marked failed. Pure.
79
+ */
80
+ export function troubleOf(message) {
81
+ const m = String(message ?? "");
82
+ if (isPlanLimit(m)) return "limit";
83
+ if (/not logged in|please (run )?\/?login|log ?in again|sign ?in again|invalid api key|authentication[_ ](error|failed)|oauth token|token (has )?expired|unauthori[sz]ed|\b401\b|codex login/i.test(m)) return "signed_out";
84
+ if (/overloaded|\b529\b|\b503\b|\b502\b|service unavailable|internal server error|api error: 5\d\d|econnreset|etimedout|enotfound|network error|socket hang up|stream disconnected/i.test(m)) return "outage";
85
+ return null;
86
+ }
87
+
88
+ const MIN_WAIT_MS = 60_000;
89
+ const MAX_WAIT_MS = 7 * 86_400_000;
90
+
91
+ /**
92
+ * When the plan's limit lifts, from its own message (GET-1303), or null when it doesn't say.
93
+ * Claude: "…limit reached|1731470400" or "resets 3pm" / "resets 3:30pm". Codex: "try again in 2 hours
94
+ * 5 minutes" or "try again at 3:04 PM". Clock times are this machine's local time, the next one to
95
+ * come. A minute is added, so the retry lands after the reset, not on it. Pure.
96
+ */
97
+ export function resetAt(message, now = Date.now()) {
98
+ const m = String(message ?? "");
99
+ const clamp = (t) => (Number.isFinite(t) && t > now ? Math.min(now + MAX_WAIT_MS, Math.max(now + MIN_WAIT_MS, t + 60_000)) : null);
100
+ const unix = /\|(\d{10})\b/.exec(m);
101
+ if (unix) return clamp(Number(unix[1]) * 1000);
102
+ const inMatch = /try again in\s+(?:(\d+)\s*d(?:ays?)?\s*)?(?:(\d+)\s*h(?:ours?|rs?)?\s*)?(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i.exec(m);
103
+ if (inMatch && (inMatch[1] || inMatch[2] || inMatch[3])) {
104
+ const ms = ((+inMatch[1] || 0) * 24 * 60 + (+inMatch[2] || 0) * 60 + (+inMatch[3] || 0)) * 60_000;
105
+ return clamp(now + ms);
106
+ }
107
+ const clock = /(?:resets?|try again at)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(m);
108
+ if (clock) {
109
+ let h = Number(clock[1]);
110
+ const min = Number(clock[2] ?? 0);
111
+ const ap = clock[3]?.toLowerCase();
112
+ if (h > 23 || min > 59 || (ap && (h < 1 || h > 12))) return null;
113
+ if (ap === "pm" && h < 12) h += 12;
114
+ if (ap === "am" && h === 12) h = 0;
115
+ const d = new Date(now);
116
+ d.setHours(h, min, 0, 0);
117
+ if (d.getTime() <= now) d.setDate(d.getDate() + 1);
118
+ return clamp(d.getTime());
119
+ }
120
+ return null;
121
+ }
122
+
72
123
  export function detectAgents(run = spawnSync) {
73
124
  const found = [];
74
125
  if (run("claude", ["auth", "status"], { encoding: "utf8" }).status === 0) found.push("claude");
@@ -167,9 +218,19 @@ export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Da
167
218
  answered++;
168
219
  return;
169
220
  } catch (err) {
170
- if (isPlanLimit(err.message)) {
171
- if (now() >= waitUntil) log(`Your ${agent} plan hit its limit. Waiting ${Math.round(limitWaitMs / 60000)} minutes, then carrying on. You can stop now and run this again later instead.`);
172
- waitUntil = Math.max(waitUntil, now() + limitWaitMs);
221
+ // A limit, a sign-out or an outage (GET-1303) is not this call's fault: wait, then try it again.
222
+ const trouble = troubleOf(err.message);
223
+ if (trouble) {
224
+ const until = trouble === "limit" ? (resetAt(err.message, now()) ?? now() + limitWaitMs) : now() + 5 * 60_000;
225
+ if (now() >= waitUntil) {
226
+ const mins = Math.max(1, Math.round((until - now()) / 60000));
227
+ log(trouble === "signed_out"
228
+ ? `You're signed out of ${agent}. Run \`${agent === "codex" ? "codex login" : "claude"}\` in another terminal and sign in; this carries on by itself.`
229
+ : trouble === "outage"
230
+ ? `${agent} isn't answering right now. Trying again in ${mins} minutes.`
231
+ : `Your ${agent} plan hit its limit. Waiting ${mins} minutes, until it resets, then carrying on. You can stop now and run this again later instead.`);
232
+ }
233
+ waitUntil = Math.max(waitUntil, until);
173
234
  continue;
174
235
  }
175
236
  if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }); return; }
package/src/index.js CHANGED
@@ -154,6 +154,7 @@ function apiClient(key) {
154
154
  get: (path, query) => request("GET", path, { query }),
155
155
  post: (path, body) => request("POST", path, { body }),
156
156
  softPost: (path, body) => request("POST", path, { body, soft: true }),
157
+ softDelete: (path, body) => request("DELETE", path, { body, soft: true }),
157
158
  };
158
159
  }
159
160
 
@@ -673,6 +674,7 @@ const runnerCmd = program
673
674
  api: {
674
675
  poll: (body) => api.softPost("/v2/runner/poll", body),
675
676
  answer: (id, body) => api.softPost(`/v2/runner/calls/${id}`, body),
677
+ startBackfill: (body) => api.softPost("/v2/backfill/own", body),
676
678
  },
677
679
  });
678
680
  });
@@ -697,7 +699,7 @@ runnerCmd
697
699
  }
698
700
  try {
699
701
  const where = installService({ agent, workspace: opts.workspace ?? null });
700
- console.log(`Connected. The runner reads on your ${agent} plan and starts every time you log in.`);
702
+ console.log(`Connected. Your calls and email are read on your ${agent} plan: your history first, then everything new. It starts every time you log in.`);
701
703
  if (where.log) console.log(`Log: ${where.log}`);
702
704
  console.log("Check on it with `npx @opennous/cli@latest runner status`. Stop it with `npx @opennous/cli@latest runner uninstall`.");
703
705
  } catch (e) {
@@ -711,7 +713,10 @@ runnerCmd
711
713
  .description("Stop the runner and stop it starting at login")
712
714
  .action(async () => {
713
715
  const { uninstallService } = await import("./runner.js");
714
- console.log(uninstallService() ? "Stopped. It won't start at login any more." : "No runner is installed on this machine.");
716
+ const removed = uninstallService();
717
+ // The server stops waiting on this machine (GET-1307): with no runner left, your reading goes back to credits.
718
+ if (hasKey()) await apiClient().softDelete("/v2/runner", { host: hostname() }).catch(() => {});
719
+ console.log(removed ? "Stopped. It won't start at login any more, and your reading is back on credits unless another machine runs it." : "No runner is installed on this machine.");
715
720
  });
716
721
 
717
722
  runnerCmd
package/src/runner.js CHANGED
@@ -8,16 +8,26 @@ import { 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
- import { commandFor, parseAnswer, isPlanLimit } from "./backfill.js";
11
+ import { commandFor, parseAnswer, troubleOf, resetAt } from "./backfill.js";
12
12
 
13
13
  export const WORKERS = 3;
14
- /** Busy: there is a read going, so ask again soon. */
14
+ /**
15
+ * How long the server may hold a poll open waiting for a call (GET-1307). Idle, the runner checks in
16
+ * about once a minute and still picks up a new call within seconds.
17
+ */
18
+ export const WAIT_MS = 45_000;
19
+ /** A server that answers at once (one without long polls): ask again after this. */
15
20
  export const BUSY_MS = 2000;
16
- /** Idle: nothing going. A heartbeat still has to land well inside the server's two minutes. */
17
21
  export const IDLE_MS = 20_000;
22
+ /** A history read the runner started that the server could not start yet: try again after this. */
23
+ export const BACKFILL_RETRY_MS = 30 * 60_000;
24
+ export const BACKFILL_DAYS = 180;
18
25
  /** At the plan's limit: take nothing, keep the heartbeat. */
19
26
  export const LIMITED_POLL_MS = 60_000;
27
+ /** A limit whose message gives no reset time is tried again after this. */
20
28
  export const LIMIT_WAIT_MS = 30 * 60_000;
29
+ /** Signed out, or the model down: look again after this (GET-1303). */
30
+ export const RECHECK_MS = 5 * 60_000;
21
31
  const RETRIES = 2;
22
32
  const ERROR_BACKOFF_MS = 30_000;
23
33
 
@@ -34,26 +44,41 @@ export function readingLine(reading) {
34
44
  return `${what}.`;
35
45
  }
36
46
 
47
+ /** What the log says when reading stops for a while, by why. Pure. */
48
+ export function pauseLine(agent, reason, until, now = Date.now()) {
49
+ const name = agent === "codex" ? "Codex" : "Claude";
50
+ const at = new Date(until).toLocaleString([], until - now > 20 * 3_600_000
51
+ ? { weekday: "short", hour: "2-digit", minute: "2-digit" } : { hour: "2-digit", minute: "2-digit" });
52
+ if (reason === "signed_out") return `You're signed out of ${name}. Run \`${agent === "codex" ? "codex login" : "claude"}\` and sign in; reading carries on by itself. Nothing is lost meanwhile.`;
53
+ if (reason === "outage") return `${name} isn't answering right now. Trying again at ${at}. Nothing is lost meanwhile.`;
54
+ return `Your ${name} plan hit its limit. Reading carries on at ${at}, when it resets. Nothing is lost meanwhile.`;
55
+ }
56
+
37
57
  /**
38
58
  * The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
39
59
  * `stop()` → true ends it (tests; the real one runs until the process is stopped).
40
60
  */
41
61
  export async function runRunner({ api, agent, host = hostname(), version = null, run, sleep, log, now = Date.now,
42
62
  workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
43
- let limitedUntil = 0, answered = 0, lastLine = null, lastError = null, toldBackfill = false;
63
+ let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, backfillAt = -Infinity;
44
64
  const answerOne = async (call, models) => {
45
65
  for (let attempt = 0; ; attempt++) {
46
66
  try {
47
67
  const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
48
68
  await api.answer(call.id, { answer: text, model: models[agent].model, usage });
49
69
  answered++;
70
+ if (limitReason) { log(limitReason === "signed_out" ? "Signed in again. Reading." : "Reading again."); limitReason = null; }
50
71
  return;
51
72
  } catch (err) {
52
- // The plan's limit: drop the call (its lease runs out and it comes back) and stop taking
53
- // more until the limit lifts. The heartbeat keeps going, so the app shows why.
54
- if (isPlanLimit(err.message)) {
55
- if (now() >= limitedUntil) log(`Your ${agent} plan hit its limit. Reading waits ${Math.round(limitWaitMs / 60000)} minutes, then carries on by itself.`);
56
- limitedUntil = Math.max(limitedUntil, now() + limitWaitMs);
73
+ // The plan's limit, a sign-out or an outage (GET-1303): none of them is the call's fault, so
74
+ // it is dropped, not failed (its lease runs out and it comes back), and nothing more is taken
75
+ // until it passes. The heartbeat keeps going, so the app shows why.
76
+ const reason = troubleOf(err.message);
77
+ if (reason) {
78
+ const until = reason === "limit" ? (resetAt(err.message, now()) ?? now() + limitWaitMs) : now() + RECHECK_MS;
79
+ if (now() >= limitedUntil || reason !== limitReason) log(pauseLine(agent, reason, until, now()));
80
+ limitedUntil = Math.max(limitedUntil, until);
81
+ limitReason = reason;
57
82
  return;
58
83
  }
59
84
  if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }).catch(() => {}); return; }
@@ -62,9 +87,11 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
62
87
  };
63
88
  while (!stop()) {
64
89
  let page;
90
+ const asked = now();
65
91
  try {
66
- page = await api.poll({ agent, host, version, answered, limit: workers,
67
- limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null });
92
+ page = await api.poll({ agent, host, version, answered, limit: workers, wait_ms: WAIT_MS,
93
+ limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null,
94
+ limit_reason: limitedUntil > now() ? limitReason : null });
68
95
  if (lastError) { log("Back online."); lastError = null; }
69
96
  } catch (err) {
70
97
  if (err.message !== lastError) log(`Waiting on Ownable: ${err.message}. Trying again every ${ERROR_BACKOFF_MS / 1000} seconds.`);
@@ -72,15 +99,27 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
72
99
  await sleep(ERROR_BACKOFF_MS);
73
100
  continue;
74
101
  }
75
- if (page.needs_backfill && !toldBackfill) {
76
- toldBackfill = true;
77
- log("Your history hasn't been read yet. Run `npx @opennous/cli@latest backfill` once; after that this keeps it current.");
102
+ // One command sets everything up (GET-1307): with no history read yet, the runner starts that
103
+ // read itself, on this plan, and answers its calls like any other.
104
+ if (page.needs_backfill && api.startBackfill && now() - backfillAt >= BACKFILL_RETRY_MS) {
105
+ backfillAt = now();
106
+ try {
107
+ await api.startBackfill({ days: BACKFILL_DAYS, confirm: "yes" });
108
+ log(`Reading your last ${BACKFILL_DAYS / 30} months of calls and email on your plan. This can take a while; everything new is read after.`);
109
+ } catch (err) {
110
+ log(`Your history read can't start yet (${err.message}). Trying again in ${BACKFILL_RETRY_MS / 60000} minutes.`);
111
+ }
78
112
  }
79
113
  const line = readingLine(page.reading);
80
114
  if (line && line !== lastLine) { log(line); }
81
115
  lastLine = line;
82
116
  const calls = page.calls ?? [];
83
- if (!calls.length) { await sleep(limitedUntil > now() ? LIMITED_POLL_MS : page.reading ? BUSY_MS : IDLE_MS); continue; }
117
+ if (!calls.length) {
118
+ if (limitedUntil > now()) { await sleep(LIMITED_POLL_MS); continue; }
119
+ // The server held the poll open: ask again at once. One that answered at once: wait a little.
120
+ if (now() - asked < 5000) await sleep(page.reading ? BUSY_MS : IDLE_MS);
121
+ continue;
122
+ }
84
123
  await Promise.all(calls.map((c) => answerOne(c, page.models)));
85
124
  }
86
125
  return { answered };