@inetafrica/open-claudia 2.6.54 → 2.6.56

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/.env.example CHANGED
@@ -21,6 +21,9 @@ CLAUDE_MODEL=claude-fable-5
21
21
  CURSOR_PATH=
22
22
  CODEX_PATH=
23
23
  AUTO_COMPACT_TOKENS=280000
24
+ # Inactivity watchdog: kill a heavy turn's child if it emits no output for
25
+ # this many ms, so a hung tool call can't wedge the bot. Default 600000 (10min).
26
+ OC_TURN_IDLE_MS=600000
24
27
  USAGE_ALERT_CONTEXT_TOKENS=120000
25
28
  USAGE_ALERT_RATE_MULTIPLIER=1.75
26
29
  USAGE_ALERT_BASELINE_TURNS=20
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.56
4
+ - **Stop a single hung child from wedging the whole bot.** Agent mode is single-flight: a heavy turn spawns one child and sets a global `runningProcess` flag; while it's set, every new message is demoted to a lightweight side-chat instead of a full turn. That flag is *only* ever cleared by the child's `close`/`error` events — so a child that hangs (a wedged tool call that never returns) pins it forever. That's exactly what happened: one turn went silent, its child never exited, and from then on the bot stayed alive (health checks passed) but answered nothing of substance — every later message fell through to a side-chat that also couldn't make progress. The fix is an **inactivity watchdog** in `runClaude` (`bot-agent.js`): track `lastActivity`, bumped on every `stdout`/`stderr` chunk from the child, and inside the existing progress-update loop (which already ticks every 2–5s while a task runs) kill the child if it produces no output for longer than the idle limit — SIGTERM to the process group, SIGKILL 3s later, plus a 10s failsafe that force-clears the flag if `close` somehow never fires. The watchdog short-circuits the close handler so the user gets one clear "task stalled — stopped it, I'm responsive again, send /continue" message instead of a confusing "(no output)". Default idle limit is 10 minutes, tunable via `OC_TURN_IDLE_MS`. Deliberately scoped to the wedge only — the v2.6.55 polling fix is healthy and untouched.
5
+
6
+ ## v2.6.55
7
+ - **Make fixing a tool as cheap as creating one.** The tool subsystem was structurally tilted toward sprawl: creating a tool was a trusting one-liner with no validation, while *fixing* one had no verb at all — so the path of least resistance was always another near-duplicate. This release closes that gap from both ends. **A first-class fix verb:** `open-claudia tool set <name> [--desc|--pack|--requires|--usage]` rewrites a tool's header metadata in place (new `updateToolHeader()` in `core/tools.js`) — it refreshes only the recognised field lines, preserving the marker, any hand-written comments, and the immutable `createdAt`, and never touches the body. `open-claudia tool edit <name>` prints the source path (stdout) so the agent's Edit/Write or the user's `$EDITOR` can open it directly, with the metadata-only hint on stderr. **An add-time safety net** (all advisory, never blocking — a tool the agent just wrote is usually right): `tool add`/`tool set` now lint the source and surface (1) hardcoded secrets — `sk-ant-`/`sk-proj-`/`sk-`/`AKIA`/`gh*_`/`xox*`/PEM/inline-Bearer literals that should be `$KEYRING` refs, mirroring the redactor; (2) requires/keyring mismatches — keyring keys the script reads but `--requires` omits (with a ready-to-paste `tool set` fix), and declared keys it never reads; (3) syntax errors via the script's own interpreter (`bash -n` / `node --check` / `py_compile`, silent when the interpreter or type is unknown — never cries wolf); and (4) a dangling `--pack` link to a pack that doesn't exist. **Immutable creation telemetry:** the `.usage.json` sidecar now stamps `createdAt` once at add-time (surfaced on every tool record); the nightly dream's "cold since creation" check ages by this stamp instead of file mtime, so a body edit can no longer reset a never-run tool's staleness clock (falls back to mtime for tools created before the stamp existed). Extends `test-tools.js` (createdAt immutability, `envRefsIn`, `lintToolSource`, `checkSyntax`, `updateToolHeader`).
8
+ - **Stop the phantom-outage restart storm — just keep polling.** The Telegram adapter was killing the bot over network blips that weren't real. A side-by-side network monitor (probing 1.1.1.1 *and* api.telegram.org every 60s) showed the connection healthy throughout, yet `bot.log` had logged 20k+ `EFATAL: read ETIMEDOUT` → "Network lost" events: the long-poll's pooled keep-alive socket goes stale on a macOS sleep/wake or an idle-NAT eviction, and every reused poll then times out instantly. The old handler treated *any* such timeout as a lost network — loudly stopping/restarting polling and, after 6 quick attempts (a sleep/wake error burst exhausts those in seconds), calling `process.exit(1)` for launchd to relaunch. That voluntary suicide *was* the "why does it keep restarting" the bot kept doing. Three changes in `channels/telegram/adapter.js`: (1) **fresh socket per poll** (`keepAlive:false`) so there's no pooled socket to go stale — the root of the ETIMEDOUT storm; (2) **gentle, throttled recovery** — a transient timeout no longer screams or tears down on the first hit (with keep-alive off the next poll self-heals on a new connection); it logs at most once a minute and only actively restarts polling if errors persist; (3) **never exit over a network blip** — only a `409 Conflict` (a second poller on the same token) exits immediately; a genuinely wedged loop now has to fail unbroken for a full 10 minutes before a clean relaunch, versus the old ~90s hair-trigger. The diagnostic `netmon.sh` that proved the network was fine stays running.
9
+
3
10
  ## v2.6.54
