@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 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
- await this.bot.startPolling();
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
- // Two pollers can't share one token — this one must bow out.
124
- console.error("Another instance is polling. Exiting.");
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 o = {};
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
- if (kb) o.reply_markup = kb;
269
- if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
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(/&amp;/g, "&");
140
140
  }
141
141
 
142
- module.exports = { escapeHtml, stripMarkdown, telegramHtml, htmlToPlain };
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
- bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
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
- bits.push(`🔗 ${dangling.length} tool(s) link a missing skill pack: ${dangling.map((t) => `${t.name}→${t.pack}`).join(", ")} — re-link with --pack or remove.`);
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
- bits.push(`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${coldUnused.map((t) => t.name).join(", ")} — keep, fold into another, or remove.`);
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
- bits.push(`👯 ${dupPairs.size} near-duplicate tool pair(s): ${[...dupPairs].join(", ")} — consider merging into one tool with subcommands.`);
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
- bits.push(`📝 ${drifted.length} tool(s) with doc/code drift: ${drifted.join("; ")} — refresh the docs with tool note.`);
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
- bits.push(`📈 Tool usage since last dream: ${kpiBits.join(" · ")}.`);
1345
+ push(`📈 Tool usage since last dream: ${kpiBits.join(" · ")}.`);
1323
1346
  }
1324
1347
  } catch (e) { /* KPI is best-effort */ }
1325
- toolNote = bits.join("\n");
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
- if (state.streamInterval) clearTimeout(state.streamInterval);
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("Cancelled.");
1349
+ await send(`Cancelled.${queuedNote()}`);
1317
1350
  } else if (state.preparingRun) {
1318
- // Turn is mid-recall/compaction — no process to kill yet. Flag it so
1319
- // runAgent bails at its pre-spawn checkpoint, and stop typing now.
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("Cancelled.");
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)
@@ -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.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"]);
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.4-mini";
268
- if (tier === "medium") return "gpt-5.3-codex";
269
- return defaultModel || "gpt-5.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
 
@@ -4,6 +4,7 @@ const NORMALIZED_EVENT_TYPES = Object.freeze([
4
4
  "session",
5
5
  "text_delta",
6
6
  "text_final",
7
+ "thinking",
7
8
  "tool_start",
8
9
  "tool_end",
9
10
  "usage",
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") textFinals.push(event.text);
331
- else if (event.type === "tool_start" || event.type === "tool_end") tools.push(event);
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 = usageParts(usage, runContext.provider).context;
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
- let finalText = result.ok ? (result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
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
- onEvent(event) {
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.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",
@@ -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
- assert.match(changelog.slice(0, 2500), /active Cursor Agent runtime support has been removed/i);
310
- assert.match(changelog.slice(0, 2500), /rollback/i);
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.4"));
162
- assert.ok(modelControls.includes("mb:codex:gpt-5.4-mini"));
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
  }
@@ -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.ok(provider.modelChoices().includes("gpt-5.4-mini"));
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.4-mini");
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),
@@ -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.0 approved by Sumeet 2026-07-10 (provider-parity release; CHANGELOG
36
- // already documents it as v3.0.0).
37
- assert.strictEqual(pkg.version, "3.0.0", "release version must remain unchanged without explicit approval");
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) => {
@@ -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");