@inetafrica/open-claudia 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.1 — streaming restored, /stop queue survival, boot resilience
4
+
5
+ - **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.
6
+ - **`/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.
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+
3
13
  ## v3.0.0 — provider parity and Cursor removal
4
14
 
5
15
  - **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,
@@ -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
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/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 {
@@ -258,6 +258,11 @@ async function executeProviderInvocation(options = {}) {
258
258
  const tools = [];
259
259
  const textDeltas = [];
260
260
  const textFinals = [];
261
+ // Text blocks grouped by the tool runs between them. The last segment is
262
+ // the provider's actual answer; everything before it is interim narration
263
+ // ("Let me check X…" written before a tool call). Kept separate so chat
264
+ // delivery can split them while result.text keeps the full transcript.
265
+ const textSegments = [[]];
261
266
  let resultText = "";
262
267
  let sessionId = runContext?.sessionId || null;
263
268
  let terminalSeen = false;
@@ -327,8 +332,13 @@ async function executeProviderInvocation(options = {}) {
327
332
  if (!event || typeof event !== "object") return;
328
333
  if (event.type === "session" && event.sessionId) sessionId = event.sessionId;
329
334
  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);
335
+ else if (event.type === "text_final" && typeof event.text === "string") {
336
+ textFinals.push(event.text);
337
+ textSegments[textSegments.length - 1].push(event.text);
338
+ } else if (event.type === "tool_start" || event.type === "tool_end") {
339
+ if (event.type === "tool_start" && textSegments[textSegments.length - 1].length) textSegments.push([]);
340
+ tools.push(event);
341
+ }
332
342
  else if (event.type === "usage") usageEvents.push(event);
333
343
  else if (event.type === "result") {
334
344
  terminalSeen = true;
@@ -487,6 +497,9 @@ async function executeProviderInvocation(options = {}) {
487
497
  finalText: textFinals.join(""),
488
498
  deltaText: textDeltas.join(""),
489
499
  });
500
+ const segmentTexts = textSegments.map((blocks) => blocks.join("")).filter((t) => t.trim());
501
+ const finalAnswerText = segmentTexts.length ? segmentTexts[segmentTexts.length - 1] : "";
502
+ const narrationText = segmentTexts.slice(0, -1).join("\n\n");
490
503
  let failure = null;
491
504
  if (spawnError) failure = stableRunError(spawnError.message, "PROVIDER_SPAWN_FAILED");
492
505
  else if (timedOut) failure = stableRunError(
@@ -511,6 +524,8 @@ async function executeProviderInvocation(options = {}) {
511
524
  sessionId,
512
525
  purpose: runContext?.purpose || "foreground",
513
526
  text: redactSensitive(String(text || "").trim()),
527
+ finalAnswerText: redactSensitive(String(finalAnswerText || "").trim()),
528
+ narrationText: redactSensitive(String(narrationText || "")),
514
529
  usage: finalUsage?.usage || null,
515
530
  usageScope: finalUsage?.scope || null,
516
531
  usageEvents,
@@ -631,6 +646,15 @@ function preflightFailureResult(runContext, provider, failure) {
631
646
  };
632
647
  }
633
648
 
649
+ function cancelledRunResult(runContext, provider) {
650
+ const result = preflightFailureResult(runContext, provider, {
651
+ code: "RUN_CANCELLED",
652
+ message: "Cancelled by /stop before the provider started",
653
+ });
654
+ result.status = "cancelled";
655
+ return result;
656
+ }
657
+
634
658
  async function settleImmediateRunResult(result, runContext, dependencies, capture) {
635
659
  for (const [name, hook] of [
636
660
  ["transcript", dependencies.persistTranscript],
@@ -738,6 +762,15 @@ function createRunnerService(dependencies = {}) {
738
762
  return immediateFailure(provider, error);
739
763
  }
740
764
 
765
+ // Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
766
+ // flag instead of killing a process (there is none yet). Honour it here,
767
+ // after the slow prompt build and before anything spawns. Persistence
768
+ // still settles (the transcript records the cancelled turn); delivery is
769
+ // suppressed downstream via the RUN_CANCELLED code.
770
+ if (dependencies.shouldAbort && dependencies.shouldAbort(runContext, { capture })) {
771
+ return settleImmediateRunResult(cancelledRunResult(runContext, provider), runContext, dependencies, capture);
772
+ }
773
+
741
774
  const result = await executeProviderInvocation({
742
775
  runContext,
743
776
  provider,
@@ -1469,11 +1502,49 @@ async function persistForegroundSession(result, runContext, state = currentState
1469
1502
  result.sessionAttached = attach;
1470
1503
  }
1471
1504
 
1505
+ // Settle the streamed preview message before the final answer goes out.
1506
+ // Success with narration → the preview becomes the narration record (footer
1507
+ // dropped). Success without narration → the preview only ever showed the
1508
+ // streamed answer itself; delete it so the final message isn't a duplicate.
1509
+ // Failure/cancel → keep the partial text but drop the lying "working" footer.
1510
+ async function finalizeStreamPreview(state, result) {
1511
+ if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1512
+ const id = state.statusMessageId;
1513
+ const channel = state.statusMessageChannel;
1514
+ const buffer = String(state.streamBuffer || "").trim();
1515
+ state.statusMessageId = null;
1516
+ state.statusMessageChannel = null;
1517
+ state.streamBuffer = "";
1518
+ if (typeof id !== "string") return;
1519
+ // A message id is only valid on the channel that minted it; a foreign-
1520
+ // channel preview is left as an orphan rather than edited cross-transport.
1521
+ if (!channel || String(channel) !== String(currentChannelId())) return;
1522
+ try {
1523
+ if (!result || !result.ok) {
1524
+ if (buffer) await editMessage(id, buffer.slice(-4000), telegramHtmlOpts({ streaming: true }));
1525
+ return;
1526
+ }
1527
+ const narration = String(result.narrationText || "").trim();
1528
+ if (narration) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
1529
+ else await deleteMessage(id);
1530
+ } catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
1531
+ }
1532
+
1472
1533
  async function deliverForegroundResult(result, runContext, state = currentState()) {
1473
1534
  if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
1535
+ await finalizeStreamPreview(state, result);
1536
+ // /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
1537
+ // is not a failure worth announcing — /stop already replied "Cancelled.".
1538
+ // The flag is consumed here (and re-cleared on the run's unwind) so it can
1539
+ // never suppress a later run's genuine failure.
1540
+ if (!result.ok && (state.cancelRequested || result.error?.code === "RUN_CANCELLED")) {
1541
+ state.cancelRequested = false;
1542
+ return { ok: true, suppressed: "cancelled" };
1543
+ }
1474
1544
  const diagnostic = providerRunDiagnostic(result, runContext);
1475
1545
  if (diagnostic) result.diagnostic = diagnostic;
1476
- let finalText = result.ok ? (result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
1546
+ const cleanAnswer = String(result.finalAnswerText || "").trim();
1547
+ let finalText = result.ok ? (cleanAnswer || result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
1477
1548
 
1478
1549
  if (result.ok) {
1479
1550
  let guarded = false;
@@ -1596,8 +1667,73 @@ function settleImmediateEffects(result, runContext, { state, store, capture }) {
1596
1667
  })();
1597
1668
  }
1598
1669
 
1670
+ const STREAM_PREVIEW_THROTTLE_MS = 2500;
1671
+ const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
1672
+
1673
+ // Edited-in-place interim preview: each completed text block the provider
1674
+ // emits mid-run is appended to one throttled status message, so the user sees
1675
+ // the narration as it happens instead of silence. finalizeStreamPreview
1676
+ // settles the message when the run ends.
1677
+ function createStreamPreview({ state, store }) {
1678
+ let enabled = !!store && (store?.adapter?.type || "") !== "voice";
1679
+ if (enabled) {
1680
+ // Interim text is NOT vetted by guardOutboundReply (only the final answer
1681
+ // is), so it must never reach a guarded external speaker. Fail closed if
1682
+ // classification is unavailable.
1683
+ try { enabled = chatContext.run(store, () => !require("./relationship").isCurrentSpeakerGuarded()); }
1684
+ catch (_) { enabled = false; }
1685
+ }
1686
+ const channelId = store?.channelId || null;
1687
+
1688
+ const flush = () => {
1689
+ state.streamInterval = null;
1690
+ if (!enabled || state.cancelRequested) return;
1691
+ const body = String(state.streamBuffer || "").trim();
1692
+ if (!body) return;
1693
+ const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
1694
+ // Event callbacks arrive off the provider's stdout stream, outside the
1695
+ // chat AsyncLocalStorage context — rebind before any send/edit.
1696
+ chatContext.run(store, async () => {
1697
+ try {
1698
+ if (typeof state.statusMessageId === "string" && String(state.statusMessageChannel) === String(channelId)) {
1699
+ await editMessage(state.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
1700
+ return;
1701
+ }
1702
+ if (state.statusMessageId === true) return; // sent but not editable — leave the single notice alone
1703
+ const sent = await send(display, telegramHtmlOpts());
1704
+ state.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
1705
+ state.statusMessageChannel = sent.ok ? channelId : null;
1706
+ } catch (_) { /* preview is best-effort */ }
1707
+ }).catch(() => {});
1708
+ };
1709
+
1710
+ const appendChunk = (chunk, runContext) => {
1711
+ if (!enabled || runContext?.purpose !== "foreground" || state.cancelRequested) return;
1712
+ state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
1713
+ if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
1714
+ if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
1715
+ };
1716
+
1717
+ return {
1718
+ append(text, runContext) {
1719
+ appendChunk(text, runContext);
1720
+ },
1721
+ // Thinking/reasoning is interim-only display: it rides the preview buffer
1722
+ // under /verbose but never enters narrationText or the final answer, so
1723
+ // a successful run's settled messages carry no reasoning residue.
1724
+ appendThinking(text, runContext) {
1725
+ if (!state.settings?.showThinking) return;
1726
+ const summary = String(text).trim();
1727
+ if (!summary) return;
1728
+ const clipped = summary.length > 900 ? `${summary.slice(0, 900)}…` : summary;
1729
+ appendChunk(`<i>🧠 ${clipped}</i>`, runContext);
1730
+ },
1731
+ };
1732
+ }
1733
+
1599
1734
  function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1600
1735
  const persistEffects = !capture || persistCapture;
1736
+ const preview = capture ? null : createStreamPreview({ state, store });
1601
1737
  return createRunnerService({
1602
1738
  getState: () => state,
1603
1739
  getStore: () => store,
@@ -1618,12 +1754,24 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1618
1754
  state.runningProcess = proc;
1619
1755
  if (ownsAdmissionLock) state.preparingRun = false;
1620
1756
  },
1621
- onEvent(event) {
1757
+ shouldAbort(runContext, { capture: isCapture } = {}) {
1758
+ if (isCapture || !ownsAdmissionLock) return false;
1759
+ if (!state.cancelRequested) return false;
1760
+ state.cancelRequested = false;
1761
+ return true;
1762
+ },
1763
+ onEvent(event, runContext) {
1622
1764
  if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1623
1765
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1624
1766
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1625
1767
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
1626
1768
  }
1769
+ if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
1770
+ preview.append(event.text, runContext);
1771
+ }
1772
+ if (preview && event.type === "thinking" && typeof event.text === "string" && event.text) {
1773
+ preview.appendThinking(event.text, runContext);
1774
+ }
1627
1775
  },
1628
1776
  onInvocationWarning(warning) {
1629
1777
  return send(`⚠️ ${warning.message}`);
@@ -1701,6 +1849,10 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1701
1849
  } finally {
1702
1850
  if (tracksMainRun && state.runningProcess) state.runningProcess = null;
1703
1851
  if (ownsAdmissionLock) state.preparingRun = false;
1852
+ // A cancel flag that survived this run (e.g. /stop raced a run that
1853
+ // finished successfully anyway) must not leak into the next turn, where
1854
+ // it would falsely abort a queued run or mute a genuine failure.
1855
+ if (ownsAdmissionLock) state.cancelRequested = false;
1704
1856
  if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
1705
1857
  stopTyping();
1706
1858
  }
@@ -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.1",
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() {
@@ -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,9 @@ 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.1 approved by Sumeet 2026-07-12 ("Push v3.0.1" — streaming restored,
36
+ // /stop queue survival, boot resilience; CHANGELOG documents it as v3.0.1).
37
+ assert.strictEqual(pkg.version, "3.0.1", "release version must remain unchanged without explicit approval");
38
38
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
39
39
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
40
40
  }
@@ -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) => {