4
11
  - **Tools as first-class discoverer nodes.** Reusable tools now go through the same recall pipeline as packs and entities — seed → activate → judge → render — instead of being dumped wholesale into every prompt. Until now `buildToolIndexBlock` listed *all* ~40 tools on every turn; that flat block is gone. In its place, the discoverer (`core/recall/discoverer.js`) seeds tool candidates from a new in-memory `matchTools()` (lexical scoring over name/description = strong, requires/pack/source = weak; `core/tools.js`), activates the tools belonging to any pack already in the candidate set (read from the pack↔tool graph + tool `pack:` headers — the latency-sensitive recall `expand()` stays pack/entity-only, zero regression risk), and lets the haiku walker judge them with a "keep a tool only if running it would actually help" instruction. Survivors render into a focused **"Tools that may help here"** block; the system prompt now just states the count and points to `tool search`/`tool list` for the rest. `matchTools` is in-memory (works on every node version, unlike the SQLite graphs).
5
12
  - **`/tooltrace` toggle (default off).** Mirrors `/recall`. When on, posts short 🔧 lines around each reply: which tools were **surfaced** for the turn, which were **run**, and any **created/updated** — so you can watch the tool layer work. All three signals are now gated behind this one toggle (`settings.showToolTrace`); the run/created/updated banners were removed from the always-on path and the surfaced list was split out of the 🧠 `/recall` banner, so the two toggles are cleanly independent.
package/bin/cli.js CHANGED
@@ -370,7 +370,7 @@ Memory tools:
370
370
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
371
371
  (alias: tw; --help for options)
372
372
  open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
373
- open-claudia tool list|search|show|add|run|remove Reusable tools: executable scripts the agent saves & re-runs (keyring preauth)
373
+ open-claudia tool list|search|show|add|set|edit|run|remove Reusable tools: executable scripts the agent saves & re-runs (keyring preauth)
374
374
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
375
375
  open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
376
376
  open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
package/bin/tool.js CHANGED
@@ -7,10 +7,18 @@
7
7
  // open-claudia tool show <name> — full docs, path, keyring status
8
8
  // open-claudia tool add <path> [--name n] — register a script as a tool
9
9
  // [--pack <dir>] [--desc "..."] [--requires "k1,k2"] [--usage "..."]
10
+ // open-claudia tool set <name> [--desc ...] — fix a tool's metadata in place
11
+ // [--pack <dir>] [--requires "k1,k2"] [--usage "..."]
12
+ // open-claudia tool edit <name> — print the file path to open & edit
10
13
  // open-claudia tool run <name> [args...] — run it with keyring pre-loaded
11
14
  // open-claudia tool remove <name> — delete one
12
15
  // open-claudia tool path — print the tools directory
13
16
  //
17
+ // Fix before fork: when a tool is wrong, prefer `tool set` (metadata) or `tool
18
+ // edit` (open the file) over adding a near-duplicate. `add` lints the source on
19
+ // save — flags hardcoded secrets, requires/keyring mismatches, syntax errors,
20
+ // and dangling pack links — all advisory, never blocking.
21
+ //
14
22
  // Credentials: a tool runs with the operational keyring merged into its env, so
15
23
  // reference creds as $NAME inside the script. `open-claudia keyring list` shows
16
24
  // what's available; never hardcode secrets in a tool.
@@ -34,6 +42,48 @@ function parseFlags(argv) {
34
42
  return { positional, flags };
35
43
  }
36
44
 
