@inetafrica/open-claudia 3.0.0 → 3.0.2
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 +16 -0
- package/bot.js +15 -0
- package/channels/telegram/adapter.js +75 -10
- package/channels/telegram/format.js +23 -1
- package/core/actions.js +6 -0
- package/core/dream.js +32 -8
- package/core/handlers.js +40 -8
- package/core/providers/claude-events.js +4 -0
- package/core/providers/codex-events.js +4 -0
- package/core/providers/codex.js +4 -4
- package/core/providers/events.js +1 -0
- package/core/runner.js +174 -7
- package/core/single-instance.js +46 -0
- package/package.json +2 -2
- package/test-cursor-removal.js +5 -2
- package/test-delivery-contract.js +28 -0
- package/test-fixtures/fake-agent-cli.js +32 -0
- package/test-provider-capabilities.js +2 -2
- package/test-provider-codex.js +2 -2
- package/test-provider-events.js +30 -0
- package/test-provider-language.js +4 -3
- package/test-telegram-poll-recovery.js +37 -0
- package/test-usage-accounting.js +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.2 — honest context numbers, dream summaries that arrive
|
|
4
|
+
|
|
5
|
+
- **The context warning counts the window again, not the bill.** The v3.0.0 provider rewrite computed the live context number from the provider's *cumulative* turn usage — every tool round-trip re-counted the cached prompt prefix, so a normal turn read as 347k+ "context" (crossing 1M on busy turns), spooked the usage alert, and could prematurely force-compact sessions whose real window was fine. The number now tracks the **peak single API call** in the turn (`peakContextTokens` over per-call usage events) — true window occupancy, the same semantics v2 reported (~160k on a heavy turn). Billing is untouched: ledger records keep the cumulative figure in `billedContextTokens`, and API-reported spend was never affected (v3 bills the same or less than v2 — the display was wrong, not the meter). The usage alert says "Context this turn" whenever the peak is available.
|
|
6
|
+
- **Nightly dream summaries reach the chat again.** Dreams ran every night at 04:00 — and for over a week every summary died with Telegram's `400: message is too long` (the report outgrew the 4,096-char hard cap and the adapter had no chunking), so consolidation happened invisibly. Two fixes: the Telegram adapter now splits any oversized message on paragraph → line → word boundaries and sends the chunks in order (reply anchor on the first, buttons on the last, per-chunk HTML→plain fallback preserved), and the dream's tool-health note caps long lists (unused tools, drifted docs) to a short digest in chat while the full lists still land in the on-disk dream log (`~/.open-claudia/dreams/<date>.md`).
|
|
7
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. New hermetic assertions: peak-vs-cumulative derivation in `test-usage-accounting.js`, chunk-size and content-preservation in `test-delivery-contract.js`.
|
|
8
|
+
|
|
9
|
+
## v3.0.1 — streaming restored, /stop queue survival, boot resilience
|
|
10
|
+
|
|
11
|
+
- **Live streaming is back, and the final message stays clean.** The v3.0.0 provider rewrite dropped the in-chat streaming feel: no live progress while a run worked, and the settled reply concatenated every text block into one blob. Now an active run edits a throttled preview message in place with the agent's narration (and its thinking, when `/verbose` is on — new command + button toggle), and when the run settles only the **last** text segment is delivered as the final answer; earlier pre-tool text is preserved as narration instead of being glued onto the reply. Providers emit a normalized `thinking` event (Claude thinking blocks, Codex reasoning items) that feeds the preview but can never leak into settled output. On success the preview becomes the narration record (or is deleted when there was none); on failure or cancel the partial preview is kept so you can see how far the run got. Preview edits are channel-guarded, so a bubble minted on one channel is never edited from another.
|
|
12
|
+
- **`/stop` no longer throws away your queued messages.** Cancelling killed the current turn *and* silently emptied the message queue — every turn you'd typed behind the running one vanished. `/stop` now cancels only the current turn (including a pre-spawn abort checkpoint for runs still building their prompt), keeps the queue intact, and tells you how many queued messages will run next. Cancelled runs unwind quietly instead of delivering an error-shaped reply.
|
|
13
|
+
- **The Codex model menu now offers the GPT-5.6 generation.** The retired `gpt-5.1-codex` trio is replaced by `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna`; effort tiers map low→luna and medium→terra so utility work stays cheap, with sol as the default.
|
|
14
|
+
- **One 409 doesn't kill the bot anymore.** A single Telegram 409 Conflict (another poller on the same token — usually a ghost poll from a restarting predecessor) used to `exit(1)` immediately, which is how one stray poll became a restart storm. The poller now rides out a conflict streak: pause 15s and retry, clear the streak after 45s of quiet, and only exit if the conflict persists beyond 2 minutes (a real second instance). The watchdog defers restarts during a streak.
|
|
15
|
+
- **A single-instance lock refuses double-boots.** Boot now claims `bot.lock` in the config dir (pid, start time, entrypoint); a second bot pointed at the same config refuses to start while the holder is alive, claims a stale or corrupt lock safely, and release never deletes a successor's lock. Combined with the 409 grace this makes accidental duplicate launches self-identifying and harmless.
|
|
16
|
+
- **Startup polling stalls recover on their own.** A Telegram long-poll that went silent right after boot (dead socket, no error) is now detected and restarted instead of leaving a deaf bot.
|
|
17
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. New hermetic tests (`test-streaming-split.js`, `test-telegram-409-grace.js`, `test-single-instance.js`, `test-telegram-poll-recovery.js`) wired into `npm test`; CI test commands now carry the fake-agent env explicitly so the suite is hermetic on runners with no provider CLIs.
|
|
18
|
+
|
|
3
19
|
## v3.0.0 — provider parity and Cursor removal
|
|
4
20
|
|
|
5
21
|
- **The runtime is now a provider-agnostic coding-agent harness.** Claude Code and OpenAI Codex implement one registry contract for prompts, immutable run admission, normalized events, native sessions, capability reporting, utility work, and pre-tool safety policy. Setup, web configuration, status, and doctor work with either, both, or neither provider installed; only model turns require a compatible authenticated provider.
|
package/bot.js
CHANGED
|
@@ -16,6 +16,21 @@ const {
|
|
|
16
16
|
ensureRuntimeDirectories,
|
|
17
17
|
} = require("./core/config");
|
|
18
18
|
ensureRuntimeDirectories();
|
|
19
|
+
|
|
20
|
+
// Refuse to double-boot: a second process on this CONFIG_DIR shares the same
|
|
21
|
+
// channel tokens and 409-wars the running service (see core/single-instance).
|
|
22
|
+
const { acquireSingleInstanceLock } = require("./core/single-instance");
|
|
23
|
+
const instanceLock = acquireSingleInstanceLock({ configDir: CONFIG_DIR });
|
|
24
|
+
if (!instanceLock.acquired) {
|
|
25
|
+
const h = instanceLock.holder;
|
|
26
|
+
console.error(
|
|
27
|
+
`Another Open Claudia instance already owns ${CONFIG_DIR} ` +
|
|
28
|
+
`(pid ${h.pid}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
|
|
29
|
+
"Stop it first (launchctl stop com.claude-telegram-bot), or give a dev run its own OPEN_CLAUDIA_CONFIG_DIR."
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
const { defaultMigrationSources, ensureMigrationSnapshot } = require("./core/migration-backup");
|
|
20
35
|
const migrationSources = defaultMigrationSources({
|
|
21
36
|
configDir: CONFIG_DIR,
|
|
@@ -10,7 +10,7 @@ const TelegramBot = require("node-telegram-bot-api");
|
|
|
10
10
|
const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
|
|
11
11
|
const { canonicalForChannel } = require("../../core/identity");
|
|
12
12
|
const { portableToInlineKeyboard } = require("../types");
|
|
13
|
-
const { telegramHtml, htmlToPlain } = require("./format");
|
|
13
|
+
const { telegramHtml, htmlToPlain, splitMessage } = require("./format");
|
|
14
14
|
|
|
15
15
|
class TelegramAdapter {
|
|
16
16
|
constructor({ id = "telegram", token, ownerChatId, chatIds }) {
|
|
@@ -36,6 +36,11 @@ class TelegramAdapter {
|
|
|
36
36
|
this._wedgedSince = 0; // start of the current unrecovered error streak
|
|
37
37
|
this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
|
|
38
38
|
this._healBackoff = 0; // grows per consecutive heal, capped
|
|
39
|
+
this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
|
|
40
|
+
this._pollStartedAt = 0;
|
|
41
|
+
this._conflictSince = 0; // start of the current 409 streak
|
|
42
|
+
this._conflictTimer = null; // pending paused retry after a 409
|
|
43
|
+
this._conflictClearTimer = null; // quiet after a retry ⇒ streak over
|
|
39
44
|
this._wireInbound();
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -53,12 +58,33 @@ class TelegramAdapter {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
async start() {
|
|
56
|
-
|
|
61
|
+
// node-telegram-bot-api resolves startPolling() only after the current
|
|
62
|
+
// getUpdates request settles. Awaiting it can therefore pin the entire bot
|
|
63
|
+
// boot sequence behind a half-open first poll. Start the loop, return so the
|
|
64
|
+
// remaining adapters can boot, and let the independent watchdog recover a
|
|
65
|
+
// poll that never completes and emits no polling_error.
|
|
66
|
+
this._pollStartedAt = Date.now();
|
|
67
|
+
Promise.resolve(this.bot.startPolling()).catch((error) => {
|
|
68
|
+
this._onPollingHiccup(error?.message || "Telegram polling failed to start");
|
|
69
|
+
});
|
|
70
|
+
if (!this._pollWatchdogTimer) {
|
|
71
|
+
this._pollWatchdogTimer = setInterval(() => this._checkPollingHealth(), 15000);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_checkPollingHealth() {
|
|
76
|
+
if (this._conflictTimer || this._conflictSince) return; // 409 backoff owns recovery
|
|
77
|
+
const lastCompletedPoll = this.bot?._polling?._lastUpdate || this._pollStartedAt;
|
|
78
|
+
if (!lastCompletedPoll || Date.now() - lastCompletedPoll <= 75000) return;
|
|
79
|
+
this._onPollingHiccup("poll watchdog: no completed getUpdates request in 75s");
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
async stop() {
|
|
83
|
+
if (this._pollWatchdogTimer) { clearInterval(this._pollWatchdogTimer); this._pollWatchdogTimer = null; }
|
|
60
84
|
if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
|
|
61
85
|
if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
|
|
86
|
+
if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
|
|
87
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
62
88
|
try { await this.bot.stopPolling(); } catch (e) {}
|
|
63
89
|
}
|
|
64
90
|
|
|
@@ -116,13 +142,42 @@ class TelegramAdapter {
|
|
|
116
142
|
}, 30000);
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
// Telegram 409s a getUpdates call while ANOTHER one holds the token. Two
|
|
146
|
+
// causes: a ghost long-poll left by an uncleanly-killed predecessor (clears
|
|
147
|
+
// within its ≤30s poll timeout), or a genuinely running second instance.
|
|
148
|
+
// Exiting on the first 409 turned the ghost case into a KeepAlive respawn
|
|
149
|
+
// storm (7 boot→409→die cycles on 2026-07-12). So: pause, retry inside a
|
|
150
|
+
// grace window, and exit only if the conflict outlives it — that really is
|
|
151
|
+
// a second instance, and the supervisor's throttled respawns pace the rest.
|
|
152
|
+
_on409Conflict() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (!this._conflictSince) this._conflictSince = now;
|
|
155
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
156
|
+
if (now - this._conflictSince > 120000) {
|
|
157
|
+
console.error("Another instance is polling (409 persisted >2min). Exiting.");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (this._conflictTimer) return; // a paused retry is already queued
|
|
161
|
+
console.error("409 Conflict: another poller holds this token — pausing 15s (ghost polls clear in <1min; exiting only if this persists >2min).");
|
|
162
|
+
// Stop hammering now; the timer below dials back in.
|
|
163
|
+
Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
|
|
164
|
+
this._conflictTimer = setTimeout(async () => {
|
|
165
|
+
this._conflictTimer = null;
|
|
166
|
+
try { await this.bot.startPolling(); } catch (e) {}
|
|
167
|
+
// No further 409 for 45s ⇒ the other poller is gone; end the streak.
|
|
168
|
+
this._conflictClearTimer = setTimeout(() => {
|
|
169
|
+
this._conflictClearTimer = null;
|
|
170
|
+
this._conflictSince = 0;
|
|
171
|
+
}, 45000);
|
|
172
|
+
}, 15000);
|
|
173
|
+
}
|
|
174
|
+
|
|
119
175
|
_wireInbound() {
|
|
120
176
|
this.bot.on("polling_error", (err) => {
|
|
121
177
|
const msg = err.message || "";
|
|
122
178
|
if (msg.includes("409 Conflict")) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
process.exit(1);
|
|
179
|
+
this._on409Conflict();
|
|
180
|
+
return;
|
|
126
181
|
}
|
|
127
182
|
if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
|
|
128
183
|
this._onPollingHiccup(msg); // transient — heal quietly, never exit
|
|
@@ -261,13 +316,23 @@ class TelegramAdapter {
|
|
|
261
316
|
}
|
|
262
317
|
|
|
263
318
|
async send(channelId, text, opts = {}) {
|
|
264
|
-
const
|
|
265
|
-
let body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
|
|
266
|
-
if (opts.parseMode) o.parse_mode = opts.parseMode;
|
|
319
|
+
const body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
|
|
267
320
|
const kb = this._normalizeKeyboard(opts.keyboard);
|
|
268
|
-
|
|
269
|
-
|
|
321
|
+
const chunks = splitMessage(body);
|
|
322
|
+
let lastId = null;
|
|
323
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
324
|
+
const o = {};
|
|
325
|
+
if (opts.parseMode) o.parse_mode = opts.parseMode;
|
|
326
|
+
// Reply anchor goes on the first chunk; buttons belong on the last.
|
|
327
|
+
if (opts.replyTo && i === 0) o.reply_to_message_id = opts.replyTo;
|
|
328
|
+
if (kb && i === chunks.length - 1) o.reply_markup = kb;
|
|
329
|
+
const id = await this._sendChunk(channelId, chunks[i], o, opts);
|
|
330
|
+
if (id !== null) lastId = id;
|
|
331
|
+
}
|
|
332
|
+
return lastId;
|
|
333
|
+
}
|
|
270
334
|
|
|
335
|
+
async _sendChunk(channelId, body, o, opts = {}) {
|
|
271
336
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
272
337
|
try {
|
|
273
338
|
const msg = await this.bot.sendMessage(channelId, body, o);
|
|
@@ -139,4 +139,26 @@ function htmlToPlain(html) {
|
|
|
139
139
|
.replace(/&/g, "&");
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// Telegram rejects messages over 4096 chars outright ("message is too long"),
|
|
143
|
+
// which silently killed long sends like the nightly dream summary. Split on
|
|
144
|
+
// paragraph > line > word boundaries, with margin for entity accounting.
|
|
145
|
+
function splitMessage(text, limit = 4000) {
|
|
146
|
+
const body = String(text ?? "");
|
|
147
|
+
if (body.length <= limit) return [body];
|
|
148
|
+
const chunks = [];
|
|
149
|
+
let rest = body;
|
|
150
|
+
while (rest.length > limit) {
|
|
151
|
+
const window = rest.slice(0, limit);
|
|
152
|
+
let cut = window.lastIndexOf("\n\n");
|
|
153
|
+
if (cut < limit * 0.5) cut = window.lastIndexOf("\n");
|
|
154
|
+
if (cut < limit * 0.5) cut = window.lastIndexOf(" ");
|
|
155
|
+
if (cut <= 0) cut = limit;
|
|
156
|
+
const chunk = rest.slice(0, cut).trimEnd();
|
|
157
|
+
if (chunk) chunks.push(chunk);
|
|
158
|
+
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
159
|
+
}
|
|
160
|
+
if (rest) chunks.push(rest);
|
|
161
|
+
return chunks.length ? chunks : [""];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { escapeHtml, stripMarkdown, telegramHtml, htmlToPlain, splitMessage };
|
package/core/actions.js
CHANGED
|
@@ -516,6 +516,12 @@ async function handleAction(envelope) {
|
|
|
516
516
|
await send(`Tool trace: ${state.settings.showToolTrace ? "on" : "off"}`);
|
|
517
517
|
return;
|
|
518
518
|
}
|
|
519
|
+
if (d.startsWith("vb:")) {
|
|
520
|
+
state.settings.showThinking = d.slice(3) === "on";
|
|
521
|
+
saveState();
|
|
522
|
+
await send(`Verbose thinking: ${state.settings.showThinking ? "on" : "off"}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
519
525
|
if (d.startsWith("cw:")) {
|
|
520
526
|
const v = d.slice(3);
|
|
521
527
|
if (v === "default") state.settings.compactWindow = null;
|
package/core/dream.js
CHANGED
|
@@ -1246,17 +1246,29 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1246
1246
|
// Read-only toward the tools themselves — a tool is never auto-deleted (that's
|
|
1247
1247
|
// destructive); we just surface the breakage for the user to re-link or remove.
|
|
1248
1248
|
let toolNote = "";
|
|
1249
|
+
let toolNoteFull = "";
|
|
1249
1250
|
try {
|
|
1250
1251
|
const toolsLib = require("./tools");
|
|
1251
1252
|
const tg = require("./tool-graph").tend(toolsLib, { packsLib: packs });
|
|
1253
|
+
// Two variants: `bits` keeps full lists for the on-disk dream log;
|
|
1254
|
+
// `chatBits` caps the long ones so the chat digest stays readable.
|
|
1252
1255
|
const bits = [];
|
|
1256
|
+
const chatBits = [];
|
|
1257
|
+
const push = (full, chat = full) => { bits.push(full); chatBits.push(chat); };
|
|
1258
|
+
const capList = (items, sep = ", ", max = 5) => (items.length <= max
|
|
1259
|
+
? items.join(sep)
|
|
1260
|
+
: `${items.slice(0, max).join(sep)} …and ${items.length - max} more (full list in the dream log)`);
|
|
1253
1261
|
if (tg.edges || tg.decayed || tg.pruned) {
|
|
1254
|
-
|
|
1262
|
+
push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
|
|
1255
1263
|
}
|
|
1256
1264
|
const allTools = toolsLib.listTools();
|
|
1257
1265
|
const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
|
|
1258
1266
|
if (dangling.length) {
|
|
1259
|
-
|
|
1267
|
+
const names = dangling.map((t) => `${t.name}→${t.pack}`);
|
|
1268
|
+
push(
|
|
1269
|
+
`🔗 ${dangling.length} tool(s) link a missing skill pack: ${names.join(", ")} — re-link with --pack or remove.`,
|
|
1270
|
+
`🔗 ${dangling.length} tool(s) link a missing skill pack: ${capList(names)} — re-link with --pack or remove.`,
|
|
1271
|
+
);
|
|
1260
1272
|
}
|
|
1261
1273
|
// Zero-run tools: created but never run and gone cold. Telemetry runCount
|
|
1262
1274
|
// is authoritative; createdAt measures "how long since it was made" (a body
|
|
@@ -1271,7 +1283,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1271
1283
|
try { return fs.statSync(t.file).mtimeMs < cutoff; } catch (e) { return false; }
|
|
1272
1284
|
});
|
|
1273
1285
|
if (coldUnused.length) {
|
|
1274
|
-
|
|
1286
|
+
const names = coldUnused.map((t) => t.name);
|
|
1287
|
+
push(
|
|
1288
|
+
`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${names.join(", ")} — keep, fold into another, or remove.`,
|
|
1289
|
+
`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${capList(names)} — keep, fold into another, or remove.`,
|
|
1290
|
+
);
|
|
1275
1291
|
}
|
|
1276
1292
|
// Near-duplicate pairs: two tools whose name+description overlap strongly,
|
|
1277
1293
|
// a sign one should have been a subcommand of the other. Dedupe by sorted
|
|
@@ -1284,7 +1300,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1284
1300
|
for (const m of near) dupPairs.add([t.name, m.name].sort().join(" ↔ "));
|
|
1285
1301
|
}
|
|
1286
1302
|
if (dupPairs.size) {
|
|
1287
|
-
|
|
1303
|
+
const pairs = [...dupPairs];
|
|
1304
|
+
push(
|
|
1305
|
+
`👯 ${pairs.length} near-duplicate tool pair(s): ${pairs.join(", ")} — consider merging into one tool with subcommands.`,
|
|
1306
|
+
`👯 ${pairs.length} near-duplicate tool pair(s): ${capList(pairs)} — consider merging into one tool with subcommands.`,
|
|
1307
|
+
);
|
|
1288
1308
|
}
|
|
1289
1309
|
// Doc/code drift: source edited after TOOL.md, or verbs in real use that
|
|
1290
1310
|
// the docs never mention. Flag only — the fix is a tool note.
|
|
@@ -1292,7 +1312,10 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1292
1312
|
.map((t) => { const d = toolsLib.docDrift(t); return d ? `${t.name} (${d})` : null; })
|
|
1293
1313
|
.filter(Boolean);
|
|
1294
1314
|
if (drifted.length) {
|
|
1295
|
-
|
|
1315
|
+
push(
|
|
1316
|
+
`📝 ${drifted.length} tool(s) with doc/code drift: ${drifted.join("; ")} — refresh the docs with tool note.`,
|
|
1317
|
+
`📝 ${drifted.length} tool(s) with doc/code drift: ${capList(drifted, "; ")} — refresh the docs with tool note.`,
|
|
1318
|
+
);
|
|
1296
1319
|
}
|
|
1297
1320
|
// 5c usage KPI: what fraction of operational actions went through the
|
|
1298
1321
|
// authorised channel, computed from telemetry stamps (state.json run
|
|
@@ -1319,10 +1342,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1319
1342
|
}
|
|
1320
1343
|
if (g.denies.length) kpiBits.push(`${g.denies.length} denied by the gate`);
|
|
1321
1344
|
if (r.failures) kpiBits.push(`${r.failures} failed run(s) at use: ${r.failedTools.join(", ")}`);
|
|
1322
|
-
|
|
1345
|
+
push(`📈 Tool usage since last dream: ${kpiBits.join(" · ")}.`);
|
|
1323
1346
|
}
|
|
1324
1347
|
} catch (e) { /* KPI is best-effort */ }
|
|
1325
|
-
|
|
1348
|
+
toolNoteFull = bits.join("\n");
|
|
1349
|
+
toolNote = chatBits.join("\n");
|
|
1326
1350
|
} catch (e) { /* tool graph is best-effort (old node) */ }
|
|
1327
1351
|
|
|
1328
1352
|
const staleNote = staleTaskReport();
|
|
@@ -1396,7 +1420,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1396
1420
|
trigger,
|
|
1397
1421
|
consolidation: { report: decision.report, lines: consolidationLines },
|
|
1398
1422
|
introspection: { report: introReport, lines: introApplied, proposed: introProposed },
|
|
1399
|
-
healthNote, graphNote, episodicNote, toolNote, staleNote, backupRoot,
|
|
1423
|
+
healthNote, graphNote, episodicNote, toolNote: toolNoteFull || toolNote, staleNote, backupRoot,
|
|
1400
1424
|
});
|
|
1401
1425
|
|
|
1402
1426
|
// Richer chat summary: consolidation + introspection narrative + what
|
package/core/handlers.js
CHANGED
|
@@ -371,7 +371,7 @@ register({
|
|
|
371
371
|
if (!authorized(env)) return;
|
|
372
372
|
send([
|
|
373
373
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
374
|
-
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode",
|
|
374
|
+
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode /verbose",
|
|
375
375
|
"Identity: /whoami /link",
|
|
376
376
|
"Team: /people /intros /auth (owner)",
|
|
377
377
|
"Automation: /cron /vault /soul /dreamsummary",
|
|
@@ -1077,6 +1077,29 @@ register({
|
|
|
1077
1077
|
},
|
|
1078
1078
|
});
|
|
1079
1079
|
|
|
1080
|
+
register({
|
|
1081
|
+
name: "verbose", description: "Stream thinking into the live preview (on/off)", args: "[on|off]",
|
|
1082
|
+
handler: async (env, { tail }) => {
|
|
1083
|
+
if (!authorized(env)) return;
|
|
1084
|
+
const { settings } = currentState();
|
|
1085
|
+
if (tail) {
|
|
1086
|
+
const v = tail.trim().toLowerCase();
|
|
1087
|
+
if (v === "on" || v === "true") settings.showThinking = true;
|
|
1088
|
+
else if (v === "off" || v === "false") settings.showThinking = false;
|
|
1089
|
+
else return send(`Usage: /verbose [on|off]. Currently ${settings.showThinking ? "on" : "off"}.`);
|
|
1090
|
+
saveState();
|
|
1091
|
+
return send(`Verbose thinking: ${settings.showThinking ? "on" : "off"}`);
|
|
1092
|
+
}
|
|
1093
|
+
send(
|
|
1094
|
+
`Verbose thinking: ${settings.showThinking ? "on" : "off"}\n\n` +
|
|
1095
|
+
"When on, the live status preview also streams my thinking (reasoning) in italics as it happens. Narration and the clean final answer are unaffected. Off by default.",
|
|
1096
|
+
{ keyboard: { inline_keyboard: [
|
|
1097
|
+
[{ text: "On", callback_data: "vb:on" }, { text: "Off", callback_data: "vb:off" }],
|
|
1098
|
+
] } },
|
|
1099
|
+
);
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1080
1103
|
register({
|
|
1081
1104
|
name: "budget", description: "Set max spend for next task", args: "[$N]",
|
|
1082
1105
|
handler: async (env, { tail }) => {
|
|
@@ -1303,25 +1326,34 @@ register({
|
|
|
1303
1326
|
if (!authorized(env)) return;
|
|
1304
1327
|
const state = currentState();
|
|
1305
1328
|
const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
|
|
1329
|
+
// /stop cancels the CURRENT turn only. Messages queued behind it stay
|
|
1330
|
+
// queued — the cancelled run's unwind drains them as usual.
|
|
1331
|
+
const queuedNote = () => {
|
|
1332
|
+
const n = Array.isArray(state.messageQueue) ? state.messageQueue.length : 0;
|
|
1333
|
+
return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
|
|
1334
|
+
};
|
|
1306
1335
|
if (state.runningProcess) {
|
|
1307
1336
|
const pid = state.runningProcess.pid;
|
|
1308
1337
|
const { killProcessTree } = require("./process-tree");
|
|
1338
|
+
// Quiet unwind: deliverForegroundResult consumes this flag to suppress
|
|
1339
|
+
// the "run failed" diagnostic the SIGTERM exit would otherwise emit.
|
|
1340
|
+
state.cancelRequested = true;
|
|
1309
1341
|
killProcessTree(pid, "SIGTERM");
|
|
1310
1342
|
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1311
1343
|
state.runningProcess = null;
|
|
1312
|
-
|
|
1344
|
+
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1345
|
+
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1346
|
+
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1313
1347
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1314
|
-
state.messageQueue = [];
|
|
1315
1348
|
await sideCancellation;
|
|
1316
|
-
await send(
|
|
1349
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1317
1350
|
} else if (state.preparingRun) {
|
|
1318
|
-
// Turn is mid-recall/
|
|
1319
|
-
//
|
|
1351
|
+
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1352
|
+
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1320
1353
|
state.cancelRequested = true;
|
|
1321
|
-
state.messageQueue = [];
|
|
1322
1354
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1323
1355
|
await sideCancellation;
|
|
1324
|
-
await send(
|
|
1356
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1325
1357
|
} else if (await sideCancellation) await send("Cancelled side response.");
|
|
1326
1358
|
else await send("Nothing running.");
|
|
1327
1359
|
},
|
|
@@ -55,6 +55,10 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
55
55
|
if (block?.type === "text" && typeof block.text === "string" && block.text) {
|
|
56
56
|
state.sawText = true;
|
|
57
57
|
events.push({ type: "text_final", text: block.text });
|
|
58
|
+
} else if (block?.type === "thinking" && typeof block.thinking === "string" && block.thinking) {
|
|
59
|
+
// Deliberately does not set sawText: thinking is never answer text,
|
|
60
|
+
// so the result-event fallback semantics stay unchanged.
|
|
61
|
+
events.push({ type: "thinking", text: block.thinking });
|
|
58
62
|
} else if (block?.type === "tool_use" && block.id && block.name) {
|
|
59
63
|
state.tools.set(block.id, block.name);
|
|
60
64
|
events.push({
|
|
@@ -80,6 +80,10 @@ function normalizeCodexEvent(rawEvent, state = createCodexParserState()) {
|
|
|
80
80
|
state.sawText = true;
|
|
81
81
|
return [{ type: "text_final", text: item.text }];
|
|
82
82
|
}
|
|
83
|
+
if (item.type === "reasoning" && typeof item.text === "string" && item.text) {
|
|
84
|
+
// Deliberately does not set sawText: reasoning is never answer text.
|
|
85
|
+
return [{ type: "thinking", text: item.text }];
|
|
86
|
+
}
|
|
83
87
|
const info = toolInfo(item);
|
|
84
88
|
if (!info) return [];
|
|
85
89
|
const exitCode = Number.isInteger(item.exit_code)
|
package/core/providers/codex.js
CHANGED
|
@@ -6,7 +6,7 @@ const { createJsonlDecoder } = require("./events");
|
|
|
6
6
|
const { createCodexParserState, normalizeCodexEvent } = require("./codex-events");
|
|
7
7
|
const { createCodexHookTransport } = require("./codex-hook");
|
|
8
8
|
|
|
9
|
-
const CODEX_MODELS = Object.freeze(["gpt-5.
|
|
9
|
+
const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
|
|
10
10
|
const CODEX_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high", "xhigh"]);
|
|
11
11
|
const REQUIRED_EXEC_HELP_FLAGS = Object.freeze(["--config", "--json", "--sandbox", "--image", "--model", "--skip-git-repo-check"]);
|
|
12
12
|
const REQUIRED_RESUME_HELP_FLAGS = Object.freeze(["--config", "--json", "--image", "--model", "--skip-git-repo-check"]);
|
|
@@ -264,9 +264,9 @@ function createCodexProvider(options = {}) {
|
|
|
264
264
|
},
|
|
265
265
|
nativeToolsNote: "Codex exposes its native exec tools; Open Claudia capability checks and approval policy remain authoritative.",
|
|
266
266
|
tierModel(tier) {
|
|
267
|
-
if (tier === "low") return "gpt-5.
|
|
268
|
-
if (tier === "medium") return "gpt-5.
|
|
269
|
-
return defaultModel || "gpt-5.
|
|
267
|
+
if (tier === "low") return "gpt-5.6-luna";
|
|
268
|
+
if (tier === "medium") return "gpt-5.6-terra";
|
|
269
|
+
return defaultModel || "gpt-5.6-sol";
|
|
270
270
|
},
|
|
271
271
|
};
|
|
272
272
|
|
package/core/providers/events.js
CHANGED
package/core/runner.js
CHANGED
|
@@ -29,7 +29,7 @@ const {
|
|
|
29
29
|
} = require("./run-context");
|
|
30
30
|
const { redactSensitive } = require("./redact");
|
|
31
31
|
const { createSideChatPromptBuilder, sideChatCoordinator } = require("./side-chat");
|
|
32
|
-
const { send, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
|
|
32
|
+
const { send, sendVoice, sendVoiceEnd, splitMessage, editMessage, deleteMessage } = require("./io");
|
|
33
33
|
const { textToVoice, splitSentences, synthSentenceMp3 } = require("./media");
|
|
34
34
|
const { killProcessTree, waitForPidsExit } = require("./process-tree");
|
|
35
35
|
const {
|
|
@@ -84,6 +84,19 @@ function usageParts(usage, backend) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// The provider's final "turn" usage sums every API call's prefix, re-counting
|
|
88
|
+
// the cached prefix once per tool round-trip. True window occupancy is the
|
|
89
|
+
// largest single call, so track the peak across per-call usage events.
|
|
90
|
+
function peakContextTokens(usageEvents, backend) {
|
|
91
|
+
let peak = 0;
|
|
92
|
+
for (const event of usageEvents || []) {
|
|
93
|
+
if (event?.scope !== "provider_call" || !event.usage) continue;
|
|
94
|
+
const context = usageParts(event.usage, backend).context;
|
|
95
|
+
if (context > peak) peak = context;
|
|
96
|
+
}
|
|
97
|
+
return peak > 0 ? peak : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
function clampUsageDelta(current, previous) {
|
|
88
101
|
if (!previous) return current;
|
|
89
102
|
return {
|
|
@@ -192,7 +205,7 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
|
|
|
192
205
|
if (announce && usageOwnerOnly) {
|
|
193
206
|
const baseline = result.trend?.baselineAvgContextTokens || 0;
|
|
194
207
|
const rate = result.trend?.latestRate || 0;
|
|
195
|
-
const contextLabel = usageScope === "turn_delta" ? "Context this turn" : "Session context total";
|
|
208
|
+
const contextLabel = hasPeak || usageScope === "turn_delta" ? "Context this turn" : "Session context total";
|
|
196
209
|
announce([
|
|
197
210
|
"Token usage alert:",
|
|
198
211
|
`${contextLabel}: ${fmtTokens(record.contextTokens)}`,
|
|
@@ -258,6 +271,11 @@ async function executeProviderInvocation(options = {}) {
|
|
|
258
271
|
const tools = [];
|
|
259
272
|
const textDeltas = [];
|
|
260
273
|
const textFinals = [];
|
|
274
|
+
// Text blocks grouped by the tool runs between them. The last segment is
|
|
275
|
+
// the provider's actual answer; everything before it is interim narration
|
|
276
|
+
// ("Let me check X…" written before a tool call). Kept separate so chat
|
|
277
|
+
// delivery can split them while result.text keeps the full transcript.
|
|
278
|
+
const textSegments = [[]];
|
|
261
279
|
let resultText = "";
|
|
262
280
|
let sessionId = runContext?.sessionId || null;
|
|
263
281
|
let terminalSeen = false;
|
|
@@ -327,8 +345,13 @@ async function executeProviderInvocation(options = {}) {
|
|
|
327
345
|
if (!event || typeof event !== "object") return;
|
|
328
346
|
if (event.type === "session" && event.sessionId) sessionId = event.sessionId;
|
|
329
347
|
else if (event.type === "text_delta" && typeof event.text === "string") textDeltas.push(event.text);
|
|
330
|
-
else if (event.type === "text_final" && typeof event.text === "string")
|
|
331
|
-
|
|
348
|
+
else if (event.type === "text_final" && typeof event.text === "string") {
|
|
349
|
+
textFinals.push(event.text);
|
|
350
|
+
textSegments[textSegments.length - 1].push(event.text);
|
|
351
|
+
} else if (event.type === "tool_start" || event.type === "tool_end") {
|
|
352
|
+
if (event.type === "tool_start" && textSegments[textSegments.length - 1].length) textSegments.push([]);
|
|
353
|
+
tools.push(event);
|
|
354
|
+
}
|
|
332
355
|
else if (event.type === "usage") usageEvents.push(event);
|
|
333
356
|
else if (event.type === "result") {
|
|
334
357
|
terminalSeen = true;
|
|
@@ -487,6 +510,9 @@ async function executeProviderInvocation(options = {}) {
|
|
|
487
510
|
finalText: textFinals.join(""),
|
|
488
511
|
deltaText: textDeltas.join(""),
|
|
489
512
|
});
|
|
513
|
+
const segmentTexts = textSegments.map((blocks) => blocks.join("")).filter((t) => t.trim());
|
|
514
|
+
const finalAnswerText = segmentTexts.length ? segmentTexts[segmentTexts.length - 1] : "";
|
|
515
|
+
const narrationText = segmentTexts.slice(0, -1).join("\n\n");
|
|
490
516
|
let failure = null;
|
|
491
517
|
if (spawnError) failure = stableRunError(spawnError.message, "PROVIDER_SPAWN_FAILED");
|
|
492
518
|
else if (timedOut) failure = stableRunError(
|
|
@@ -511,6 +537,8 @@ async function executeProviderInvocation(options = {}) {
|
|
|
511
537
|
sessionId,
|
|
512
538
|
purpose: runContext?.purpose || "foreground",
|
|
513
539
|
text: redactSensitive(String(text || "").trim()),
|
|
540
|
+
finalAnswerText: redactSensitive(String(finalAnswerText || "").trim()),
|
|
541
|
+
narrationText: redactSensitive(String(narrationText || "")),
|
|
514
542
|
usage: finalUsage?.usage || null,
|
|
515
543
|
usageScope: finalUsage?.scope || null,
|
|
516
544
|
usageEvents,
|
|
@@ -631,6 +659,15 @@ function preflightFailureResult(runContext, provider, failure) {
|
|
|
631
659
|
};
|
|
632
660
|
}
|
|
633
661
|
|
|
662
|
+
function cancelledRunResult(runContext, provider) {
|
|
663
|
+
const result = preflightFailureResult(runContext, provider, {
|
|
664
|
+
code: "RUN_CANCELLED",
|
|
665
|
+
message: "Cancelled by /stop before the provider started",
|
|
666
|
+
});
|
|
667
|
+
result.status = "cancelled";
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
|
|
634
671
|
async function settleImmediateRunResult(result, runContext, dependencies, capture) {
|
|
635
672
|
for (const [name, hook] of [
|
|
636
673
|
["transcript", dependencies.persistTranscript],
|
|
@@ -738,6 +775,15 @@ function createRunnerService(dependencies = {}) {
|
|
|
738
775
|
return immediateFailure(provider, error);
|
|
739
776
|
}
|
|
740
777
|
|
|
778
|
+
// Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
|
|
779
|
+
// flag instead of killing a process (there is none yet). Honour it here,
|
|
780
|
+
// after the slow prompt build and before anything spawns. Persistence
|
|
781
|
+
// still settles (the transcript records the cancelled turn); delivery is
|
|
782
|
+
// suppressed downstream via the RUN_CANCELLED code.
|
|
783
|
+
if (dependencies.shouldAbort && dependencies.shouldAbort(runContext, { capture })) {
|
|
784
|
+
return settleImmediateRunResult(cancelledRunResult(runContext, provider), runContext, dependencies, capture);
|
|
785
|
+
}
|
|
786
|
+
|
|
741
787
|
const result = await executeProviderInvocation({
|
|
742
788
|
runContext,
|
|
743
789
|
provider,
|
|
@@ -1387,7 +1433,8 @@ async function persistForegroundUsage(result, runContext, state = currentState()
|
|
|
1387
1433
|
let usage = result.usage;
|
|
1388
1434
|
let usageScope = result.usageScope || "turn_delta";
|
|
1389
1435
|
let rawUsage = usage;
|
|
1390
|
-
let liveContextTokens =
|
|
1436
|
+
let liveContextTokens = peakContextTokens(result.usageEvents, runContext.provider)
|
|
1437
|
+
?? usageParts(usage, runContext.provider).context;
|
|
1391
1438
|
const usageSessionId = result.sessionId || runContext.sessionId;
|
|
1392
1439
|
const usageProject = runContext.project?.name || null;
|
|
1393
1440
|
if (runContext.provider === "codex") {
|
|
@@ -1469,11 +1516,49 @@ async function persistForegroundSession(result, runContext, state = currentState
|
|
|
1469
1516
|
result.sessionAttached = attach;
|
|
1470
1517
|
}
|
|
1471
1518
|
|
|
1519
|
+
// Settle the streamed preview message before the final answer goes out.
|
|
1520
|
+
// Success with narration → the preview becomes the narration record (footer
|
|
1521
|
+
// dropped). Success without narration → the preview only ever showed the
|
|
1522
|
+
// streamed answer itself; delete it so the final message isn't a duplicate.
|
|
1523
|
+
// Failure/cancel → keep the partial text but drop the lying "working" footer.
|
|
1524
|
+
async function finalizeStreamPreview(state, result) {
|
|
1525
|
+
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1526
|
+
const id = state.statusMessageId;
|
|
1527
|
+
const channel = state.statusMessageChannel;
|
|
1528
|
+
const buffer = String(state.streamBuffer || "").trim();
|
|
1529
|
+
state.statusMessageId = null;
|
|
1530
|
+
state.statusMessageChannel = null;
|
|
1531
|
+
state.streamBuffer = "";
|
|
1532
|
+
if (typeof id !== "string") return;
|
|
1533
|
+
// A message id is only valid on the channel that minted it; a foreign-
|
|
1534
|
+
// channel preview is left as an orphan rather than edited cross-transport.
|
|
1535
|
+
if (!channel || String(channel) !== String(currentChannelId())) return;
|
|
1536
|
+
try {
|
|
1537
|
+
if (!result || !result.ok) {
|
|
1538
|
+
if (buffer) await editMessage(id, buffer.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
const narration = String(result.narrationText || "").trim();
|
|
1542
|
+
if (narration) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1543
|
+
else await deleteMessage(id);
|
|
1544
|
+
} catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1472
1547
|
async function deliverForegroundResult(result, runContext, state = currentState()) {
|
|
1473
1548
|
if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
|
|
1549
|
+
await finalizeStreamPreview(state, result);
|
|
1550
|
+
// /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
|
|
1551
|
+
// is not a failure worth announcing — /stop already replied "Cancelled.".
|
|
1552
|
+
// The flag is consumed here (and re-cleared on the run's unwind) so it can
|
|
1553
|
+
// never suppress a later run's genuine failure.
|
|
1554
|
+
if (!result.ok && (state.cancelRequested || result.error?.code === "RUN_CANCELLED")) {
|
|
1555
|
+
state.cancelRequested = false;
|
|
1556
|
+
return { ok: true, suppressed: "cancelled" };
|
|
1557
|
+
}
|
|
1474
1558
|
const diagnostic = providerRunDiagnostic(result, runContext);
|
|
1475
1559
|
if (diagnostic) result.diagnostic = diagnostic;
|
|
1476
|
-
|
|
1560
|
+
const cleanAnswer = String(result.finalAnswerText || "").trim();
|
|
1561
|
+
let finalText = result.ok ? (cleanAnswer || result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
|
|
1477
1562
|
|
|
1478
1563
|
if (result.ok) {
|
|
1479
1564
|
let guarded = false;
|
|
@@ -1596,8 +1681,73 @@ function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
|
1596
1681
|
})();
|
|
1597
1682
|
}
|
|
1598
1683
|
|
|
1684
|
+
const STREAM_PREVIEW_THROTTLE_MS = 2500;
|
|
1685
|
+
const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
|
|
1686
|
+
|
|
1687
|
+
// Edited-in-place interim preview: each completed text block the provider
|
|
1688
|
+
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1689
|
+
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1690
|
+
// settles the message when the run ends.
|
|
1691
|
+
function createStreamPreview({ state, store }) {
|
|
1692
|
+
let enabled = !!store && (store?.adapter?.type || "") !== "voice";
|
|
1693
|
+
if (enabled) {
|
|
1694
|
+
// Interim text is NOT vetted by guardOutboundReply (only the final answer
|
|
1695
|
+
// is), so it must never reach a guarded external speaker. Fail closed if
|
|
1696
|
+
// classification is unavailable.
|
|
1697
|
+
try { enabled = chatContext.run(store, () => !require("./relationship").isCurrentSpeakerGuarded()); }
|
|
1698
|
+
catch (_) { enabled = false; }
|
|
1699
|
+
}
|
|
1700
|
+
const channelId = store?.channelId || null;
|
|
1701
|
+
|
|
1702
|
+
const flush = () => {
|
|
1703
|
+
state.streamInterval = null;
|
|
1704
|
+
if (!enabled || state.cancelRequested) return;
|
|
1705
|
+
const body = String(state.streamBuffer || "").trim();
|
|
1706
|
+
if (!body) return;
|
|
1707
|
+
const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
|
|
1708
|
+
// Event callbacks arrive off the provider's stdout stream, outside the
|
|
1709
|
+
// chat AsyncLocalStorage context — rebind before any send/edit.
|
|
1710
|
+
chatContext.run(store, async () => {
|
|
1711
|
+
try {
|
|
1712
|
+
if (typeof state.statusMessageId === "string" && String(state.statusMessageChannel) === String(channelId)) {
|
|
1713
|
+
await editMessage(state.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (state.statusMessageId === true) return; // sent but not editable — leave the single notice alone
|
|
1717
|
+
const sent = await send(display, telegramHtmlOpts());
|
|
1718
|
+
state.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
|
|
1719
|
+
state.statusMessageChannel = sent.ok ? channelId : null;
|
|
1720
|
+
} catch (_) { /* preview is best-effort */ }
|
|
1721
|
+
}).catch(() => {});
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1724
|
+
const appendChunk = (chunk, runContext) => {
|
|
1725
|
+
if (!enabled || runContext?.purpose !== "foreground" || state.cancelRequested) return;
|
|
1726
|
+
state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
|
|
1727
|
+
if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
|
|
1728
|
+
if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
return {
|
|
1732
|
+
append(text, runContext) {
|
|
1733
|
+
appendChunk(text, runContext);
|
|
1734
|
+
},
|
|
1735
|
+
// Thinking/reasoning is interim-only display: it rides the preview buffer
|
|
1736
|
+
// under /verbose but never enters narrationText or the final answer, so
|
|
1737
|
+
// a successful run's settled messages carry no reasoning residue.
|
|
1738
|
+
appendThinking(text, runContext) {
|
|
1739
|
+
if (!state.settings?.showThinking) return;
|
|
1740
|
+
const summary = String(text).trim();
|
|
1741
|
+
if (!summary) return;
|
|
1742
|
+
const clipped = summary.length > 900 ? `${summary.slice(0, 900)}…` : summary;
|
|
1743
|
+
appendChunk(`<i>🧠 ${clipped}</i>`, runContext);
|
|
1744
|
+
},
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1599
1748
|
function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
|
|
1600
1749
|
const persistEffects = !capture || persistCapture;
|
|
1750
|
+
const preview = capture ? null : createStreamPreview({ state, store });
|
|
1601
1751
|
return createRunnerService({
|
|
1602
1752
|
getState: () => state,
|
|
1603
1753
|
getStore: () => store,
|
|
@@ -1618,12 +1768,24 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1618
1768
|
state.runningProcess = proc;
|
|
1619
1769
|
if (ownsAdmissionLock) state.preparingRun = false;
|
|
1620
1770
|
},
|
|
1621
|
-
|
|
1771
|
+
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
1772
|
+
if (isCapture || !ownsAdmissionLock) return false;
|
|
1773
|
+
if (!state.cancelRequested) return false;
|
|
1774
|
+
state.cancelRequested = false;
|
|
1775
|
+
return true;
|
|
1776
|
+
},
|
|
1777
|
+
onEvent(event, runContext) {
|
|
1622
1778
|
if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
|
|
1623
1779
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
1624
1780
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
1625
1781
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
1626
1782
|
}
|
|
1783
|
+
if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
|
|
1784
|
+
preview.append(event.text, runContext);
|
|
1785
|
+
}
|
|
1786
|
+
if (preview && event.type === "thinking" && typeof event.text === "string" && event.text) {
|
|
1787
|
+
preview.appendThinking(event.text, runContext);
|
|
1788
|
+
}
|
|
1627
1789
|
},
|
|
1628
1790
|
onInvocationWarning(warning) {
|
|
1629
1791
|
return send(`⚠️ ${warning.message}`);
|
|
@@ -1701,6 +1863,10 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
1701
1863
|
} finally {
|
|
1702
1864
|
if (tracksMainRun && state.runningProcess) state.runningProcess = null;
|
|
1703
1865
|
if (ownsAdmissionLock) state.preparingRun = false;
|
|
1866
|
+
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
1867
|
+
// finished successfully anyway) must not leak into the next turn, where
|
|
1868
|
+
// it would falsely abort a queued run or mute a genuine failure.
|
|
1869
|
+
if (ownsAdmissionLock) state.cancelRequested = false;
|
|
1704
1870
|
if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
|
|
1705
1871
|
stopTyping();
|
|
1706
1872
|
}
|
|
@@ -1889,6 +2055,7 @@ module.exports = {
|
|
|
1889
2055
|
shouldAutoCompact,
|
|
1890
2056
|
effectiveCompactThreshold,
|
|
1891
2057
|
usageParts,
|
|
2058
|
+
peakContextTokens,
|
|
1892
2059
|
clampUsageDelta,
|
|
1893
2060
|
normalizeCodexUsage,
|
|
1894
2061
|
applyUsageToState,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Single-instance lock on the config dir. Two bot processes sharing one
|
|
2
|
+
// CONFIG_DIR share one Telegram token; Telegram then 409-terminates their
|
|
3
|
+
// getUpdates polls in turns and each side keeps exiting/respawning — the
|
|
4
|
+
// 2026-07-12 restart storm, where a debugger-run dev checkout fought the
|
|
5
|
+
// launchd service copy all morning. Whoever boots second must refuse to
|
|
6
|
+
// start BEFORE touching any channel.
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
function pidAlive(pid) {
|
|
14
|
+
try { process.kill(pid, 0); return true; }
|
|
15
|
+
catch (e) { return e.code === "EPERM"; } // EPERM: alive, different owner
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function acquireSingleInstanceLock({ configDir, name = "bot" }) {
|
|
19
|
+
const lockPath = path.join(configDir, `${name}.lock`);
|
|
20
|
+
try {
|
|
21
|
+
const prev = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
22
|
+
if (prev && prev.pid && prev.pid !== process.pid && pidAlive(prev.pid)) {
|
|
23
|
+
return { acquired: false, holder: prev, lockPath };
|
|
24
|
+
}
|
|
25
|
+
// Missing, corrupt, own-pid, or dead-pid (stale after SIGKILL): claim it.
|
|
26
|
+
} catch (e) {}
|
|
27
|
+
fs.writeFileSync(lockPath, JSON.stringify({
|
|
28
|
+
pid: process.pid,
|
|
29
|
+
startedAt: new Date().toISOString(),
|
|
30
|
+
entrypoint: process.argv[1] || "",
|
|
31
|
+
}));
|
|
32
|
+
let released = false;
|
|
33
|
+
const release = () => {
|
|
34
|
+
if (released) return;
|
|
35
|
+
released = true;
|
|
36
|
+
try {
|
|
37
|
+
const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
38
|
+
// Never delete a lock a successor already owns.
|
|
39
|
+
if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath);
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
};
|
|
42
|
+
process.on("exit", release);
|
|
43
|
+
return { acquired: true, lockPath, release };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { acquireSingleInstanceLock, pidAlive };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
12
|
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js",
|
|
13
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js"
|
|
13
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bot.js",
|
package/test-cursor-removal.js
CHANGED
|
@@ -306,8 +306,11 @@ function semanticStaticProbe() {
|
|
|
306
306
|
assert.match(fs.readFileSync(path.join(root, "test-provider-registry.js"), "utf8"), /provider\("cursor"\)/);
|
|
307
307
|
|
|
308
308
|
const changelog = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
|
|
309
|
-
|
|
310
|
-
assert.
|
|
309
|
+
const removalStart = changelog.indexOf("## v3.0.0");
|
|
310
|
+
assert.ok(removalStart !== -1, "the v3.0.0 removal section must remain in the changelog");
|
|
311
|
+
const removalNotes = changelog.slice(removalStart, removalStart + 2500);
|
|
312
|
+
assert.match(removalNotes, /active Cursor Agent runtime support has been removed/i);
|
|
313
|
+
assert.match(removalNotes, /rollback/i);
|
|
311
314
|
}
|
|
312
315
|
|
|
313
316
|
async function main() {
|
|
@@ -84,5 +84,33 @@ const inChat = (adapter, fn) => runInChat({ adapter, channelId: "chan-1", transp
|
|
|
84
84
|
assert.deepStrictEqual(io.normalizeSendResult(null), { ok: false, messageId: null, editable: false });
|
|
85
85
|
assert.deepStrictEqual(io.normalizeSendResult(99), { ok: true, messageId: "99", editable: true });
|
|
86
86
|
|
|
87
|
+
// 7. Telegram splitMessage: messages over the 4096 hard cap must be chunked
|
|
88
|
+
// instead of dying with "message is too long" (the silent dream-summary
|
|
89
|
+
// failure). Chunks stay under the margin and preserve all content.
|
|
90
|
+
{
|
|
91
|
+
const { splitMessage } = require("./channels/telegram/format");
|
|
92
|
+
|
|
93
|
+
assert.deepStrictEqual(splitMessage("short message"), ["short message"], "under limit → single chunk untouched");
|
|
94
|
+
assert.deepStrictEqual(splitMessage(""), [""], "empty stays a single empty chunk");
|
|
95
|
+
|
|
96
|
+
const paragraphs = [];
|
|
97
|
+
for (let i = 0; i < 40; i++) paragraphs.push(`Paragraph ${i} ${"x".repeat(180)}`);
|
|
98
|
+
const longText = paragraphs.join("\n\n");
|
|
99
|
+
const chunks = splitMessage(longText);
|
|
100
|
+
assert.ok(chunks.length > 1, "long multi-paragraph text is chunked");
|
|
101
|
+
for (const c of chunks) assert.ok(c.length <= 4000, "every chunk fits Telegram's limit with margin");
|
|
102
|
+
assert.strictEqual(
|
|
103
|
+
chunks.join("\n\n").replace(/\s+/g, " "),
|
|
104
|
+
longText.replace(/\s+/g, " "),
|
|
105
|
+
"no content lost across paragraph-boundary splits",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const unbroken = "y".repeat(9500);
|
|
109
|
+
const hardChunks = splitMessage(unbroken);
|
|
110
|
+
assert.ok(hardChunks.length >= 3, "giant unbroken string hard-splits");
|
|
111
|
+
for (const c of hardChunks) assert.ok(c.length <= 4000, "hard-split chunks also fit");
|
|
112
|
+
assert.strictEqual(hardChunks.join(""), unbroken, "hard split loses nothing");
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
console.log("delivery-contract OK");
|
|
88
116
|
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -267,6 +267,26 @@ function claudeEvents(sessionId) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
const events = [init];
|
|
270
|
+
if (process.env.FAKE_AGENT_THINKING) {
|
|
271
|
+
events.push({
|
|
272
|
+
type: "assistant",
|
|
273
|
+
message: {
|
|
274
|
+
role: "assistant",
|
|
275
|
+
content: [{ type: "thinking", thinking: process.env.FAKE_AGENT_THINKING, signature: "fixture-sig" }],
|
|
276
|
+
},
|
|
277
|
+
session_id: sessionId,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (process.env.FAKE_AGENT_NARRATION) {
|
|
281
|
+
events.push({
|
|
282
|
+
type: "assistant",
|
|
283
|
+
message: {
|
|
284
|
+
role: "assistant",
|
|
285
|
+
content: [{ type: "text", text: process.env.FAKE_AGENT_NARRATION }],
|
|
286
|
+
},
|
|
287
|
+
session_id: sessionId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
270
290
|
if (boolControl("FAKE_AGENT_TOOL")) {
|
|
271
291
|
events.push({
|
|
272
292
|
type: "assistant",
|
|
@@ -321,6 +341,18 @@ function codexEvents(sessionId) {
|
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
const events = [started, { type: "turn.started" }];
|
|
344
|
+
if (process.env.FAKE_AGENT_THINKING) {
|
|
345
|
+
events.push({
|
|
346
|
+
type: "item.completed",
|
|
347
|
+
item: { id: "item-think", type: "reasoning", text: process.env.FAKE_AGENT_THINKING },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (process.env.FAKE_AGENT_NARRATION) {
|
|
351
|
+
events.push({
|
|
352
|
+
type: "item.completed",
|
|
353
|
+
item: { id: "item-narration", type: "agent_message", text: process.env.FAKE_AGENT_NARRATION },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
324
356
|
if (boolControl("FAKE_AGENT_TOOL")) {
|
|
325
357
|
const command = {
|
|
326
358
|
id: "item-1",
|
|
@@ -158,8 +158,8 @@ async function commandProbe() {
|
|
|
158
158
|
|
|
159
159
|
await dispatch("/model");
|
|
160
160
|
const modelControls = callbacks(sent.at(-1).options.keyboard.inline_keyboard);
|
|
161
|
-
assert.ok(modelControls.includes("mb:codex:gpt-5.
|
|
162
|
-
assert.ok(modelControls.includes("mb:codex:gpt-5.
|
|
161
|
+
assert.ok(modelControls.includes("mb:codex:gpt-5.6-sol"));
|
|
162
|
+
assert.ok(modelControls.includes("mb:codex:gpt-5.6-luna"));
|
|
163
163
|
assert.ok(!modelControls.includes("mb:codex:o4-mini"));
|
|
164
164
|
assert.ok(modelControls.includes("mb:claude:claude-sonnet-4-6"));
|
|
165
165
|
}
|
package/test-provider-codex.js
CHANGED
|
@@ -46,7 +46,7 @@ assert.strictEqual(provider.label, "OpenAI Codex");
|
|
|
46
46
|
assert.strictEqual(provider.isAvailable(), true);
|
|
47
47
|
assert.strictEqual(provider.executable(), "/fixture/codex");
|
|
48
48
|
assert.strictEqual(provider.defaultModel(), "gpt-5-codex");
|
|
49
|
-
assert.
|
|
49
|
+
assert.deepStrictEqual(provider.modelChoices(), ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
|
|
50
50
|
assert.ok(!provider.modelChoices().includes("o4-mini"));
|
|
51
51
|
assert.deepStrictEqual(provider.compatibilityStatus(), {
|
|
52
52
|
state: "compatible",
|
|
@@ -180,7 +180,7 @@ const utility = validateInvocation(provider.buildUtilityInvocation(deepFreeze({
|
|
|
180
180
|
outputSchemaPath: "/fixture/schema.json",
|
|
181
181
|
})));
|
|
182
182
|
assert.ok(utility.args.includes("--ephemeral"));
|
|
183
|
-
assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "gpt-5.
|
|
183
|
+
assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "gpt-5.6-luna");
|
|
184
184
|
assert.strictEqual(utility.args[utility.args.indexOf("--output-schema") + 1], "/fixture/schema.json");
|
|
185
185
|
assert.deepStrictEqual(
|
|
186
186
|
utility.args.slice(utility.args.indexOf("--sandbox"), utility.args.indexOf("--sandbox") + 2),
|
package/test-provider-events.js
CHANGED
|
@@ -20,6 +20,7 @@ assert.deepStrictEqual([...NORMALIZED_EVENT_TYPES].sort(), [
|
|
|
20
20
|
"session",
|
|
21
21
|
"text_delta",
|
|
22
22
|
"text_final",
|
|
23
|
+
"thinking",
|
|
23
24
|
"tool_end",
|
|
24
25
|
"tool_start",
|
|
25
26
|
"usage",
|
|
@@ -76,6 +77,28 @@ assert.strictEqual(selectTerminalText({
|
|
|
76
77
|
finalText: "```json\n{\"status\":\"ok\"}\n```",
|
|
77
78
|
}), '{"status":"ok"}', "structured terminal output overrides presentation text");
|
|
78
79
|
|
|
80
|
+
const claudeThinkingState = createClaudeParserState();
|
|
81
|
+
assert.deepStrictEqual(normalizeClaudeEvent({
|
|
82
|
+
type: "assistant",
|
|
83
|
+
message: { content: [
|
|
84
|
+
{ type: "thinking", thinking: "Weighing the fixture options…", signature: "sig-1" },
|
|
85
|
+
{ type: "text", text: "answer" },
|
|
86
|
+
] },
|
|
87
|
+
}, claudeThinkingState), [
|
|
88
|
+
{ type: "thinking", text: "Weighing the fixture options…" },
|
|
89
|
+
{ type: "text_final", text: "answer" },
|
|
90
|
+
]);
|
|
91
|
+
const claudeThinkingOnly = createClaudeParserState();
|
|
92
|
+
normalizeClaudeEvent({
|
|
93
|
+
type: "assistant",
|
|
94
|
+
message: { content: [{ type: "thinking", thinking: "only thoughts" }] },
|
|
95
|
+
}, claudeThinkingOnly);
|
|
96
|
+
assert.deepStrictEqual(
|
|
97
|
+
normalizeClaudeEvent(samples.claude.result, claudeThinkingOnly).at(-1),
|
|
98
|
+
{ type: "result", text: "Hello world", sessionId: "claude-session-1" },
|
|
99
|
+
"thinking alone must not mark sawText — the result fallback text still applies",
|
|
100
|
+
);
|
|
101
|
+
|
|
79
102
|
const usageOnly = { type: "assistant", message: { usage: { input_tokens: 1, output_tokens: 1 }, content: [] } };
|
|
80
103
|
const usageState = createClaudeParserState();
|
|
81
104
|
assert.strictEqual(normalizeClaudeEvent(usageOnly, usageState).filter((event) => event.type === "usage").length, 1);
|
|
@@ -114,6 +137,13 @@ assert.deepStrictEqual(normalizeCodexEvent(samples.codex.result, codexState), [
|
|
|
114
137
|
]);
|
|
115
138
|
assert.deepStrictEqual(normalizeCodexEvent(samples.codex.result, codexState), [], "duplicate Codex terminal and usage settle once");
|
|
116
139
|
|
|
140
|
+
assert.deepStrictEqual(normalizeCodexEvent({
|
|
141
|
+
type: "item.completed",
|
|
142
|
+
item: { id: "item-think", type: "reasoning", text: "Weighing options…" },
|
|
143
|
+
}, createCodexParserState()), [
|
|
144
|
+
{ type: "thinking", text: "Weighing options…" },
|
|
145
|
+
]);
|
|
146
|
+
|
|
117
147
|
const codexAuth = normalizeCodexEvent({ type: "turn.failed", error: { message: "401 Unauthorized" } }, createCodexParserState());
|
|
118
148
|
assert.deepStrictEqual(codexAuth, [{
|
|
119
149
|
type: "error",
|
|
@@ -32,9 +32,10 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
|
|
35
|
+
// 3.0.2 approved by Sumeet 2026-07-12 ("Yeh do it and push it out" — peak
|
|
36
|
+
// context metric restored, Telegram long-message chunking so dream summaries
|
|
37
|
+
// deliver; CHANGELOG documents it as v3.0.2).
|
|
38
|
+
assert.strictEqual(pkg.version, "3.0.2", "release version must remain unchanged without explicit approval");
|
|
38
39
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
39
40
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
40
41
|
}
|
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
const assert = require("assert");
|
|
4
4
|
const { TelegramAdapter } = require("./channels/telegram/adapter");
|
|
5
5
|
|
|
6
|
+
async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
|
|
7
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
8
|
+
adapter._pollWatchdogTimer = null;
|
|
9
|
+
adapter._pollStartedAt = 0;
|
|
10
|
+
adapter.bot = {
|
|
11
|
+
_polling: { _lastUpdate: null },
|
|
12
|
+
startPolling() { return new Promise(() => {}); },
|
|
13
|
+
};
|
|
14
|
+
const hiccups = [];
|
|
15
|
+
adapter._onPollingHiccup = (message) => hiccups.push(message);
|
|
16
|
+
|
|
17
|
+
let watchdog;
|
|
18
|
+
const realSetInterval = global.setInterval;
|
|
19
|
+
const realClearInterval = global.clearInterval;
|
|
20
|
+
const realNow = Date.now;
|
|
21
|
+
global.setInterval = (fn) => { watchdog = fn; return { watchdog: true }; };
|
|
22
|
+
global.clearInterval = () => {};
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const outcome = await Promise.race([
|
|
26
|
+
adapter.start().then(() => "started"),
|
|
27
|
+
new Promise((resolve) => setTimeout(() => resolve("blocked"), 25)),
|
|
28
|
+
]);
|
|
29
|
+
assert.strictEqual(outcome, "started", "adapter startup must not wait for Telegram's first long-poll request to settle");
|
|
30
|
+
assert.strictEqual(typeof watchdog, "function", "adapter startup must arm an independent poll-stall watchdog");
|
|
31
|
+
|
|
32
|
+
Date.now = () => adapter._pollStartedAt + 120000;
|
|
33
|
+
watchdog();
|
|
34
|
+
assert.match(hiccups[0] || "", /no completed getUpdates/i, "watchdog must recover a silent first-poll stall");
|
|
35
|
+
} finally {
|
|
36
|
+
Date.now = realNow;
|
|
37
|
+
global.setInterval = realSetInterval;
|
|
38
|
+
global.clearInterval = realClearInterval;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
async function testHealCancelsWedgedLongPollBeforeRestarting() {
|
|
7
43
|
const calls = [];
|
|
8
44
|
let stopOptions;
|
|
@@ -48,6 +84,7 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
|
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
(async () => {
|
|
87
|
+
await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
|
|
51
88
|
await testHealCancelsWedgedLongPollBeforeRestarting();
|
|
52
89
|
console.log("telegram poll recovery OK");
|
|
53
90
|
})().catch((err) => {
|
package/test-usage-accounting.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
normalizeCodexUsage,
|
|
10
10
|
applyUsageToState,
|
|
11
11
|
shouldAutoCompact,
|
|
12
|
+
peakContextTokens,
|
|
12
13
|
} = require("./core/runner");
|
|
13
14
|
const stateApi = require("./core/state");
|
|
14
15
|
const { normalizeUsageRecord, loadUsageHistory, USAGE_HISTORY_FILE } = require("./core/usage-log");
|
|
@@ -152,5 +153,28 @@ assert.strictEqual(shouldAutoCompact(state({
|
|
|
152
153
|
sessionUsage: { liveContextTokens: 250, lastInputTokens: 250, lastCompactedAt: Date.now() },
|
|
153
154
|
})), true);
|
|
154
155
|
|
|
156
|
+
// peakContextTokens: the live context number must be the LARGEST single API
|
|
157
|
+
// call, not the turn-cumulative sum (the v3.0.0 347k-vs-160k regression).
|
|
158
|
+
assert.strictEqual(peakContextTokens([
|
|
159
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 1000, cache_read_input_tokens: 150000, cache_creation_input_tokens: 2000 } },
|
|
160
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 500, cache_read_input_tokens: 158000, cache_creation_input_tokens: 1500 } },
|
|
161
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 200, cache_read_input_tokens: 120000 } },
|
|
162
|
+
], "claude"), 160000, "peak is the max single provider_call context");
|
|
163
|
+
assert.strictEqual(peakContextTokens([
|
|
164
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 100, cache_read_input_tokens: 50 } },
|
|
165
|
+
{ type: "usage", scope: "turn", usage: { input_tokens: 999999, cache_read_input_tokens: 999999 } },
|
|
166
|
+
], "claude"), 150, "turn-scope cumulative usage never wins over per-call peak");
|
|
167
|
+
assert.strictEqual(peakContextTokens([], "claude"), null, "no events → null so callers fall back");
|
|
168
|
+
assert.strictEqual(peakContextTokens([
|
|
169
|
+
{ type: "usage", scope: "turn", usage: { input_tokens: 10 } },
|
|
170
|
+
], "claude"), null, "only turn-scope events → null");
|
|
171
|
+
assert.strictEqual(peakContextTokens([
|
|
172
|
+
{ type: "usage", scope: "provider_call", usage: { output_tokens: 40 } },
|
|
173
|
+
], "claude"), null, "zero-context calls → null");
|
|
174
|
+
assert.strictEqual(peakContextTokens(null, "claude"), null, "missing events list tolerated");
|
|
175
|
+
assert.strictEqual(peakContextTokens([
|
|
176
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 70, cached_input_tokens: 30, output_tokens: 5 } },
|
|
177
|
+
], "codex"), 70, "codex context counts input only, matching usageParts");
|
|
178
|
+
|
|
155
179
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
156
180
|
console.log("usage accounting OK");
|