@inetafrica/open-claudia 3.0.6 → 3.0.8

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/Dockerfile CHANGED
@@ -1,21 +1,8 @@
1
- FROM node:20-slim
2
-
3
- # Install system dependencies
4
- # chromium is the browser engine driven by agent-browser (apt pulls its headless libs).
5
- RUN apt-get update && apt-get install -y --no-install-recommends \
6
- curl \
7
- ffmpeg \
8
- ca-certificates \
9
- git \
10
- jq \
11
- python3 \
12
- python3-pip \
13
- build-essential \
14
- sudo \
15
- openssh-client \
16
- rsync \
17
- chromium \
18
- && rm -rf /var/lib/apt/lists/*
1
+ # Release image. The slow system layer (apt + chromium, ~250 packages) lives
2
+ # in Dockerfile.base, published as the `:base` tag — rebuilt only when that
3
+ # file changes. This build is minutes: fresh agent CLIs + app source.
4
+ ARG BASE_IMAGE=git.coders.africa:5050/kazee/agent-space/open-claudia:base
5
+ FROM ${BASE_IMAGE}
19
6
 
20
7
  # Install Claude Code CLI
21
8
  RUN curl -fsSL https://claude.ai/install.sh | sh || \
@@ -31,14 +18,6 @@ RUN npm install -g @openai/codex
31
18
  RUN npm install -g agent-browser
32
19
  ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
33
20
 
34
- # Create non-root user (Claude Code refuses --dangerously-skip-permissions as root)
35
- # node:20-slim already has uid/gid 1000 (node user). Create claudia with different IDs.
36
- RUN groupadd -g 1001 claudia && useradd -u 1001 -g 1001 -m -d /data claudia
37
-
38
- # Allow claudia to install packages at runtime without a password
39
- RUN echo "claudia ALL=(ALL) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt" > /etc/sudoers.d/claudia-apt && \
40
- chmod 0440 /etc/sudoers.d/claudia-apt
41
-
42
21
  # Create app directory
43
22
  WORKDIR /app
44
23
 
@@ -66,7 +66,7 @@ class TelegramAdapter {
66
66
  // remaining adapters can boot, and let the independent watchdog recover a
67
67
  // poll that never completes and emits no polling_error.
68
68
  this._pollStartedAt = Date.now();
69
- Promise.resolve(this.bot.startPolling()).catch((error) => {
69
+ Promise.resolve(this.bot.startPolling({ restart: false })).catch((error) => {
70
70
  this._onPollingHiccup(error?.message || "Telegram polling failed to start");
71
71
  });
72
72
  if (!this._pollWatchdogTimer) {
@@ -87,7 +87,7 @@ class TelegramAdapter {
87
87
  if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
88
88
  if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
89
89
  if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
90
- try { await this.bot.stopPolling(); } catch (e) {}
90
+ try { await this._stopPollingClean("adapter stop"); } catch (e) {}
91
91
  }
92
92
 
93
93
  // A transient network error on the long-poll. The network is almost never
@@ -116,6 +116,22 @@ class TelegramAdapter {
116
116
  this._healTimer = setTimeout(() => this._heal(), delay);
117
117
  }
118
118
 
119
+ // stopPolling({cancel:true}) in node-telegram-bot-api LEAKS the poll loop:
120
+ // Bluebird cancellation skips .then/.catch but still runs the chain's
121
+ // .finally, which sees `_abort === false` and reschedules _polling() — so
122
+ // every cancel-stop left a zombie loop behind. Each zombie's getUpdates
123
+ // 409'd the live one, every 409 triggered another cancel-stop/start, and
124
+ // the pile-up (15+ concurrent Telegram sockets on 2026-07-12) presented as
125
+ // a phantom "second poller" that only a process restart cleared. A PLAIN
126
+ // stop sets `_abort = true` before the request settles (no zombie), and
127
+ // destroying the agent forces a hung half-open getUpdates to settle NOW
128
+ // instead of never. The 5s race caps the wait if teardown gets swallowed.
129
+ async _stopPollingClean(reason) {
130
+ const stopped = Promise.resolve(this.bot.stopPolling({ reason })).catch(() => {});
131
+ try { this._agent.destroy(); } catch (e) {}
132
+ await Promise.race([stopped, new Promise((r) => setTimeout(r, 5000))]);
133
+ }
134
+
119
135
  async _heal() {
120
136
  this._healTimer = null;
121
137
  // Backstop only: if polling has been unbroken-wedged for 10 minutes, a
@@ -130,15 +146,11 @@ class TelegramAdapter {
130
146
  process.exit(1);
131
147
  }
132
148
  try {
133
- try {
134
- // `stopPolling()` without cancellation waits for the active getUpdates
135
- // request to settle. A half-open socket may never settle, which wedges
136
- // this recovery routine itself. Abort the request before restarting.
137
- await this.bot.stopPolling({ cancel: true, reason: "stale Telegram long-poll recovery" });
138
- } catch (e) {}
139
- try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
149
+ await this._stopPollingClean("stale Telegram long-poll recovery");
140
150
  await new Promise((r) => setTimeout(r, 500));
141
- await this.bot.startPolling();
151
+ // restart:false — the lib's restart path is another cancel-stop (zombie
152
+ // factory); we already stopped cleanly, so only start if truly idle.
153
+ await this.bot.startPolling({ restart: false });
142
154
  } catch (e) {
143
155
  return; // restart didn't take — the next hiccup reschedules a heal
144
156
  }
@@ -177,11 +189,12 @@ class TelegramAdapter {
177
189
  if (this._conflictTimer) return; // a paused retry is already queued
178
190
  const pauseMs = persisted ? 60000 : 15000;
179
191
  console.error(`409 Conflict: another poller holds this token — pausing ${Math.round(pauseMs / 1000)}s, then retrying (we hold the instance lock; never exiting).`);
180
- // Stop hammering now; the timer below dials back in.
181
- Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
192
+ // Stop hammering now (clean stop: no zombie loop, ghost socket severed);
193
+ // the timer below dials back in.
194
+ Promise.resolve(this._stopPollingClean("409 conflict backoff")).catch(() => {});
182
195
  this._conflictTimer = setTimeout(async () => {
183
196
  this._conflictTimer = null;
184
- try { await this.bot.startPolling(); } catch (e) {}
197
+ try { await this.bot.startPolling({ restart: false }); } catch (e) {}
185
198
  // No further 409 for 45s ⇒ the other poller is gone; end the streak.
186
199
  this._conflictClearTimer = setTimeout(() => {
187
200
  this._conflictClearTimer = null;
@@ -6,8 +6,10 @@ 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.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
10
- const CODEX_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high", "xhigh"]);
9
+ // gpt-5.6-sol-pro is rejected on ChatGPT-plan auth (400 from the API); it needs API-key billing.
10
+ const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-sol-pro", "gpt-5.6-terra", "gpt-5.6-luna"]);
11
+ // max + ultra verified live against codex 0.144 (ultra = proactive multi-agent behavior).
12
+ const CODEX_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
11
13
  const REQUIRED_EXEC_HELP_FLAGS = Object.freeze(["--config", "--json", "--sandbox", "--image", "--model", "--skip-git-repo-check"]);
12
14
  const REQUIRED_RESUME_HELP_FLAGS = Object.freeze(["--config", "--json", "--image", "--model", "--skip-git-repo-check"]);
13
15
 
package/core/runner.js CHANGED
@@ -776,6 +776,12 @@ function createRunnerService(dependencies = {}) {
776
776
  return immediateFailure(provider, error);
777
777
  }
778
778
 
779
+ // Prompt bundle is final: let the service render pre-spawn debug banners
780
+ // (/recall, /tooltrace) from its recall metadata. Best-effort by contract.
781
+ if (dependencies.onPromptBundle) {
782
+ try { await dependencies.onPromptBundle(bundle, runContext); } catch (e) {}
783
+ }
784
+
779
785
  // Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
780
786
  // flag instead of killing a process (there is none yet). Honour it here,
781
787
  // after the slow prompt build and before anything spawns. Persistence
@@ -1762,7 +1768,10 @@ function createStreamPreview({ state, store }) {
1762
1768
  function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1763
1769
  const persistEffects = !capture || persistCapture;
1764
1770
  const preview = capture ? null : createStreamPreview({ state, store });
1765
- return createRunnerService({
1771
+ // Chat-visibility + learning-signal layer (skill/pack/tool announcements,
1772
+ // /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
1773
+ const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
1774
+ const service = createRunnerService({
1766
1775
  getState: () => state,
1767
1776
  getStore: () => store,
1768
1777
  resolveProvider: resolveForegroundProvider,
@@ -1788,12 +1797,19 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1788
1797
  state.cancelRequested = false;
1789
1798
  return true;
1790
1799
  },
1800
+ onPromptBundle(bundle, runContext) {
1801
+ if (!observer || runContext.purpose !== "foreground") return;
1802
+ observer.onRecallSurfaced(bundle.recallMetadata);
1803
+ },
1791
1804
  onEvent(event, runContext) {
1792
1805
  if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1793
1806
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1794
1807
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1795
1808
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
1796
1809
  }
1810
+ if (observer && event.type === "tool_start" && runContext.purpose === "foreground") {
1811
+ try { observer.onToolStart(event.name, event.detail); } catch (_) {}
1812
+ }
1797
1813
  if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
1798
1814
  preview.append(event.text, runContext);
1799
1815
  }
@@ -1814,6 +1830,8 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1814
1830
  },
1815
1831
  idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
1816
1832
  });
1833
+ service.turnObserver = observer;
1834
+ return service;
1817
1835
  }
1818
1836
 
1819
1837
  async function handoffMemoryReview(result, runContext, store) {
@@ -1891,6 +1909,11 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1891
1909
  finalizeSideChatQueueItem(item, runContext, result);
1892
1910
  }
1893
1911
  }
1912
+ // Feed the recall/tool graphs from what this turn actually did — before the
1913
+ // async reviewer runs, so it sees applied_on already set (no double-announce).
1914
+ if (!capture && service.turnObserver && runContext.purpose === "foreground") {
1915
+ try { service.turnObserver.onTurnSettled(!!result.ok); } catch (e) { /* best-effort */ }
1916
+ }
1894
1917
  if (!internalCapture) await handoffMemoryReview(result, runContext, store);
