@inetafrica/open-claudia 3.2.0 → 3.2.3

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.
@@ -672,11 +672,15 @@ class TelegramAdapter {
672
672
  if (!media || !media.fileId) return null;
673
673
  const file = await this.bot.getFile(media.fileId);
674
674
  const fileUrl = `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
675
- const ext = media.fileName ? path.extname(media.fileName) || ".bin"
676
- : media.type === "voice" ? ".ogg"
677
- : media.type === "audio" ? path.extname(media.fileName || "") || ".ogg"
678
- : media.type === "photo" ? ".jpg"
679
- : ".bin";
675
+ // Telegram's file_path carries the real extension even when the message
676
+ // has no fileName (e.g. videos), so prefer it over the .bin fallback.
677
+ const ext = path.extname(media.fileName || "")
678
+ || path.extname(file.file_path || "")
679
+ || (media.type === "voice" ? ".ogg"
680
+ : media.type === "audio" ? ".ogg"
681
+ : media.type === "photo" ? ".jpg"
682
+ : media.type === "video" ? ".mp4"
683
+ : ".bin");
680
684
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
681
685
  const localPath = media.type === "document" && media.fileName
682
686
  ? path.join(baseDir, media.fileName)
package/core/handlers.js CHANGED
@@ -1611,14 +1611,18 @@ register({
1611
1611
  // Stop pending preview flushes but keep statusMessageId/Channel: the
1612
1612
  // killed run's delivery unwind edits the preview to drop its footer.
1613
1613
  if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1614
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1614
+ // Clearing the interval here orphans the run's own stopper (ownership
1615
+ // check makes it a no-op), so the explicit typing:stop must go out now.
1616
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1617
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1615
1618
  await sideCancellation;
1616
1619
  await send(`Cancelled.${queuedNote()}`);
1617
1620
  } else if (state.preparingRun) {
1618
1621
  // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1619
1622
  // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1620
1623
  state.cancelRequested = true;
1621
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1624
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1625
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1622
1626
  await sideCancellation;
1623
1627
  await send(`Cancelled.${queuedNote()}`);
1624
1628
  } else if (await sideCancellation) await send("Cancelled side response.");
package/core/loopback.js CHANGED
@@ -82,7 +82,7 @@ const JSON_KINDS = new Set([
82
82
  "relay-send", "recent-fetch", "audit-tail",
83
83
  "auth-list", "auth-revoke",
84
84
  "approval-request",
85
- "say",
85
+ "say", "call", "tts", "transcribe",
86
86
  ]);
87
87
 
88
88
  function callerIsOwner(payload) {
@@ -200,6 +200,56 @@ async function handleJson(req, res, url, kind) {
200
200
  return reply(res, ok ? 200 : 500, { ok });
201
201
  }
202
202
 
203
+ if (kind === "tts") {
204
+ // Synthesis-to-FILE: no chat egress, so no guardrail — the artifact stays on
205
+ // local disk until some send verb (with its own guard) ships it. Runs here
206
+ // because only the bot process holds the speech token.
207
+ const text = String(payload.text || "").trim();
208
+ if (!text) return reply(res, 400, { error: "missing text" });
209
+ try {
210
+ const { textToVoiceFile } = require("./media");
211
+ const outPath = await textToVoiceFile(text, {
212
+ voice: payload.voice ? String(payload.voice) : undefined,
213
+ out: payload.out ? String(payload.out) : undefined,
214
+ });
215
+ if (!outPath) return reply(res, 503, { error: "no TTS route available" });
216
+ return reply(res, 200, { ok: true, path: outPath });
217
+ } catch (e) {
218
+ return reply(res, 500, { error: `synthesis failed: ${e.message}` });
219
+ }
220
+ }
221
+
222
+ if (kind === "transcribe") {
223
+ const filePath = String(payload.path || "").trim();
224
+ if (!filePath) return reply(res, 400, { error: "missing path" });
225
+ if (!path.isAbsolute(filePath)) return reply(res, 400, { error: "path must be absolute (the bot process has its own cwd)" });
226
+ if (!fs.existsSync(filePath)) return reply(res, 400, { error: `file not found: ${filePath}` });
227
+ try {
228
+ const { transcribeMedia } = require("./media");
229
+ const text = await transcribeMedia(filePath);
230
+ if (text == null) return reply(res, 503, { error: "no STT route available" });
231
+ return reply(res, 200, { ok: true, text });
232
+ } catch (e) {
233
+ return reply(res, 500, { error: `transcription failed: ${e.message}` });
234
+ }
235
+ }
236
+
237
+ if (kind === "call") {
238
+ // Placing a live voice call is owner-only: it rings a phone on the bot's
239
+ // behalf, so nothing guarded/external may trigger it.
240
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "only the owner can place a call" });
241
+ const manager = adapter._callManager;
242
+ if (!manager || typeof manager.outgoing !== "function") {
243
+ return reply(res, 501, { error: `adapter ${adapterId} does not support outgoing calls` });
244
+ }
245
+ try {
246
+ const result = await manager.outgoing({ chatId: channelId, toUserId: payload.toUserId || undefined });
247
+ return reply(res, 200, { ok: true, ...result });
248
+ } catch (e) {
249
+ return reply(res, 500, { error: e.message });
250
+ }
251
+ }
252
+
203
253
  if (kind === "schedule-wakeup") {
204
254
  const fireAt = Number(payload.fireAt);
205
255
  if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
@@ -297,6 +347,10 @@ async function handleJson(req, res, url, kind) {
297
347
  label: String(payload.label || payload.prompt).slice(0, 60),
298
348
  schedule: payload.schedule ? String(payload.schedule) : undefined,
299
349
  repeat: !!payload.repeat,
350
+ // Absent/invalid → the scheduler stamps its default watch timeout.
351
+ expiresAt: Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0
352
+ ? Number(payload.expiresAt)
353
+ : undefined,
300
354
  source: payload.source || "agent",
301
355
  sessionId: payload.sessionId || null,
302
356
  previousSessionId: payload.previousSessionId || null,
package/core/media.js CHANGED
@@ -101,7 +101,7 @@ async function elevenLabsToVoice(clean) {
101
101
 
102
102
  // Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
103
103
  // Telegram and voice transports receive a compact Ogg/Opus file.
104
- async function pocketToVoice(clean) {
104
+ async function pocketToVoice(clean, voiceOverride) {
105
105
  const token = speechBearerToken();
106
106
  if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
107
107
  let wavPath = null;
@@ -109,7 +109,7 @@ async function pocketToVoice(clean) {
109
109
  const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
110
110
  method: "POST",
111
111
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
112
- body: JSON.stringify({ input: clean, voice: ttsVoice.getVoice(), response_format: "wav" }),
112
+ body: JSON.stringify({ input: clean, voice: voiceOverride || ttsVoice.getVoice(), response_format: "wav" }),
113
113
  signal: AbortSignal.timeout(60000),
114
114
  });
115
115
  if (!res.ok) {
@@ -186,16 +186,69 @@ function canSpeak() {
186
186
  }
187
187
 
188
188
  // Natural voice via ElevenLabs, falling back to macOS `say` only on no-key/error.
189
- async function textToVoice(text) {
189
+ // opts.voice overrides the gateway voice id (e.g. a cloned voice) for the pocket
190
+ // route only — ElevenLabs/say fallbacks keep their configured voices.
191
+ async function textToVoice(text, opts = {}) {
190
192
  const clean = cleanForTTS(text);
191
193
  if (!clean) return null;
192
- const pocket = await pocketToVoice(clean);
194
+ const pocket = await pocketToVoice(clean, opts.voice);
193
195
  if (pocket) return pocket;
194
196
  const eleven = await elevenLabsToVoice(clean);
195
197
  if (eleven) return eleven;
196
198
  return sayToVoice(clean);
197
199
  }
198
200
 
201
+ // Synthesize to a caller-chosen FILE (voice-overs, video narration) instead of a
202
+ // chat send. `out` picks the container by extension (.ogg is native; anything
203
+ // else transcodes). Returns the final path, or null when no TTS route exists.
204
+ async function textToVoiceFile(text, { voice, out } = {}) {
205
+ const oggPath = await textToVoice(text, { voice });
206
+ if (!oggPath) return null;
207
+ if (!out || path.resolve(out) === path.resolve(oggPath)) return oggPath;
208
+ fs.mkdirSync(path.dirname(out), { recursive: true });
209
+ if (path.extname(out).toLowerCase() === path.extname(oggPath).toLowerCase()) {
210
+ fs.copyFileSync(oggPath, out);
211
+ } else {
212
+ if (!FFMPEG) throw new Error(`ffmpeg unavailable to convert to ${path.extname(out) || "the requested format"}`);
213
+ execSync(`"${FFMPEG}" -i "${oggPath}" -y "${out}" 2>/dev/null`, { timeout: 60000 });
214
+ }
215
+ try { fs.unlinkSync(oggPath); } catch (e) {}
216
+ return out;
217
+ }
218
+
219
+ // True when at least one transcribeAudio route can produce text.
220
+ function canTranscribe() {
221
+ if (SPEECH_SERVICE_URL && speechBearerToken()) return true;
222
+ return Boolean(WHISPER_CLI && FFMPEG);
223
+ }
224
+
225
+ // Transcribe ANY audio/video file (mp3, m4a, wav, ogg, mp4, mkv...): normalize
226
+ // to 16kHz mono wav first when ffmpeg is available — that extracts the audio
227
+ // track from video containers and shrinks the gateway upload. Returns the
228
+ // transcript string, or null when no STT route exists.
229
+ async function transcribeMedia(filePath) {
230
+ if (!canTranscribe()) return null;
231
+ let wavPath = null;
232
+ let sendPath = filePath;
233
+ if (FFMPEG && !/\.wav$/i.test(filePath)) {
234
+ try {
235
+ wavPath = path.join(TEMP_DIR, `stt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.wav`);
236
+ execSync(`"${FFMPEG}" -i "${filePath}" -vn -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`, { timeout: 300000 });
237
+ sendPath = wavPath;
238
+ } catch (e) {
239
+ console.error("STT transcode error:", e.message);
240
+ try { if (wavPath) fs.unlinkSync(wavPath); } catch (_) {}
241
+ wavPath = null;
242
+ sendPath = filePath;
243
+ }
244
+ }
245
+ try {
246
+ return await transcribeAudio(sendPath);
247
+ } finally {
248
+ if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
249
+ }
250
+ }
251
+
199
252
  // Split a reply into sentence-sized chunks for streamed TTS. Tiny fragments are
200
253
  // merged into the next chunk so we don't fire a TTS call per "Hi." and end up
201
254
  // with choppy playback. Returns cleaned, non-empty chunks.
@@ -250,4 +303,4 @@ async function synthSentenceMp3(text) {
250
303
  }
251
304
  }
252
305
 
253
- module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
306
+ module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
@@ -4,7 +4,7 @@ const config = require("../config");
4
4
 
5
5
  const PROVIDER_IDS = Object.freeze(["claude", "codex"]);
6
6
  const PROVIDER_ID_SET = new Set(PROVIDER_IDS);
7
- const PURPOSES = Object.freeze(["subagent", "recall", "review", "dream", "introspection", "enforcer"]);
7
+ const PURPOSES = Object.freeze(["subagent", "recall", "review", "dedup", "dream", "introspection", "enforcer"]);
8
8
  const PURPOSE_SET = new Set(PURPOSES);
9
9
  const TIERS = Object.freeze(["low", "medium", "high"]);
10
10
  const TIER_SET = new Set(TIERS);
@@ -12,6 +12,7 @@ const PURPOSE_PROVIDER_ENV = Object.freeze({
12
12
  subagent: "SUBAGENT_PROVIDER",
13
13
  recall: "RECALL_PROVIDER",
14
14
  review: "MEMORY_PROVIDER",
15
+ dedup: "MEMORY_PROVIDER",
15
16
  dream: "DREAM_PROVIDER",
16
17
  introspection: "DREAM_PROVIDER",
17
18
  enforcer: "ENFORCER_PROVIDER",
@@ -20,12 +21,14 @@ const PURPOSE_MODEL_PREFIX = Object.freeze({
20
21
  subagent: "SUBAGENT_MODEL",
21
22
  recall: "RECALL_MODEL",
22
23
  review: "PACK_REVIEW_MODEL",
24
+ dedup: "PACK_DEDUP_MODEL",
23
25
  dream: "DREAM_MODEL",
24
26
  introspection: "DREAM_MODEL",
25
27
  enforcer: "ENFORCER_MODEL",
26
28
  });
27
29
  const LEGACY_CLAUDE_MODEL_ENV = Object.freeze({
28
30
  review: "PACK_REVIEW_MODEL",
31
+ dedup: "PACK_DEDUP_MODEL",
29
32
  dream: "DREAM_MODEL",
30
33
  introspection: "DREAM_MODEL",
31
34
  enforcer: "ENFORCER_MODEL",
package/core/router.js CHANGED
@@ -12,7 +12,7 @@ const { isChatAuthorized } = require("./access");
12
12
  const introFlow = require("./intro-flow");
13
13
  const { send, deleteMessage } = require("./io");
14
14
  const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
15
- const { admitRunContext, runAgent } = require("./runner");
15
+ const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
16
16
  const { transcribeAudio, cloneVoiceFromClip } = require("./media");
17
17
  const { vault } = require("./vault-store");
18
18
  const { isOnboarded, handleOnboarding } = require("./onboarding");
@@ -203,9 +203,20 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
203
203
  return !(triage && triage.held);
204
204
  }
205
205
 
206
+ // Chip-on-receipt: enforcer triage, media download, and STT all run before
207
+ // runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
208
+ // soon as a turn-bound message arrives; the run's own heartbeat supersedes it
209
+ // at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
210
+ // run is active — the chip is already owned by that turn.
211
+ function startPreTurnTyping(state) {
212
+ if (state.runningProcess || state.preparingRun) return () => {};
213
+ try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
214
+ }
215
+
206
216
  async function handleVoice(envelope) {
207
217
  if (isDuplicate(envelope)) return;
208
218
  const state = currentState();
219
+ let stopPreTurnTyping = () => {};
209
220
  try {
210
221
  const media = envelope.media?.[0];
211
222
  if (!media) return;
@@ -223,7 +234,7 @@ async function handleVoice(envelope) {
223
234
  purpose: "foreground",
224
235
  attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
225
236
  });
226
- envelope.adapter.typing(envelope.channelId).catch(() => {});
237
+ stopPreTurnTyping = startPreTurnTyping(state);
227
238
  const tStart = Date.now();
228
239
  const oggPath = await envelope.adapter.downloadMedia(media);
229
240
  const tAfterDownload = Date.now();
@@ -243,11 +254,13 @@ async function handleVoice(envelope) {
243
254
  voiceStream: envelope.adapter?.type === "voice",
244
255
  });
245
256
  } catch (err) { await send(`Voice failed: ${err.message}`); }
257
+ finally { stopPreTurnTyping(); }
246
258
  }
247
259
 
248
260
  async function handleAudio(envelope) {
249
261
  if (isDuplicate(envelope)) return;
250
262
  const state = currentState();
263
+ let stopPreTurnTyping = () => {};
251
264
  try {
252
265
  const media = envelope.media?.[0];
253
266
  if (!media) return;
@@ -260,7 +273,7 @@ async function handleAudio(envelope) {
260
273
  purpose: "foreground",
261
274
  attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
262
275
  });
263
- envelope.adapter.typing(envelope.channelId).catch(() => {});
276
+ stopPreTurnTyping = startPreTurnTyping(state);
264
277
  const p = await envelope.adapter.downloadMedia(media);
265
278
  const t = await transcribeAudio(p);
266
279
  try { fs.unlinkSync(p); } catch (e) {}
@@ -272,6 +285,7 @@ async function handleAudio(envelope) {
272
285
  attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
273
286
  });
274
287
  } catch (err) { await send(`Audio failed: ${err.message}`); }
288
+ finally { stopPreTurnTyping(); }
275
289
  }
276
290
 
277
291
  // A clip the owner sent right after /voice clone. Upload it to the speech
@@ -297,6 +311,7 @@ async function captureVoiceClone(envelope, media) {
297
311
  async function handlePhoto(envelope) {
298
312
  if (isDuplicate(envelope)) return;
299
313
  const state = currentState();
314
+ let stopPreTurnTyping = () => {};
300
315
  try {
301
316
  const items = envelope.media || [];
302
317
  if (!items.length) return;
@@ -304,6 +319,7 @@ async function handlePhoto(envelope) {
304
319
  purpose: "foreground",
305
320
  attachments: items.map((item) => ({ type: "image", mimeType: item.mimeType || "image/*", fileName: item.fileName || null })),
306
321
  });
322
+ stopPreTurnTyping = startPreTurnTyping(state);
307
323
  const paths = [];
308
324
  for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
309
325
  const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
@@ -327,11 +343,13 @@ async function handlePhoto(envelope) {
327
343
  })),
328
344
  });
329
345
  } catch (err) { await send(`Image failed: ${err.message}`); }
346
+ finally { stopPreTurnTyping(); }
330
347
  }
331
348
 
332
349
  async function handleDocument(envelope) {
333
350
  if (isDuplicate(envelope)) return;
334
351
  const state = currentState();
352
+ let stopPreTurnTyping = () => {};
335
353
  try {
336
354
  const media = envelope.media?.[0];
337
355
  if (!media) return;
@@ -342,6 +360,7 @@ async function handleDocument(envelope) {
342
360
  purpose: "foreground",
343
361
  attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
344
362
  });
363
+ stopPreTurnTyping = startPreTurnTyping(state);
345
364
  const savePath = await envelope.adapter.downloadMedia(media);
346
365
  const fileName = media.fileName || path.basename(savePath);
347
366
  const mime = media.mimeType || "";
@@ -361,6 +380,7 @@ async function handleDocument(envelope) {
361
380
  attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
362
381
  });
363
382
  } catch (err) { await send(`Failed: ${err.message}`); }
383
+ finally { stopPreTurnTyping(); }
364
384
  }
365
385
 
366
386
  // Provider-agnostic capture of a credential pasted into chat with no auth
@@ -605,7 +625,11 @@ async function handleText(envelope) {
605
625
  // stop, instead of forwarding a secret to the model (Codex refuses those).
606
626
  if (await captureLooseCredential(text.trim(), envelope)) return;
607
627
 
608
- if (!(await admitGuardedTurn(text, envelope, state))) return;
628
+ const stopPreTurnTyping = startPreTurnTyping(state);
629
+ let admitted;
630
+ try { admitted = await admitGuardedTurn(text, envelope, state); }
631
+ catch (e) { stopPreTurnTyping(); throw e; }
632
+ if (!admitted) { stopPreTurnTyping(); return; }
609
633
 
610
634
  const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
611
635
  const attachments = [];
package/core/runner.js CHANGED
@@ -1725,17 +1725,24 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1725
1725
  return { ...invocation, env };
1726
1726
  }
1727
1727
 
1728
+ // Singleton per chat state: a new start supersedes any running heartbeat
1729
+ // (pre-turn "thinking" started at message receipt hands off to the run's own
1730
+ // heartbeat here). A stopper only acts while its own interval is current, so
1731
+ // a stale stopper from a superseded start can never kill a newer heartbeat.
1728
1732
  function startTypingHeartbeat(state, store) {
1729
1733
  const adapter = store?.adapter || currentAdapter();
1730
1734
  const channelId = store?.channelId || currentChannelId();
1731
1735
  if (!adapter || !channelId) return () => {};
1736
+ if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1732
1737
  const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
1733
1738
  state.thinkingPhase = true;
1734
1739
  tick();
1735
- state.typingHeartbeat = setInterval(tick, 4000);
1740
+ const interval = setInterval(tick, 4000);
1741
+ state.typingHeartbeat = interval;
1736
1742
  return () => {
1743
+ if (state.typingHeartbeat !== interval) return;
1737
1744
  state.thinkingPhase = false;
1738
- if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1745
+ clearInterval(interval);
1739
1746
  state.typingHeartbeat = null;
1740
1747
  try { adapter.typingStop?.(channelId); } catch (_) {}
1741
1748
  };
@@ -1880,7 +1887,10 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1880
1887
  observer.onRecallSurfaced(bundle.recallMetadata);
1881
1888
  },
1882
1889
  onEvent(event, runContext) {
1883
- if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1890
+ // Streaming text reads as "typing"; tool work re-enters "thinking" so
1891
+ // the chip doesn't decay to a stale "typing" through long tool stretches.
1892
+ if (["text_delta", "text_final", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1893
+ else if (event.type === "tool_start") state.thinkingPhase = true;
1884
1894
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1885
1895
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1886
1896
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
@@ -2248,6 +2258,7 @@ module.exports = {
2248
2258
  runAgent,
2249
2259
  runAgentCapture,
2250
2260
  runAgentSilent,
2261
+ startTypingHeartbeat,
2251
2262
  shouldRetryRefusedRun,
2252
2263
  quarantineRefusedRun,
2253
2264
  refusalRetryContext,
package/core/scheduler.js CHANGED
@@ -43,6 +43,10 @@ const RETRY_DELAY_MS = 30 * 1000;
43
43
  const BUSY_DELAY_MS = 30 * 1000;
44
44
  // A watch's `check` command is capped so a hung poll can't wedge the tick.
45
45
  const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
46
+ // Every watch carries a hard expiry so none can idle-poll forever: when the
47
+ // deadline lapses with the check still failing, the next poll tick fires the
48
+ // wake anyway (marked expired) instead of skipping silently.
49
+ const DEFAULT_WATCH_TIMEOUT_MS = 2 * 60 * 60 * 1000;
46
50
  const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
47
51
  const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
48
52
 
@@ -107,6 +111,7 @@ function createSchedulerService(dependencies = {}) {
107
111
  const retryDelayMs = Number.isFinite(dependencies.retryDelayMs) ? dependencies.retryDelayMs : RETRY_DELAY_MS;
108
112
  const busyDelayMs = Number.isFinite(dependencies.busyDelayMs) ? dependencies.busyDelayMs : BUSY_DELAY_MS;
109
113
  const missedGraceMs = Number.isFinite(dependencies.missedGraceMs) ? dependencies.missedGraceMs : MISSED_WAKEUP_GRACE_MS;
114
+ const watchTimeoutMs = Number.isFinite(dependencies.watchTimeoutMs) ? dependencies.watchTimeoutMs : DEFAULT_WATCH_TIMEOUT_MS;
110
115
  const occurrenceIdFactory = dependencies.occurrenceIdFactory || (() => uniqueId("occ"));
111
116
  const legacyCronsFile = dependencies.legacyCronsFile === undefined
112
117
  ? (dependencies.jobsStore ? null : CRONS_FILE)
@@ -273,22 +278,21 @@ function createSchedulerService(dependencies = {}) {
273
278
 
274
279
  function wrappedPrompt(job, target) {
275
280
  const firedAt = new Date(now()).toISOString();
276
- const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
277
- if (target.fallbackFrom) {
278
- return [
279
- `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
280
- `[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`,
281
- job.prompt,
282
- ].join("\n");
281
+ const expired = !!(job.check && job.watchExpired);
282
+ const header = job.check
283
+ ? (expired ? "Watch expired" : "Watch triggered")
284
+ : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
285
+ const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
286
+ if (expired) {
287
+ lines.push("[This watch reached its timeout without its check ever passing — the watched condition did NOT happen. This is a deadline wake-up, not a trigger: investigate why the condition never became true, and arm a new watch if it is still worth waiting for.]");
283
288
  }
