@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 +5 -26
- package/channels/telegram/adapter.js +26 -13
- package/core/providers/codex.js +4 -2
- package/core/runner.js +24 -1
- package/core/turn-observer.js +244 -0
- package/package.json +1 -1
- package/test-provider-codex.js +2 -2
- package/test-provider-dream.js +1 -1
- package/test-provider-language.js +5 -5
- package/test-telegram-poll-recovery.js +93 -9
package/Dockerfile
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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;
|
package/core/providers/codex.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
const
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
package/test-provider-codex.js
CHANGED
|
@@ -46,7 +46,7 @@ assert.strictEqual(provider.label, "OpenAI Codex");
|
|
|
46
46
|
assert.strictEqual(provider.isAvailable(), true);
|
|
47
47
|
assert.strictEqual(provider.executable(), "/fixture/codex");
|
|
48
48
|
assert.strictEqual(provider.defaultModel(), "gpt-5-codex");
|
|
49
|
-
assert.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: "
|
|
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(
|
package/test-provider-dream.js
CHANGED
|
@@ -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=.*
|
|
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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
assert.strictEqual(pkg.version, "3.0.
|
|
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
|
-
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
assert.
|
|
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
|
|
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);
|