45
+ // Advisory safety report for a freshly-added or just-edited tool. Never blocks —
46
+ // a tool the agent just wrote is usually right; we surface risks and let the
47
+ // human/agent decide. Covers: hardcoded secrets, requires/keyring mismatch,
48
+ // syntax errors (when we have the interpreter), and dangling pack links.
49
+ function reportSafety(t) {
50
+ // Hardcoded secrets + requires/keyring reconciliation.
51
+ try {
52
+ const { secrets, undeclared, unused } = tools.lintToolSource(t.content, { requires: t.requires });
53
+ if (secrets.length) {
54
+ console.log(`\n⚠ Hardcoded secret(s) detected: ${secrets.join(", ")}.`);
55
+ console.log(" Replace with a $KEYRING_VAR reference — keyring perms + log redaction only protect creds stored there.");
56
+ }
57
+ if (undeclared.length) {
58
+ console.log(`\n⚠ Reads keyring key(s) not in --requires: ${undeclared.join(", ")}.`);
59
+ console.log(` Add them so a missing key is caught up-front: open-claudia tool set ${t.name} --requires "${[...t.requires, ...undeclared].join(",")}"`);
60
+ }
61
+ if (unused.length) {
62
+ console.log(`\nNote: --requires lists key(s) the script never reads: ${unused.join(", ")}.`);
63
+ }
64
+ } catch (e) { /* lint optional */ }
65
+
66
+ // Syntax check (only when we recognise + have the interpreter).
67
+ try {
68
+ const syn = tools.checkSyntax(t.file, t.content);
69
+ if (syn && !syn.ok) {
70
+ console.log(`\n⚠ Syntax check failed:\n${syn.error.split("\n").map((l) => " " + l).join("\n")}`);
71
+ console.log(` Fix it: open-claudia tool edit ${t.name}`);
72
+ }
73
+ } catch (e) { /* syntax check optional */ }
74
+
75
+ // Dangling pack link.
76
+ if (t.pack) {
77
+ try {
78
+ const pack = require("../core/packs").readPack(t.pack);
79
+ if (!pack) {
80
+ console.log(`\n⚠ Linked skill pack "${t.pack}" doesn't exist.`);
81
+ console.log(` Create it (open-claudia pack ...) or relink: open-claudia tool set ${t.name} --pack <existing-dir>`);
82
+ }
83
+ } catch (e) { /* packs optional */ }
84
+ }
85
+ }
86
+
37
87
  function run(args) {
38
88
  const cmd = (args[0] || "list").toLowerCase();
39
89
  const rest = args.slice(1);
@@ -132,6 +182,9 @@ function run(args) {
132
182
  const missing = tools.missingRequires(t);
133
183
  if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
134
184
  }
185
+ // Add-time safety net (advisory): secrets, requires/keyring mismatch,
186
+ // syntax, dangling pack link.
187
+ reportSafety(t);
135
188
  // Similarity guard (warn, don't block): if existing tools already cover
136
189
  // this, prefer extending one with a subcommand over a near-duplicate.
137
190
  try {
@@ -149,6 +202,50 @@ function run(args) {
149
202
  break;
150
203
  }
151
204
 
205
+ case "set":
206
+ case "fix": {
207
+ const { positional, flags } = parseFlags(rest);
208
+ const name = positional[0];
209
+ if (!name) {
210
+ console.error('Usage: tool set <name> [--desc "..."] [--pack dir] [--requires "k1,k2"] [--usage "..."]');
211
+ process.exitCode = 1; return;
212
+ }
213
+ if (!tools.findTool(name)) {
214
+ console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return;
215
+ }
216
+ const patch = {};
217
+ if (typeof flags.desc === "string") patch.description = flags.desc;
218
+ else if (typeof flags.description === "string") patch.description = flags.description;
219
+ if (typeof flags.pack === "string") patch.pack = flags.pack;
220
+ if (typeof flags.requires === "string") patch.requires = flags.requires.split(/[,\s]+/).filter(Boolean);
221
+ if (typeof flags.usage === "string") patch.usage = flags.usage;
222
+ if (Object.keys(patch).length === 0) {
223
+ console.error('Nothing to set. Pass at least one of --desc / --pack / --requires / --usage. Body edits: open-claudia tool edit ' + name);
224
+ process.exitCode = 1; return;
225
+ }
226
+ const updated = tools.updateToolHeader(name, patch);
227
+ if (!updated) { console.error(`Could not update "${name}".`); process.exitCode = 1; return; }
228
+ console.log(`Updated tool "${updated.name}".`);
229
+ console.log(` Description: ${updated.description || "(none)"}`);
230
+ if (updated.pack) console.log(` Skill pack: ${updated.pack}`);
231
+ if (updated.requires.length) console.log(` Requires: ${updated.requires.join(", ")}`);
232
+ if (updated.usage) console.log(` Usage: ${updated.usage}`);
233
+ reportSafety(updated);
234
+ break;
235
+ }
236
+
237
+ case "edit": {
238
+ const name = rest[0];
239
+ if (!name) { console.error("Usage: tool edit <name>"); process.exitCode = 1; return; }
240
+ const t = tools.findTool(name);
241
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
242
+ // Body edits are an Edit/Write job — print the path so the agent (or the
243
+ // user's $EDITOR) can open it directly. Metadata fixes: open-claudia tool set.
244
+ console.log(t.file);
245
+ console.error(`Open and edit the source above. Header/metadata only: open-claudia tool set ${t.name} [flags]`);
246
+ break;
247
+ }
248
+
152
249
  case "run":
153
250
  case "exec": {
154
251
  const name = rest[0];
@@ -180,7 +277,7 @@ function run(args) {
180
277
  break;
181
278
 
182
279
  default:
183
- console.log('Usage: open-claudia tool [list | search <query> | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
280
+ console.log('Usage: open-claudia tool [list | search <query> | show <name> | add <path> [flags] | set <name> [flags] | edit <name> | run <name> [args] | remove <name> | path]');
184
281
  }
185
282
  }
186
283
 
package/bot-agent.js CHANGED
@@ -1259,6 +1259,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1259
1259
  const startTime = Date.now();
1260
1260
  let longRunningNotified = false;
1261
1261
 
1262
+ // Inactivity watchdog. The single-flight `runningProcess` flag is only
1263
+ // cleared by this child's close/error events, so a child that hangs
1264
+ // (e.g. a wedged tool call) pins the flag forever and demotes every later
1265
+ // message to a side-chat. Track the last time the child produced output;
1266
+ // if it goes silent past the idle limit, kill it so the flag clears and
1267
+ // the bot responds again. Tune with OC_TURN_IDLE_MS (default 10min).
1268
+ const idleLimitMs = Math.max(60000, parseInt(process.env.OC_TURN_IDLE_MS, 10) || 10 * 60 * 1000);
1269
+ let lastActivity = Date.now();
1270
+ let watchdogTripped = false;
1271
+
1262
1272
  let lastUpdate = "";
1263
1273
  // Adaptive update interval: 2s for first 2min, then 5s to avoid rate limits
1264
1274
  const scheduleUpdate = () => {
@@ -1268,6 +1278,29 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1268
1278
  };
1269
1279
  const updateProgress = async () => {
1270
1280
  const elapsed = Math.floor((Date.now() - startTime) / 1000);
1281
+ // Inactivity watchdog: kill a child that has gone silent past the idle limit.
1282
+ if (!watchdogTripped && runningProcess === proc && Date.now() - lastActivity > idleLimitMs) {
1283
+ watchdogTripped = true;
1284
+ const idleMin = Math.floor((Date.now() - lastActivity) / 60000);
1285
+ const pid = proc.pid;
1286
+ console.error(`[watchdog] child pid ${pid} idle ${idleMin}min (limit ${Math.floor(idleLimitMs / 60000)}min) — killing`);
1287
+ try { process.kill(-pid, "SIGTERM"); } catch (e) {
1288
+ try { proc.kill("SIGTERM"); } catch (e2) {}
1289
+ }
1290
+ setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch (e) {} }, 3000);
1291
+ // Failsafe: if close never fires, force-clear so the bot recovers anyway.
1292
+ setTimeout(() => {
1293
+ if (runningProcess === proc) {
1294
+ runningProcess = null; runningProcessPrompt = null;
1295
+ clearTimeout(streamInterval); streamInterval = null;
1296
+ }
1297
+ }, 10000);
1298
+ try {
1299
+ await send(`Task stalled — no activity for ${idleMin}min, so I stopped it and I'm responsive again. Send /continue to resume, or just re-ask.`);
1300
+ } catch (e) {}
1301
+ statusMessageId = null;
1302
+ return; // stop the progress loop; close (or failsafe) clears state
1303
+ }
1271
1304
  try {
1272
1305
  bot.sendChatAction(CHAT_ID, "typing").catch(() => {});
1273
1306
  const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
@@ -1292,6 +1325,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1292
1325
  scheduleUpdate();
1293
1326
 
1294
1327
  proc.stdout.on("data", (data) => {
1328
+ lastActivity = Date.now();
1295
1329
  streamBuffer += data.toString();
1296
1330
  const events = parseStreamEvents(streamBuffer);
1297
1331
  const lastNewline = streamBuffer.lastIndexOf("\n");
@@ -1367,6 +1401,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1367
1401
 
1368
1402
  let stderrBuffer = "";
1369
1403
  proc.stderr.on("data", (d) => {
1404
+ lastActivity = Date.now();
1370
1405
  const chunk = d.toString();
1371
1406
  stderrBuffer += chunk;
1372
1407
  console.error("STDERR:", redactSensitive(chunk));
@@ -1375,6 +1410,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1375
1410
  proc.on("close", async (code) => {
1376
1411
  runningProcess = null; runningProcessPrompt = null;
1377
1412
  clearTimeout(streamInterval); streamInterval = null;
1413
+ if (watchdogTripped) {
1414
+ // The inactivity watchdog already notified the user and killed the
1415
+ // child; don't also deliver partial / "(no output)" text.
1416
+ statusMessageId = null;
1417
+ if (settings.budget) settings.budget = null;
1418
+ return;
1419
+ }
1378
1420
  if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1379
1421
  await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
1380
1422
  return;
@@ -19,16 +19,23 @@ class TelegramAdapter {
19
19
  this.token = token;
20
20
  this.ownerChatId = ownerChatId;
21
21
  this.chatIds = chatIds || [];
22
- // Own the HTTP(S) agent so we can drop dead keep-alive sockets on reconnect.
23
- this._agent = new https.Agent({ keepAlive: true });
22
+ // Dial a fresh socket per poll (keepAlive:false). The long-poll sits idle up
23
+ // to 30s; with a *pooled* keep-alive socket, a macOS sleep/wake or an idle-NAT
24
+ // eviction silently kills that one socket and every later poll reuses it and
25
+ // ETIMEDOUTs — forever. A new connection each poll (~every 30s, trivial for
26
+ // one bot) sidesteps the stale-socket trap that drove the old restart storm.
27
+ // We still own the agent so a heal can destroy any lingering socket.
28
+ this._agent = new https.Agent({ keepAlive: false });
24
29
  this.bot = new TelegramBot(token, {
25
30
  polling: { autoStart: false, params: { timeout: 30 } },
26
31
  request: { agent: this._agent },
27
32
  });
28
33
  this._listeners = { message: new Set(), action: new Set() };
29
- this._reconnectTimer = null;
30
- this._recoveryTimer = null;
31
- this._reconnectAttempts = 0;
34
+ this._healTimer = null; // one active recovery in flight at a time
35
+ this._healthyTimer = null; // fires once polling's been quiet long enough
36
+ this._wedgedSince = 0; // start of the current unrecovered error streak
37
+ this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
38
+ this._healBackoff = 0; // grows per consecutive heal, capped
32
39
  this._wireInbound();
33
40
  }
34
41
 
@@ -50,60 +57,73 @@ class TelegramAdapter {
50
57
  }
51
58
 
52
59
  async stop() {
53
- if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
54
- if (this._recoveryTimer) { clearTimeout(this._recoveryTimer); this._recoveryTimer = null; }
60
+ if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
61
+ if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
55
62
  try { await this.bot.stopPolling(); } catch (e) {}
56
63
  }
57
64
 
58
- // Recover from a wedged poll loop. The library reuses one keep-alive socket;
59
- // after a sleep/wake or network blip that socket dies and every poll then
60
- // ETIMEDOUTs forever restarting polling alone reuses the same dead socket.
61
- // So we destroy the agent (forcing a fresh connection), back off, and if we
62
- // still can't recover after several tries we exit so launchd (KeepAlive)
63
- // relaunches a fully clean process.
64
- _scheduleReconnect() {
65
- if (this._reconnectTimer) return; // one cycle in flight; swallow the flood
66
- if (this._recoveryTimer) { clearTimeout(this._recoveryTimer); this._recoveryTimer = null; }
67
- this._reconnectAttempts += 1;
68
- const MAX = 6;
69
- if (this._reconnectAttempts > MAX) {
70
- console.error(`Polling unrecoverable after ${MAX} attempts — exiting for supervisor restart.`);
65
+ // A transient network error on the long-poll. The network is almost never
66
+ // actually down netmon shows it healthy while these fire it's a single
67
+ // stale socket. So we DON'T scream "Network lost" and tear everything down on
68
+ // the first timeout the way the old code did: with keep-alive off the next
69
+ // poll already dials fresh and self-heals. We log at most once a minute, only
70
+ // actively recover if errors persist, and NEVER exit over a network blip — the
71
+ // old exit-for-relaunch *was* the restart the bot kept doing on every idle
72
+ // timeout (a sleep/wake burst could exhaust all 6 attempts in seconds).
73
+ _onPollingHiccup(msg) {
74
+ const now = Date.now();
75
+ if (!this._wedgedSince) this._wedgedSince = now;
76
+ if (now - this._lastHiccupLog > 60000) {
77
+ this._lastHiccupLog = now;
78
+ console.log(`Polling hiccup (${msg}) — retrying on a fresh connection.`);
79
+ }
80
+ if (this._healTimer) return; // a heal is already scheduled; let it run
81
+ this._healBackoff = Math.min(this._healBackoff + 1, 5);
82
+ const delay = Math.min(15000, 500 * 2 ** this._healBackoff); // 1s … 15s
83
+ this._healTimer = setTimeout(() => this._heal(), delay);
84
+ }
85
+
86
+ async _heal() {
87
+ this._healTimer = null;
88
+ // Backstop only: if polling has been unbroken-wedged for 10 minutes, a
89
+ // restart-in-place won't fix it — exit for a clean launchd relaunch. On a
90
+ // healthy network this never fires (heals recover in seconds); it replaces
91
+ // the old hair-trigger ~90s exit that caused the restart storm.
92
+ if (this._wedgedSince && Date.now() - this._wedgedSince > 600000) {
93
+ console.error("Polling wedged >10min — exiting for a clean restart.");
71
94
  process.exit(1);
72
95
  }
73
- const delay = Math.min(30000, 1000 * 2 ** this._reconnectAttempts); // 2s,4s,8s,16s,30s,30s
74
- console.log(`Network lost. Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this._reconnectAttempts}/${MAX})...`);
75
- this._reconnectTimer = setTimeout(async () => {
76
- this._reconnectTimer = null;
77
- try {
78
- try { await this.bot.stopPolling(); } catch (e) {}
79
- try { this._agent.destroy(); } catch (e) {} // drop the dead pooled socket
80
- await new Promise((r) => setTimeout(r, 1000));
81
- await this.bot.startPolling();
82
- console.log("Polling restarted.");
83
- // No fresh error within 30s ⇒ genuinely recovered; reset the counter.
84
- this._recoveryTimer = setTimeout(() => {
85
- this._recoveryTimer = null;
86
- if (this._reconnectAttempts) console.log("Connection healthy again.");
87
- this._reconnectAttempts = 0;
88
- }, 30000);
89
- } catch (e) {
90
- console.error("Reconnect attempt failed:", e.message);
91
- this._scheduleReconnect(); // back off and retry; don't die on first miss
92
- }
93
- }, delay);
96
+ try {
97
+ try { await this.bot.stopPolling(); } catch (e) {}
98
+ try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
99
+ await new Promise((r) => setTimeout(r, 500));
100
+ await this.bot.startPolling();
101
+ } catch (e) {
102
+ return; // restart didn't take the next hiccup reschedules a heal
103
+ }
104
+ // Quiet for 30s after a restart ⇒ genuinely recovered.
105
+ if (this._healthyTimer) clearTimeout(this._healthyTimer);
106
+ this._healthyTimer = setTimeout(() => {
107
+ this._healthyTimer = null;
108
+ if (this._wedgedSince) console.log("Polling healthy again.");
109
+ this._wedgedSince = 0;
110
+ this._healBackoff = 0;
111
+ }, 30000);
94
112
  }
95
113
 
96
114
  _wireInbound() {
97
115
  this.bot.on("polling_error", (err) => {
98
116
  const msg = err.message || "";
99
- console.error("Polling error:", msg);
100
117
  if (msg.includes("409 Conflict")) {
118
+ // Two pollers can't share one token — this one must bow out.
101
119
  console.error("Another instance is polling. Exiting.");
102
120
  process.exit(1);
103
121
  }
104
122
  if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
105
- this._scheduleReconnect();
123
+ this._onPollingHiccup(msg); // transient — heal quietly, never exit
124
+ return;
106
125
  }
126
+ console.error("Polling error:", msg); // unexpected — surface, keep polling
107
127
  });
108
128
 
109
129
  this.bot.on("message", (msg) => {
package/core/dream.js CHANGED
@@ -844,11 +844,15 @@ async function runDream({ trigger = "manual" } = {}) {
844
844
  bits.push(`🔗 ${dangling.length} tool(s) link a missing skill pack: ${dangling.map((t) => `${t.name}→${t.pack}`).join(", ")} — re-link with --pack or remove.`);
845
845
  }
846
846
  // Zero-run tools: created but never run and gone cold. Telemetry runCount
847
- // is authoritative; file mtime stands in for "how long ago". Flag only —
848
- // a never-used tool may still be worth keeping, so the user decides.
847
+ // is authoritative; createdAt measures "how long since it was made" (a body
848
+ // edit bumps mtime and would reset the clock, so prefer the immutable stamp
849
+ // and fall back to mtime only for tools added before createdAt existed).
850
+ // Flag only — a never-used tool may still be worth keeping, user decides.
849
851
  const STALE_DAYS = Number(process.env.TOOL_STALE_DAYS || 30);
850
852
  const cutoff = Date.now() - STALE_DAYS * 86400000;
851
853
  const coldUnused = allTools.filter((t) => !(t.runCount > 0) && !t.lastUsed).filter((t) => {
854
+ const born = t.createdAt ? Date.parse(t.createdAt) : NaN;
855
+ if (!Number.isNaN(born)) return born < cutoff;
852
856
  try { return fs.statSync(t.file).mtimeMs < cutoff; } catch (e) { return false; }
853
857
  });
854
858
  if (coldUnused.length) {
package/core/tools.js CHANGED
@@ -66,6 +66,20 @@ function recordRun(name) {
66
66
  writeUsage(u);
67
67
  }
68
68
 
69
+ // Stamp an immutable createdAt the first time a tool is registered. Kept in the
70
+ // sidecar (not the header) so editing the file never disturbs it — unlike file
71
+ // mtime, which resets on every edit and would make an edited-but-never-run tool
72
+ // look freshly minted. The dream's zero-run hygiene ages tools from this.
73
+ function recordCreate(name) {
74
+ const n = sanitizeName(name);
75
+ if (!n) return;
76
+ const u = readUsage();
77
+ const cur = u[n] || { runCount: 0, lastUsed: "" };
78
+ if (!cur.createdAt) cur.createdAt = new Date().toISOString();
79
+ u[n] = cur;
80
+ writeUsage(u);
81
+ }
82
+
69
83
  // ── Lexical matching (in-memory) ─────────────────────────────────────────────
70
84
  // Tools are few and short, so we score in memory rather than standing up a third
71
85
  // FTS index — and unlike the recall/tool graphs this then works on every node
@@ -184,6 +198,7 @@ function readTool(name, usageMap) {
184
198
  file,
185
199
  executable: stat ? !!(stat.mode & 0o111) : false,
186
200
  updatedAt: stat ? stat.mtime.toISOString() : "",
201
+ createdAt: usage.createdAt || "",
187
202
  runCount: usage.runCount || 0,
188
203
  lastUsed: usage.lastUsed || "",
189
204
  content,
@@ -229,6 +244,82 @@ function commentPrefixFor(content, srcPath) {
229
244
  return "#";
230
245
  }
231
246
 
247
+ // ── Creation-time guards ─────────────────────────────────────────────────────
248
+ // Cheap static checks surfaced when a tool is registered, so a broken, insecure,
249
+ // or mis-declared tool is caught at `tool add` time instead of at first run. All
250
+ // advisory — they shape warnings, they never block the save (a tool the agent
251
+ // just wrote is usually right; we surface the risk and let the human/agent act).
252
+
253
+ // Hardcoded-secret shapes (mirrors the redactor, plus a few common providers). A
254
+ // match means a literal secret is baked into the file — it should be a $KEYRING
255
+ // reference instead, so the keyring's perms + log redaction actually protect it.
256
+ const SECRET_PATTERNS = [
257
+ [/sk-ant-[A-Za-z0-9._-]{8,}/, "Anthropic key (sk-ant-…)"],
258
+ [/sk-proj-[A-Za-z0-9._-]{8,}/, "OpenAI project key (sk-proj-…)"],
259
+ [/\bsk-[A-Za-z0-9]{20,}/, "OpenAI-style key (sk-…)"],
260
+ [/\bAKIA[0-9A-Z]{16}\b/, "AWS access key id (AKIA…)"],
261
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/, "GitHub token (ghp_…)"],
262
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/, "Slack token (xox…)"],
263
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key block"],
264
+ [/(?:Bearer\s+)[A-Za-z0-9._=-]{20,}/, "inline Bearer token"],
265
+ ];
266
+
267
+ // Names a script reads from the environment: $VAR, ${VAR}, process.env.VAR,
268
+ // process.env["VAR"], os.environ["VAR"], os.getenv("VAR"). Used to reconcile a
269
+ // tool's actual credential use against its declared --requires.
270
+ function envRefsIn(content) {
271
+ const refs = new Set();
272
+ const text = String(content || "");
273
+ let m;
274
+ const res = [
275
+ /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
276
+ /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
277
+ /process\.env\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
278
+ /os\.environ(?:\.get)?[\[(]\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
279
+ /os\.getenv\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
280
+ ];
281
+ for (const re of res) while ((m = re.exec(text))) refs.add(m[1]);
282
+ return refs;
283
+ }
284
+
285
+ // Static lint of a tool's source against the keyring + its declared requires.
286
+ // Returns { secrets, undeclared, unused } — all advisory:
287
+ // secrets — hardcoded credential literals that should be $VAR refs
288
+ // undeclared — keyring keys the script reads but --requires omits (so
289
+ // missingRequires can't pre-warn → cryptic runtime failure)
290
+ // unused — --requires keys the script never references
291
+ function lintToolSource(content, { requires = [], keyringKeys = null } = {}) {
292
+ const text = String(content || "");
293
+ const secrets = [];
294
+ for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.push(label);
295
+ let keys = keyringKeys;
296
+ if (!keys) { try { keys = require("./keyring").keys(); } catch (e) { keys = []; } }
297
+ const keySet = new Set(keys);
298
+ const refs = envRefsIn(text);
299
+ const declared = new Set((requires || []).map((s) => String(s).trim()).filter(Boolean));
300
+ const undeclared = [...refs].filter((r) => keySet.has(r) && !declared.has(r));
301
+ const unused = [...declared].filter((d) => !refs.has(d));
302
+ return { secrets, undeclared, unused };
303
+ }
304
+
305
+ // Syntax-check a tool via its interpreter, if we recognise one. Returns
306
+ // { ok, error } or null when we can't check (unknown type or interpreter not
307
+ // installed) — never cry wolf over a checker we don't have.
308
+ function checkSyntax(file, content) {
309
+ const first = String(content || "").split("\n")[0] || "";
310
+ let cmd = null, args = null;
311
+ if (/\b(bash|sh)\b/.test(first) || /\.sh$/i.test(file)) { cmd = "bash"; args = ["-n", file]; }
312
+ else if (/\b(node|deno|bun)\b/.test(first) || /\.(c?js|mjs)$/i.test(file)) { cmd = "node"; args = ["--check", file]; }
313
+ else if (/\bpython3?\b/.test(first) || /\.py$/i.test(file)) { cmd = "python3"; args = ["-m", "py_compile", file]; }
314
+ if (!cmd) return null;
315
+ try {
316
+ const r = require("child_process").spawnSync(cmd, args, { encoding: "utf-8" });
317
+ if (r.error) return null;
318
+ if (r.status === 0) return { ok: true, error: "" };
319
+ return { ok: false, error: (r.stderr || r.stdout || "").trim().split("\n").slice(0, 3).join("\n") };
320
+ } catch (e) { return null; }
321
+ }
322
+
232
323
  // Register a script as a tool. Copies it into TOOLS_DIR under a sanitized name,
233
324
  // ensures it carries a header (synthesising one from opts if absent), and makes
234
325
  // it executable. Returns the stored tool.
@@ -272,6 +363,7 @@ function addTool(srcPath, opts = {}) {
272
363
  const dest = toolFile(name);
273
364
  fs.writeFileSync(dest, content, { mode: 0o700 });
274
365
  try { fs.chmodSync(dest, 0o700); } catch (e) {}
366
+ recordCreate(name);
275
367
  return readTool(name);
276
368
  }
277
369
 
@@ -282,6 +374,57 @@ function removeTool(name) {
282
374
  return tool;
283
375
  }
284
376
 
377
+ // Update a registered tool's header metadata in place (description/pack/requires
378
+ // /usage) without touching its body, name, or createdAt — the "fix" verb, so
379
+ // improving a tool is as cheap as creating one (reinforces extend-don't-
380
+ // duplicate). Preserves the marker line and any hand-written comments in the
381
+ // header; only the recognised field lines are refreshed. Body edits stay the job
382
+ // of Edit/Write. Returns the updated tool, or null if no such tool.
383
+ function updateToolHeader(name, opts = {}) {
384
+ const t = findTool(name);
385
+ if (!t) return null;
386
+ const prefix = commentPrefixFor(t.content, t.file);
387
+ const description = opts.description !== undefined ? opts.description : t.description;
388
+ const pack = opts.pack !== undefined ? opts.pack : t.pack;
389
+ const usage = opts.usage !== undefined ? opts.usage : t.usage;
390
+ let requires = opts.requires !== undefined ? opts.requires : t.requires;
391
+ if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
392
+
393
+ const lines = t.content.split("\n");
394
+ let headStart = 0;
395
+ if (lines[0] && lines[0].startsWith("#!")) headStart = 1;
396
+ while (headStart < lines.length && lines[headStart].trim() === "") headStart++;
397
+
398
+ const kept = []; // preserved comment lines (marker + any hand notes)
399
+ let markerIdx = -1;
400
+ let j = headStart;
401
+ for (; j < lines.length; j++) {
402
+ const body = uncomment(lines[j]);
403
+ if (body === null) break; // comment block ended
404
+ const kv = body.match(/^([a-zA-Z-]+)\s*:\s*(.*)$/);
405
+ const key = kv ? kv[1].toLowerCase() : null;
406
+ if (key === MARKER) { markerIdx = kept.length; kept.push(lines[j]); }
407
+ else if (key && HEADER_KEYS.includes(key)) { /* drop — re-added below */ }
408
+ else kept.push(lines[j]); // keep arbitrary comment
409
+ }
410
+ const blockEnd = j;
411
+
412
+ const fieldLines = [];
413
+ if (description) fieldLines.push(`${prefix} description: ${description}`);
414
+ if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
415
+ if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
416
+ if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
417
+
418
+ let newBlock;
419
+ if (markerIdx >= 0) newBlock = [...kept.slice(0, markerIdx + 1), ...fieldLines, ...kept.slice(markerIdx + 1)];
420
+ else newBlock = [`${prefix} ${MARKER}: ${t.name}`, ...fieldLines, ...kept];
421
+
422
+ const next = [...lines.slice(0, headStart), ...newBlock, ...lines.slice(blockEnd)].join("\n");
423
+ fs.writeFileSync(t.file, next, { mode: 0o700 });
424
+ try { fs.chmodSync(t.file, 0o700); } catch (e) {}
425
+ return readTool(t.name);
426
+ }
427
+
285
428
  // Env for running a tool: the bot's standard subprocess env (PATH + keyring
286
429
  // creds merged) so tools are pre-authed the same way the agent is. Falls back to
287
430
  // a plain keyring merge if config isn't importable (e.g. standalone test).
@@ -317,7 +460,8 @@ function toolNameFromPath(filePath) {
317
460
 
318
461
  module.exports = {
319
462
  TOOLS_DIR, MARKER, USAGE_FILE,
320
- parseHeader, readTool, listTools, findTool, addTool, removeTool,
463
+ parseHeader, readTool, listTools, findTool, addTool, removeTool, updateToolHeader,
321
464
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
322
- matchTools, recordRun, readUsage,
465
+ matchTools, recordRun, recordCreate, readUsage,
466
+ lintToolSource, checkSyntax, envRefsIn,
323
467
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.54",
3
+ "version": "2.6.56",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
package/test-tools.js CHANGED
@@ -115,6 +115,65 @@ tools.recordRun("beta");
115
115
  const ranked = tools.matchTools("common widget gadget").map((m) => m.name);
116
116
  assert.ok(ranked.indexOf("beta") < ranked.indexOf("alpha"), "on equal score, the more-run tool ranks first");
117
117
 
118
+ // ── createdAt: stamped once at add, surfaced on the record, immutable across a
119
+ // body rewrite (so dream's "cold since creation" clock can't be reset by an
120
+ // edit) ──
121
+ assert.ok(tools.findTool("hello").createdAt, "addTool stamps createdAt");
122
+ const bornHello = tools.findTool("hello").createdAt;
123
+ tools.recordCreate("hello"); // a second create-record must NOT move the stamp
124
+ assert.strictEqual(tools.findTool("hello").createdAt, bornHello, "createdAt is immutable once set");
125
+
126
+ // ── envRefsIn: pulls every shape of env read out of a script ──
127
+ const refs = tools.envRefsIn('echo $FOO ${BAR}\nx=process.env.BAZ\ny=process.env["QUX"]\n');
128
+ for (const k of ["FOO", "BAR", "BAZ", "QUX"]) assert.ok(refs.has(k), `envRefsIn finds ${k}`);
129
+
130
+ // ── lintToolSource: hardcoded secrets, undeclared keyring reads, unused requires
131
+ // (all advisory; keyringKeys passed explicitly so the test is hermetic) ──
132
+ const lint = tools.lintToolSource(
133
+ 'curl -H "Authorization: Bearer sk-ant-abcdefgh12345678" "$inet_central_user" "$missing_decl"\n',
134
+ { requires: ["unused_key"], keyringKeys: ["inet_central_user", "unused_key"] }
135
+ );
136
+ assert.ok(lint.secrets.some((s) => /Anthropic/.test(s)), "lint flags the sk-ant- literal");
137
+ assert.ok(lint.secrets.some((s) => /Bearer/.test(s)), "lint flags the inline Bearer token");
138
+ assert.ok(lint.undeclared.includes("inet_central_user"), "a keyring key read but not declared is 'undeclared'");
139
+ assert.ok(!lint.undeclared.includes("missing_decl"), "a non-keyring env read is not flagged undeclared");
140
+ assert.ok(lint.unused.includes("unused_key"), "a declared key the script never reads is 'unused'");
141
+
142
+ // ── checkSyntax: catches a broken bash script, passes a good one, and returns
143
+ // null for a type it can't check ──
144
+ const goodSh = path.join(tmp, "good.sh");
145
+ fs.writeFileSync(goodSh, "#!/usr/bin/env bash\necho ok\n");
146
+ assert.deepStrictEqual(tools.checkSyntax(goodSh, fs.readFileSync(goodSh, "utf-8")), { ok: true, error: "" }, "valid bash → ok");
147
+ const badSh = path.join(tmp, "bad.sh");
148
+ fs.writeFileSync(badSh, "#!/usr/bin/env bash\nif [ ; then echo hi\n");
149
+ const badRes = tools.checkSyntax(badSh, fs.readFileSync(badSh, "utf-8"));
150
+ assert.ok(badRes && badRes.ok === false && badRes.error, "broken bash → { ok:false, error }");
151
+ assert.strictEqual(tools.checkSyntax("/tmp/x.txt", "plain text, no shebang\n"), null, "unknown type → null (no crying wolf)");
152
+
153
+ // ── updateToolHeader (the 'fix' verb): refreshes recognised field lines in place,
154
+ // preserves the marker (exactly once), hand-written comments, and createdAt;
155
+ // leaves the body untouched ──
156
+ const fixSrc = path.join(tmp, "fixme.sh");
157
+ fs.writeFileSync(
158
+ fixSrc,
159
+ "#!/usr/bin/env bash\n# open-claudia-tool: fixme\n# description: old desc\n# a hand note worth keeping\necho \"$WIDGET_KEY\"\n"
160
+ );
161
+ const fixed0 = tools.addTool(fixSrc, {});
162
+ const bornFix = fixed0.createdAt;
163
+ const fixed = tools.updateToolHeader("fixme", { description: "new desc", requires: ["WIDGET_KEY"], usage: "fixme <x>" });
164
+ assert.strictEqual(fixed.description, "new desc", "updateToolHeader rewrites the description");
165
+ assert.deepStrictEqual(fixed.requires, ["WIDGET_KEY"], "updateToolHeader sets requires");
166
+ assert.strictEqual(fixed.usage, "fixme <x>", "updateToolHeader sets usage");
167
+ assert.strictEqual(fixed.createdAt, bornFix, "updateToolHeader preserves createdAt");
168
+ assert.ok(/a hand note worth keeping/.test(fixed.content), "updateToolHeader keeps hand-written comments");
169
+ assert.strictEqual((fixed.content.match(/open-claudia-tool: fixme/g) || []).length, 1, "marker survives exactly once");
170
+ assert.ok(/echo "\$WIDGET_KEY"/.test(fixed.content), "updateToolHeader leaves the body untouched");
171
+ // requires-string form is normalised to a list
172
+ const fixed2 = tools.updateToolHeader("fixme", { requires: "WIDGET_KEY, OTHER_KEY" });
173
+ assert.deepStrictEqual(fixed2.requires, ["WIDGET_KEY", "OTHER_KEY"], "string requires split into a list");
174
+ // a body that now reads OTHER_KEY would no longer be 'unused' — lint stays in sync
175
+ assert.strictEqual(tools.updateToolHeader("does-not-exist", { description: "x" }), null, "updateToolHeader on a missing tool → null");
176
+
118
177
  // ── remove ──
119
178
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
120
179
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");