1895
1918
  if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1896
1919
  state.settings.budget = null;
@@ -0,0 +1,244 @@
1
+ // Turn observer — the chat-visibility + learning-signal layer for a
2
+ // foreground run, ported from the v2.15 runner (dropped wholesale in the v3
3
+ // rewrite, which left /recall and /tooltrace as dead toggles and the recall +
4
+ // tool graphs starving: read by the discoverer and the dream, written by
5
+ // no-one). One observer is created per foreground run and wired to three
6
+ // moments: prompt-bundle ready (banners), each tool_start event
7
+ // (announcements + activity capture), and turn settled (graph feeding).
8
+ //
9
+ // Everything here is best-effort and owner-only: traces and announcements
10
+ // must never leak to a guarded external speaker, and no failure in this layer
11
+ // may break the turn.
12
+
13
+ const { chatContext, currentAdapter } = require("./context");
14
+ const { send } = require("./io");
15
+
16
+ function esc(s) {
17
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+ }
19
+
20
+ function htmlOpts(store) {
21
+ try {
22
+ const adapter = chatContext.run(store, () => currentAdapter());
23
+ return adapter?.type === "telegram" ? { parseMode: "HTML" } : {};
24
+ } catch (e) { return {}; }
25
+ }
26
+
27
+ function createTurnObserver({ state, store }) {
28
+ const settings = state.settings || {};
29
+ // Fail closed: if the speaker can't be classified, treat them as guarded
30
+ // and emit nothing — internal activity must never reach a non-owner.
31
+ let guarded = true;
32
+ try { guarded = chatContext.run(store, () => require("./relationship").isCurrentSpeakerGuarded()); }
33
+ catch (e) { guarded = true; }
34
+
35
+ const packsLib = require("./packs");
36
+ const entitiesLib = require("./entities");
37
+ const toolsLib = require("./tools");
38
+ const skillsLib = require("./skills");
39
+
40
+ // Deduped per turn: a node touched twice announces once.
41
+ const notified = new Set();
42
+ const notify = (key, text, opts) => {
43
+ if (guarded) return;
44
+ if (notified.has(key)) return;
45
+ notified.add(key);
46
+ try { chatContext.run(store, () => send(text, opts).catch(() => {})); }
47
+ catch (e) { /* announcements are best-effort */ }
48
+ };
49
+ const notifyToolTrace = (key, text) => {
50
+ if (!settings.showToolTrace) return;
51
+ notify(key, text);
52
+ };
53
+
54
+ // Nodes the agent actually OPENED this turn (📖) — the co-use signal the
55
+ // recall graph reinforces on: actually-read, not merely surfaced.
56
+ const openedThisTurn = new Set();
57
+ // Reusable tools the agent RAN this turn, in order, as { name, shape }.
58
+ // Order matters (a tool chain is a pipeline) — feeds the follows-graph.
59
+ const toolRunsThisTurn = [];
60
+
61
+ // 🧠 /recall + 🔧 /tooltrace banners, fired once the prompt bundle (and its
62
+ // recall metadata) exists — before the provider spawns, like v2.15.
63
+ const onRecallSurfaced = (metadata) => {
64
+ if (guarded) return;
65
+ const r = metadata;
66
+ if (!r || r.omitted) return;
67
+ const fmt = (arr, icon) => (arr || []).map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
68
+ if (settings.showRecall) {
69
+ const lines = [...fmt(r.packs, "📦"), ...fmt(r.entities, "👤"), ...fmt(r.episodes, "📓")];
70
+ if (lines.length) notify("banner:recall", `🧠 <b>Recall this turn</b> (${esc(r.engine || "")})\n${lines.join("\n")}`, htmlOpts(store));
71
+ else if (r.gated) notify("banner:recall", `🧠 <b>Recall</b> (${esc(r.engine || "")}): skipped by pre-gate — trivial turn.`, htmlOpts(store));
72
+ }
73
+ if (settings.showToolTrace) {
74
+ const toolLines = fmt(r.tools, "🔧");
75
+ if (toolLines.length) notify("banner:tools", `🔧 <b>Tools surfaced this turn</b>\n${toolLines.join("\n")}`, htmlOpts(store));
76
+ }
77
+ };
78
+
79
+ // Write-side announcements: a Skill invocation, or a Write/Edit landing in
80
+ // a pack / entity / tool / legacy-skill directory.
81
+ const noteWriteTool = (toolName, input) => {
82
+ try {
83
+ if (toolName === "Skill" && input?.skill) {
84
+ notify(`use:${input.skill}`, `Using skill: ${input.skill}`);
85
+ return;
86
+ }
87
+ const filePath = input?.file_path || input?.filePath;
88
+ if ((toolName === "Write" || toolName === "Edit") && filePath) {
89
+ const packDir = packsLib.packNameFromPath(filePath);
90
+ if (packDir) {
91
+ if (packsLib.readPack(packDir)) notify(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
92
+ else notify(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
93
+ try {
94
+ packsLib.recordForegroundWrite(packDir, {
95
+ tool: toolName,
96
+ oldString: input?.old_string ?? input?.oldString,
97
+ content: input?.content,
98
+ });
99
+ } catch (e) {}
100
+ return;
101
+ }
102
+ const entSlug = entitiesLib.entityNameFromPath(filePath);
103
+ if (entSlug) {
104
+ if (entitiesLib.readEntity(entSlug)) notify(`entity:${entSlug}`, `👤 Updating what I know about ${entSlug}…`);
105
+ else notify(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
106
+ return;
107
+ }
108
+ const toolName2 = toolsLib.toolNameFromPath(filePath);
109
+ if (toolName2) {
110
+ if (toolsLib.findTool(toolName2)) notifyToolTrace(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
111
+ else notifyToolTrace(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
112
+ return;
113
+ }
114
+ const dir = skillsLib.skillNameFromPath(filePath);
115
+ if (!dir) return;
116
+ // The tool_use event precedes the actual write, so existence now
117
+ // distinguishes a brand-new skill from a patch.
118
+ if (skillsLib.skillExists(dir)) notify(`write:${dir}`, `Updating skill: ${dir}`);
119
+ else notify(`write:${dir}`, `Learning new skill: ${dir} — /skills show ${dir} to inspect, /skills remove ${dir} to drop it.`);
120
+ }
121
+ } catch (e) { /* announcements are best-effort */ }
122
+ };
123
+
124
+ // Read-side recall from shell: "📖 Recalled my notes on …" fires when the
125
+ // agent actually pulls a full pack/entity via the CLI (it decided it was
126
+ // worth reading), not when a headline was auto-injected. Also records tool
127
+ // runs (🔧 lines gated by /tooltrace) and `tool add` saves.
128
+ const noteRecallFromShell = (command) => {
129
+ try {
130
+ const cmd = String(command || "");
131
+ if (!cmd.includes("open-claudia")) return;
132
+ let m;
133
+ const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
134
+ while ((m = packRe.exec(cmd))) {
135
+ const dir = m[1];
136
+ const pack = packsLib.readPack(dir);
137
+ const name = (pack && (pack.name || pack.dir)) || dir;
138
+ openedThisTurn.add(`pack:${dir}`);
139
+ notify(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
140
+ }
141
+ const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
142
+ while ((m = entRe.exec(cmd))) {
143
+ const slug = m[1];
144
+ const ent = entitiesLib.readEntity(slug);
145
+ const name = (ent && (ent.name || ent.slug)) || slug;
146
+ openedThisTurn.add(`entity:${slug}`);
147
+ notify(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
148
+ }
149
+ try {
150
+ for (const run of require("./tool-graph").parseToolRuns(cmd)) {
151
+ toolRunsThisTurn.push(run);
152
+ const verb = (String(run.shape || "").split(/\s+/)[1] || "");
153
+ let doc = "";
154
+ try { doc = toolsLib.verbDoc(run.name, verb); } catch (e) {}
155
+ notifyToolTrace(`ran:${run.name}:${verb}`, `🔧 ${run.shape || run.name}${doc ? ` — ${doc}` : ""}`);
156
+ }
157
+ } catch (e) { /* graph optional (old node) */ }
158
+ // `open-claudia tool add <src> [--name X]` copies an external file in,
159
+ // so the Write/Edit path above can't see it — detect it here.
160
+ const addRe = /\btool\s+add\s+(\S+)([^\n;|&]*)/gi;
161
+ while ((m = addRe.exec(cmd))) {
162
+ let name = (m[2].match(/--name\s+["']?([^\s"']+)/i) || [])[1];
163
+ if (!name) name = (String(m[1]).split(/[\\/]/).pop() || "").replace(/\.[^.]+$/, "");
164
+ name = toolsLib.sanitizeName(name);
165
+ if (name) notifyToolTrace(`added:${name}`, `🛠️ Saved tool: ${name} — open-claudia tool show ${name} to inspect.`);
166
+ }
167
+ } catch (e) { /* announcements are best-effort */ }
168
+ };
169
+
170
+ // The same 📖 signal when the agent opens a note with the Read tool instead
171
+ // of the CLI. Deduped against the CLI path via the shared notify key.
172
+ const noteRecallFromReadPath = (filePath) => {
173
+ try {
174
+ const node = require("./recall/read-signal").recallNodeFromPath(filePath);
175
+ if (!node) return;
176
+ openedThisTurn.add(`${node.kind}:${node.id}`);
177
+ notify(`recall:${node.kind}:${node.id}`, `📖 Recalled my notes on: ${node.name}`);
178
+ } catch (e) { /* announcements are best-effort */ }
179
+ };
180
+
181
+ const onToolStart = (name, detail) => {
182
+ const input = detail && typeof detail === "object" ? detail : {};
183
+ if (["Bash", "Shell"].includes(name)) {
184
+ const command = typeof detail === "string" ? detail : input.command;
185
+ if (command) noteRecallFromShell(command);
186
+ return;
187
+ }
188
+ if (name === "Read") {
189
+ const filePath = input.file_path || input.filePath;
190
+ if (filePath) noteRecallFromReadPath(filePath);
191
+ return;
192
+ }
193
+ noteWriteTool(name, input);
194
+ };
195
+
196
+ // End-of-turn graph feeding. Only learn from turns that actually completed:
197
+ // an errored turn is not evidence a recalled pattern "helped".
198
+ const onTurnSettled = (succeeded) => {
199
+ if (!succeeded) return;
200
+ // Hebbian co-use: nodes actually opened together get their `related`
201
+ // edges reinforced — co-USE (📖), never co-recall.
202
+ if (openedThisTurn.size > 0) {
203
+ try {
204
+ const recallGraph = require("./recall/graph");
205
+ if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
206
+ require("./recall/metrics").logUse([...openedThisTurn]);
207
+ } catch (e) { /* best-effort */ }
208
+ }
209
+ // Directed tool-graph: tools run in succession become follows-edges.
210
+ if (toolRunsThisTurn.length > 1) {
211
+ try { require("./tool-graph").reinforceSequence(toolRunsThisTurn.map((t) => t.name)); }
212
+ catch (e) { /* best-effort */ }
213
+ }
214
+ // Tool usage telemetry (runCount + lastUsed sidecar).
215
+ if (toolRunsThisTurn.length) {
216
+ try { for (const t of toolRunsThisTurn) toolsLib.recordRun(t.name); }
217
+ catch (e) { /* best-effort */ }
218
+ }
219
+ // Pack↔tool contextual edges: "this tool was used in this topic, as `…`".
220
+ if (toolRunsThisTurn.length && openedThisTurn.size) {
221
+ try {
222
+ const tg = require("./tool-graph");
223
+ const packDirs = [...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5));
224
+ for (const dir of packDirs) {
225
+ for (const run of toolRunsThisTurn) tg.recordPackTool(dir, run.name, { command: run.shape, bump: 1 });
226
+ }
227
+ } catch (e) { /* best-effort */ }
228
+ }
229
+ // Ability transfer: an ability pack opened alongside a project pack was
230
+ // demonstrably applied there — grow applied_on now, before the reviewer.
231
+ if (openedThisTurn.size > 1) {
232
+ try {
233
+ for (const t of packsLib.recordCoUse([...openedThisTurn])) {
234
+ notify(`applied:${t.ability}:${t.project}`,
235
+ `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} — it transfers there now too.`);
236
+ }
237
+ } catch (e) { /* best-effort */ }
238
+ }
239
+ };
240
+
241
+ return { onRecallSurfaced, onToolStart, onTurnSettled };
242
+ }
243
+
244
+ module.exports = { createTurnObserver };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.6",
3
+ "version": "3.0.8",
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": {
@@ -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.deepStrictEqual(provider.modelChoices(), ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
49
+ assert.deepStrictEqual(provider.modelChoices(), ["gpt-5.6-sol", "gpt-5.6-sol-pro", "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",
@@ -164,7 +164,7 @@ assert.throws(
164
164
  (error) => error && error.code === "UNSUPPORTED_PROVIDER_CAPABILITY",
165
165
  );
166
166
  assert.throws(
167
- () => provider.buildMainInvocation(simpleContext({ providerSettings: { effort: "max", budget: null, worktree: false } })),
167
+ () => provider.buildMainInvocation(simpleContext({ providerSettings: { effort: "bogus", budget: null, worktree: false } })),
168
168
  (error) => error && error.code === "UNSUPPORTED_PROVIDER_SETTING",
169
169
  );
170
170
  assert.throws(
@@ -243,7 +243,7 @@ async function providerProbe(kind) {
243
243
  assert.ok(record.argv.includes("max"), "Claude retains maximum dream effort");
244
244
  } else {
245
245
  assert.ok(record.argv.includes("--output-schema"));
246
- assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*xhigh/.test(arg)), "generic max effort maps to Codex's strongest supported value");
246
+ assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*max/.test(arg)), "max effort passes through natively since codex 0.144 supports it");
247
247
  }
248
248
  }
249
249
 
@@ -32,11 +32,11 @@ 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.6 approved by Sumeet 2026-07-12 ("Yeh you can do that too" — coldStart
36
- // flag on usage records so restart cache-bust burn, measured at ~8% of all
37
- // spend from the 22-day history, is a one-line query instead of a forensic
38
- // reconstruction).
39
- assert.strictEqual(pkg.version, "3.0.6", "release version must remain unchanged without explicit approval");
35
+ // 3.0.8 approved by Sumeet 2026-07-12 (fast-path release after the v3.0.7
36
+ // docker image was cancelled mid-build: same code as 3.0.7 zombie-poll-loop
37
+ // fix + turn-observer restore plus the pre-baked :base CI image and codex
38
+ // gpt-5.6-sol-pro / max / ultra support).
39
+ assert.strictEqual(pkg.version, "3.0.8", "release version must remain unchanged without explicit approval");
40
40
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
41
41
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
42
42
  }
@@ -39,9 +39,16 @@ async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
39
39
  }
40
40
  }
41
41
 
42
- async function testHealCancelsWedgedLongPollBeforeRestarting() {
42
+ // stopPolling({cancel:true}) leaks the poll loop (Bluebird runs the chain's
43
+ // .finally on cancel with `_abort` still false, rescheduling _polling) — the
44
+ // zombie loops then 409 each other forever (the 15-socket pile-up of
45
+ // 2026-07-12). Recovery must therefore use a PLAIN stop (sets _abort before
46
+ // the request settles) and destroy the agent so a hung half-open getUpdates
47
+ // settles now instead of never.
48
+ async function testHealStopsPlainlyDestroysAgentAndRestartsIdle() {
43
49
  const calls = [];
44
50
  let stopOptions;
51
+ let startOptions;
45
52
  const adapter = Object.create(TelegramAdapter.prototype);
46
53
  adapter._healTimer = null;
47
54
  adapter._healthyTimer = null;
@@ -55,7 +62,8 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
55
62
  stopOptions = options;
56
63
  calls.push("stop");
57
64
  },
58
- async startPolling() {
65
+ async startPolling(options) {
66
+ startOptions = options;
59
67
  calls.push("start");
60
68
  },
61
69
  };
@@ -75,17 +83,93 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
75
83
  global.clearTimeout = realClearTimeout;
76
84
  }
77
85
 
78
- assert.deepStrictEqual(
79
- stopOptions,
80
- { cancel: true, reason: "stale Telegram long-poll recovery" },
81
- "recovery must cancel a wedged getUpdates request rather than await it forever",
82
- );
83
- assert.deepStrictEqual(calls, ["stop", "destroy", "start"]);
86
+ assert.strictEqual(stopOptions && stopOptions.cancel, undefined,
87
+ "recovery must NOT cancel-stop: Bluebird cancellation leaks a zombie poll loop that 409s the next one");
88
+ assert.strictEqual(stopOptions && stopOptions.reason, "stale Telegram long-poll recovery");
89
+ assert.deepStrictEqual(calls, ["stop", "destroy", "start"],
90
+ "recovery must sever the hung socket (agent.destroy) between stop and start");
91
+ assert.strictEqual(startOptions && startOptions.restart, false,
92
+ "restart must be idle-only: the lib's restart path is another cancel-stop (zombie factory)");
93
+ }
94
+
95
+ async function testStopPollingCleanDoesNotHangOnAWedgedStop() {
96
+ const adapter = Object.create(TelegramAdapter.prototype);
97
+ adapter._agent = { destroy() {} };
98
+ adapter.bot = {
99
+ stopPolling() { return new Promise(() => {}); }, // never settles
100
+ };
101
+
102
+ const realSetTimeout = global.setTimeout;
103
+ global.setTimeout = (fn, ms) => {
104
+ if (ms === 5000) fn(); // the cap fires
105
+ return { ms };
106
+ };
107
+ try {
108
+ const outcome = await Promise.race([
109
+ adapter._stopPollingClean("test").then(() => "returned"),
110
+ new Promise((resolve) => realSetTimeout(() => resolve("hung"), 25)),
111
+ ]);
112
+ assert.strictEqual(outcome, "returned", "_stopPollingClean must cap the wait when the stop promise never settles");
113
+ } finally {
114
+ global.setTimeout = realSetTimeout;
115
+ }
116
+ }
117
+
118
+ async function test409ConflictSeversGhostSocketAndRestartsIdle() {
119
+ const calls = [];
120
+ let stopOptions;
121
+ let startOptions;
122
+ const adapter = Object.create(TelegramAdapter.prototype);
123
+ adapter.ownerChatId = null;
124
+ adapter._conflictSince = 0;
125
+ adapter._conflictTimer = null;
126
+ adapter._conflictClearTimer = null;
127
+ adapter._conflictAlertAt = 0;
128
+ adapter._conflictAlerted = false;
129
+ adapter._agent = {
130
+ destroy() { calls.push("destroy"); },
131
+ };
132
+ adapter.bot = {
133
+ async stopPolling(options) {
134
+ stopOptions = options;
135
+ calls.push("stop");
136
+ },
137
+ async startPolling(options) {
138
+ startOptions = options;
139
+ calls.push("start");
140
+ },
141
+ };
142
+
143
+ const timers = [];
144
+ const realSetTimeout = global.setTimeout;
145
+ const realClearTimeout = global.clearTimeout;
146
+ global.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return { ms }; };
147
+ global.clearTimeout = () => {};
148
+
149
+ try {
150
+ adapter._on409Conflict();
151
+ // Let the fire-and-forget clean stop settle.
152
+ await new Promise((resolve) => realSetTimeout(resolve, 5));
153
+ assert.strictEqual(stopOptions && stopOptions.cancel, undefined,
154
+ "409 backoff must NOT cancel-stop (zombie loop leak)");
155
+ assert.ok(calls.includes("destroy"),
156
+ "409 backoff must destroy the agent to sever the ghost getUpdates holding the token");
157
+ const retry = timers.find((t) => t.ms === 15000);
158
+ assert.ok(retry, "409 backoff must queue a paused retry");
159
+ await retry.fn();
160
+ assert.strictEqual(startOptions && startOptions.restart, false,
161
+ "409 retry must restart idle-only, never through the lib's cancel-restart path");
162
+ } finally {
163
+ global.setTimeout = realSetTimeout;
164
+ global.clearTimeout = realClearTimeout;
165
+ }
84
166
  }
85
167
 
86
168
  (async () => {
87
169
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
88
- await testHealCancelsWedgedLongPollBeforeRestarting();
170
+ await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
171
+ await testStopPollingCleanDoesNotHangOnAWedgedStop();
172
+ await test409ConflictSeversGhostSocketAndRestartsIdle();
89
173
  console.log("telegram poll recovery OK");
90
174
  })().catch((err) => {
91
175
  console.error(err.stack || err);