@opennous/cli 0.17.0 → 0.17.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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/backfill.js +66 -5
- package/src/runner.js +27 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Fixed: a sign-out or an outage no longer fails your reads
|
|
6
|
+
|
|
7
|
+
Signed out of Claude or Codex, or the model not answering: `runner` and `backfill` now wait instead
|
|
8
|
+
of marking every conversation unreadable, and tell you what to run. At your plan's limit they wait
|
|
9
|
+
until the reset time the limit message names, not a fixed 30 minutes (GET-1303).
|
|
10
|
+
|
|
3
11
|
## 0.17.0
|
|
4
12
|
|
|
5
13
|
### 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 →
|
|
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
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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/runner.js
CHANGED
|
@@ -8,7 +8,7 @@ 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,
|
|
11
|
+
import { commandFor, parseAnswer, troubleOf, resetAt } from "./backfill.js";
|
|
12
12
|
|
|
13
13
|
export const WORKERS = 3;
|
|
14
14
|
/** Busy: there is a read going, so ask again soon. */
|
|
@@ -17,7 +17,10 @@ export const BUSY_MS = 2000;
|
|
|
17
17
|
export const IDLE_MS = 20_000;
|
|
18
18
|
/** At the plan's limit: take nothing, keep the heartbeat. */
|
|
19
19
|
export const LIMITED_POLL_MS = 60_000;
|
|
20
|
+
/** A limit whose message gives no reset time is tried again after this. */
|
|
20
21
|
export const LIMIT_WAIT_MS = 30 * 60_000;
|
|
22
|
+
/** Signed out, or the model down: look again after this (GET-1303). */
|
|
23
|
+
export const RECHECK_MS = 5 * 60_000;
|
|
21
24
|
const RETRIES = 2;
|
|
22
25
|
const ERROR_BACKOFF_MS = 30_000;
|
|
23
26
|
|
|
@@ -34,26 +37,41 @@ export function readingLine(reading) {
|
|
|
34
37
|
return `${what}.`;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** What the log says when reading stops for a while, by why. Pure. */
|
|
41
|
+
export function pauseLine(agent, reason, until, now = Date.now()) {
|
|
42
|
+
const name = agent === "codex" ? "Codex" : "Claude";
|
|
43
|
+
const at = new Date(until).toLocaleString([], until - now > 20 * 3_600_000
|
|
44
|
+
? { weekday: "short", hour: "2-digit", minute: "2-digit" } : { hour: "2-digit", minute: "2-digit" });
|
|
45
|
+
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.`;
|
|
46
|
+
if (reason === "outage") return `${name} isn't answering right now. Trying again at ${at}. Nothing is lost meanwhile.`;
|
|
47
|
+
return `Your ${name} plan hit its limit. Reading carries on at ${at}, when it resets. Nothing is lost meanwhile.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
|
|
39
52
|
* `stop()` → true ends it (tests; the real one runs until the process is stopped).
|
|
40
53
|
*/
|
|
41
54
|
export async function runRunner({ api, agent, host = hostname(), version = null, run, sleep, log, now = Date.now,
|
|
42
55
|
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
|
|
43
|
-
let limitedUntil = 0, answered = 0, lastLine = null, lastError = null, toldBackfill = false;
|
|
56
|
+
let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, toldBackfill = false;
|
|
44
57
|
const answerOne = async (call, models) => {
|
|
45
58
|
for (let attempt = 0; ; attempt++) {
|
|
46
59
|
try {
|
|
47
60
|
const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
|
|
48
61
|
await api.answer(call.id, { answer: text, model: models[agent].model, usage });
|
|
49
62
|
answered++;
|
|
63
|
+
if (limitReason) { log(limitReason === "signed_out" ? "Signed in again. Reading." : "Reading again."); limitReason = null; }
|
|
50
64
|
return;
|
|
51
65
|
} catch (err) {
|
|
52
|
-
// The plan's limit
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
// The plan's limit, a sign-out or an outage (GET-1303): none of them is the call's fault, so
|
|
67
|
+
// it is dropped, not failed (its lease runs out and it comes back), and nothing more is taken
|
|
68
|
+
// until it passes. The heartbeat keeps going, so the app shows why.
|
|
69
|
+
const reason = troubleOf(err.message);
|
|
70
|
+
if (reason) {
|
|
71
|
+
const until = reason === "limit" ? (resetAt(err.message, now()) ?? now() + limitWaitMs) : now() + RECHECK_MS;
|
|
72
|
+
if (now() >= limitedUntil || reason !== limitReason) log(pauseLine(agent, reason, until, now()));
|
|
73
|
+
limitedUntil = Math.max(limitedUntil, until);
|
|
74
|
+
limitReason = reason;
|
|
57
75
|
return;
|
|
58
76
|
}
|
|
59
77
|
if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }).catch(() => {}); return; }
|
|
@@ -64,7 +82,8 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
64
82
|
let page;
|
|
65
83
|
try {
|
|
66
84
|
page = await api.poll({ agent, host, version, answered, limit: workers,
|
|
67
|
-
limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null
|
|
85
|
+
limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null,
|
|
86
|
+
limit_reason: limitedUntil > now() ? limitReason : null });
|
|
68
87
|
if (lastError) { log("Back online."); lastError = null; }
|
|
69
88
|
} catch (err) {
|
|
70
89
|
if (err.message !== lastError) log(`Waiting on Ownable: ${err.message}. Trying again every ${ERROR_BACKOFF_MS / 1000} seconds.`);
|