284
- if (target.switchedFrom) {
285
- return [
286
- `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
287
- `[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`,
288
- job.prompt,
289
- ].join("\n");
289
+ if (target.fallbackFrom) {
290
+ lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
291
+ } else if (target.switchedFrom) {
292
+ lines.push(`[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`);
290
293
  }
291
- return `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]\n${job.prompt}`;
294
+ lines.push(job.prompt);
295
+ return lines.join("\n");
292
296
  }
293
297
 
294
298
  function unscheduleJob(id) {
@@ -394,7 +398,7 @@ function createSchedulerService(dependencies = {}) {
394
398
 
395
399
  async function fireJob(idOrJob, options = {}) {
396
400
  const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
397
- const job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
401
+ let job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
398
402
  if (!job) return { ok: false, status: "missing" };
399
403
  if (job.disabled || job.status === "dead-letter" || job.status === "archived") {
400
404
  return { ok: false, status: job.status, skipped: true };
@@ -424,10 +428,35 @@ function createSchedulerService(dependencies = {}) {
424
428
  catch (_) { check = { code: 1, timedOut: false }; }
425
429
  finally { checksInFlight.delete(job.id); }
426
430
  if (!check || check.code !== 0) {
427
- logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
428
- return { ok: false, status: "watch-idle", skipped: true };
431
+ // Expiry backstop: a watch whose deadline has lapsed fires the wake
432
+ // anyway (marked expired) so no watch can ever end silently. Watches
433
+ // persisted before expiry existed (no expiresAt) keep polling forever.
434
+ const expiresAt = Number(job.expiresAt || 0);
435
+ if (!(expiresAt > 0 && now() >= expiresAt)) {
436
+ logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
437
+ return { ok: false, status: "watch-idle", skipped: true };
438
+ }
439
+ logWatch(job, `expired without firing (check exit ${check ? check.code : "?"}) — waking agent`);
440
+ // Expiry ends the watch's window: one-shot removal even for --repeat
441
+ // (the woken agent re-arms if still wanted). Persisted so a retry of a
442
+ // failed expiry fire keeps the same semantics across restarts.
443
+ try {
444
+ const marked = jobsStore.update(job.id, { repeat: false, watchExpired: true });
445
+ if (marked) job = marked;
446
+ else { job.repeat = false; job.watchExpired = true; }
447
+ } catch (_) { job.repeat = false; job.watchExpired = true; }
448
+ } else {
449
+ logWatch(job, "triggered (check passed) — waking agent");
450
+ if (job.watchExpired) {
451
+ // A prior expiry fire failed and the condition came true before the
452
+ // retry: report it as a real trigger, not an expiry.
453
+ try {
454
+ const cleared = jobsStore.update(job.id, { watchExpired: false });
455
+ if (cleared) job = cleared;
456
+ else job.watchExpired = false;
457
+ } catch (_) { job.watchExpired = false; }
458
+ }
429
459
  }
430
- logWatch(job, "triggered (check passed) — waking agent");
431
460
  }
432
461
 
433
462
  const adapter = resolveAdapter(job);
@@ -504,7 +533,10 @@ function createSchedulerService(dependencies = {}) {
504
533
  return { ok: false, status: "failed", error: stableFailure(error) };
505
534
  }
506
535
 
507
- try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
536
+ const advisory = job.check
537
+ ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
538
+ : `Wakeup: ${job.label}`;
539
+ try { await send(advisory); } catch (_) { /* advisory only */ }
508
540
  let result;
509
541
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
510
542
  catch (error) {
@@ -655,15 +687,21 @@ function createSchedulerService(dependencies = {}) {
655
687
  }
656
688
 
657
689
  // A watch is a cron carrying a shell `check`; it rides the migration-verified
658
- // jobs store untouched (check/repeat survive normalizeRunnableJob as extra
659
- // fields). Default schedule polls every minute; default is one-shot.
690
+ // jobs store untouched (check/repeat/expiresAt survive normalizeRunnableJob
691
+ // as extra fields). Default schedule polls every minute; default is one-shot.
692
+ // Every new watch gets an expiry (caller's expiresAt or the default timeout)
693
+ // so it can never idle-poll silently forever.
660
694
  function addWatch(spec) {
695
+ const requestedExpiry = Number(spec.expiresAt);
661
696
  return addJob({
662
697
  ...spec,
663
698
  kind: "cron",
664
699
  schedule: spec.schedule || "* * * * *",
665
700
  check: String(spec.check || "").trim(),
666
701
  repeat: !!spec.repeat,
702
+ expiresAt: Number.isFinite(requestedExpiry) && requestedExpiry > 0
703
+ ? requestedExpiry
704
+ : now() + watchTimeoutMs,
667
705
  label: spec.label || String(spec.prompt || "watch").slice(0, 60),
668
706
  });
669
707
  }
@@ -710,6 +748,7 @@ module.exports = {
710
748
  MISSED_WAKEUP_GRACE_MS,
711
749
  RETRY_DELAY_MS,
712
750
  BUSY_DELAY_MS,
751
+ DEFAULT_WATCH_TIMEOUT_MS,
713
752
  createSchedulerService,
714
753
  parseFallbacks,
715
754
  terminalRunSucceeded,
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ // socket.io-client permanently stops reconnecting when the server REJECTS a
4
+ // handshake (auth middleware next(new Error())): connect_error fires with
5
+ // socket.active === false and the manager never dials again. During the
6
+ // 2026-08-08 outage chat-central booted before Central's MongoDB and rejected
7
+ // every handshake for a few minutes, terminally stranding the whole bot fleet.
8
+ // This wires a manual connect() retry with capped backoff so a rejected
9
+ // handshake is never terminal. A deliberate client-side disconnect() cancels
10
+ // any pending retry and stays final until connect() is called again.
11
+
12
+ const DEFAULT_INITIAL_DELAY_MS = 2000;
13
+ const DEFAULT_MAX_DELAY_MS = 30000;
14
+
15
+ function wireTerminalReconnect(socket, opts = {}) {
16
+ const label = opts.label || "socket";
17
+ const log = opts.log || ((msg) => console.error(msg));
18
+ const initialDelay = opts.initialDelayMs || DEFAULT_INITIAL_DELAY_MS;
19
+ const maxDelay = opts.maxDelayMs || DEFAULT_MAX_DELAY_MS;
20
+
21
+ let attempts = 0;
22
+ let timer = null;
23
+ let stopped = false;
24
+
25
+ const cancel = () => {
26
+ if (timer) { clearTimeout(timer); timer = null; }
27
+ };
28
+
29
+ const schedule = (why) => {
30
+ if (stopped || timer) return;
31
+ const delay = Math.min(initialDelay * 2 ** attempts, maxDelay);
32
+ attempts += 1;
33
+ log(`${label}: ${why} — socket.io will not retry on its own; manual connect() in ${Math.ceil(delay / 1000)}s (attempt ${attempts})`);
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ if (stopped || socket.connected || socket.active) return;
37
+ try { socket.connect(); }
38
+ catch (e) { schedule(`reconnect attempt threw (${e.message})`); }
39
+ }, delay);
40
+ if (typeof timer.unref === "function") timer.unref();
41
+ };
42
+
43
+ socket.on("connect", () => { attempts = 0; cancel(); });
44
+ socket.on("connect_error", (err) => {
45
+ if (socket.active) return; // transport failure: the manager auto-retries
46
+ schedule(`handshake rejected (${err && err.message ? err.message : err})`);
47
+ });
48
+ socket.on("disconnect", (reason) => {
49
+ if (reason === "io client disconnect") { cancel(); return; }
50
+ if (socket.active) return; // the manager auto-reconnects
51
+ schedule(`disconnected (${reason})`);
52
+ });
53
+
54
+ // disconnect() while wedged emits no event, so intercept the call itself;
55
+ // connect() re-arms retries for the adapter-restart path.
56
+ const origDisconnect = socket.disconnect;
57
+ socket.disconnect = function (...args) {
58
+ stopped = true;
59
+ cancel();
60
+ return origDisconnect.apply(this, args);
61
+ };
62
+ const origConnect = socket.connect;
63
+ socket.connect = function (...args) {
64
+ stopped = false;
65
+ return origConnect.apply(this, args);
66
+ };
67
+
68
+ return socket;
69
+ }
70
+
71
+ module.exports = { wireTerminalReconnect, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS };