cc-claw 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +871 -414
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -33,7 +33,7 @@ var VERSION;
33
33
  var init_version = __esm({
34
34
  "src/version.ts"() {
35
35
  "use strict";
36
- VERSION = true ? "0.28.0" : (() => {
36
+ VERSION = true ? "0.29.1" : (() => {
37
37
  try {
38
38
  return JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8")).version ?? "unknown";
39
39
  } catch {
@@ -7559,6 +7559,380 @@ var init_html = __esm({
7559
7559
  }
7560
7560
  });
7561
7561
 
7562
+ // src/channels/telegram-throttle.ts
7563
+ var telegram_throttle_exports = {};
7564
+ __export(telegram_throttle_exports, {
7565
+ CircuitState: () => CircuitState,
7566
+ Priority: () => Priority,
7567
+ TelegramThrottle: () => TelegramThrottle,
7568
+ getThrottleState: () => getThrottleState
7569
+ });
7570
+ import { GrammyError } from "grammy";
7571
+ function perChatInterval(chatId) {
7572
+ return parseInt(chatId) < 0 ? PER_GROUP_INTERVAL_MS : PER_DM_INTERVAL_MS;
7573
+ }
7574
+ function getThrottleState() {
7575
+ if (!_activeThrottle) return null;
7576
+ return _activeThrottle.getState();
7577
+ }
7578
+ function is429(err) {
7579
+ return err instanceof GrammyError && err.error_code === 429;
7580
+ }
7581
+ function sleep(ms) {
7582
+ return new Promise((r) => setTimeout(r, ms));
7583
+ }
7584
+ var PER_DM_INTERVAL_MS, PER_GROUP_INTERVAL_MS, GLOBAL_INTERVAL_MS, MAX_RETRIES2, RETRY_DELAY_MS, MAX_QUEUE_SIZE, EDIT_PRESSURE_THRESHOLD, MAX_PER_CHAT_QUEUE, MAX_TOTAL_PAUSE_MS, CIRCUIT_TRIP_THRESHOLD, CIRCUIT_TRIP_WINDOW_MS, CIRCUIT_COOLDOWN_STEP_SEC, CIRCUIT_RESET_WINDOW_MS, CircuitState, Priority, _activeThrottle, TelegramThrottle;
7585
+ var init_telegram_throttle = __esm({
7586
+ "src/channels/telegram-throttle.ts"() {
7587
+ "use strict";
7588
+ init_log();
7589
+ PER_DM_INTERVAL_MS = 1e3;
7590
+ PER_GROUP_INTERVAL_MS = 3500;
7591
+ GLOBAL_INTERVAL_MS = 100;
7592
+ MAX_RETRIES2 = 2;
7593
+ RETRY_DELAY_MS = 1e3;
7594
+ MAX_QUEUE_SIZE = 60;
7595
+ EDIT_PRESSURE_THRESHOLD = MAX_QUEUE_SIZE / 2;
7596
+ MAX_PER_CHAT_QUEUE = 15;
7597
+ MAX_TOTAL_PAUSE_MS = 5 * 60 * 1e3;
7598
+ CIRCUIT_TRIP_THRESHOLD = 3;
7599
+ CIRCUIT_TRIP_WINDOW_MS = 5 * 60 * 1e3;
7600
+ CIRCUIT_COOLDOWN_STEP_SEC = 5;
7601
+ CIRCUIT_RESET_WINDOW_MS = 5 * 60 * 1e3;
7602
+ CircuitState = /* @__PURE__ */ ((CircuitState2) => {
7603
+ CircuitState2["CLOSED"] = "closed";
7604
+ CircuitState2["OPEN"] = "open";
7605
+ CircuitState2["HALF_OPEN"] = "half_open";
7606
+ return CircuitState2;
7607
+ })(CircuitState || {});
7608
+ Priority = /* @__PURE__ */ ((Priority2) => {
7609
+ Priority2[Priority2["P0_CRITICAL"] = 0] = "P0_CRITICAL";
7610
+ Priority2[Priority2["P1_NORMAL"] = 1] = "P1_NORMAL";
7611
+ Priority2[Priority2["P2_COSMETIC"] = 2] = "P2_COSMETIC";
7612
+ return Priority2;
7613
+ })(Priority || {});
7614
+ _activeThrottle = null;
7615
+ TelegramThrottle = class {
7616
+ queue = [];
7617
+ processing = false;
7618
+ lastSendPerChat = /* @__PURE__ */ new Map();
7619
+ perChatQueueCount = /* @__PURE__ */ new Map();
7620
+ // O(1) per-chat depth lookup
7621
+ lastGlobalSend = 0;
7622
+ // Pause state
7623
+ pausedUntil = 0;
7624
+ pauseStartedAt = 0;
7625
+ // Per-chat cosmetic backoff — tryBestEffort() 429s only affect future
7626
+ // best-effort calls for the SAME chat, never triggering a global pause.
7627
+ cosmeticPausedUntil = /* @__PURE__ */ new Map();
7628
+ // Circuit breaker state — tracks repeated 429s and manages recovery
7629
+ circuitState = "closed" /* CLOSED */;
7630
+ circuitTrips = [];
7631
+ // timestamps of recent 429s
7632
+ circuitCooldownUntil = 0;
7633
+ // when OPEN cooldown expires
7634
+ lastSuccessfulSend = 0;
7635
+ // for resetting trip count after 5min of success
7636
+ constructor() {
7637
+ _activeThrottle = this;
7638
+ }
7639
+ /** Enqueue a Telegram API call with automatic pacing and 429 handling.
7640
+ * Priority controls queue insertion order:
7641
+ * P0_CRITICAL — keyboard responses, finalize edits, /stop — always first
7642
+ * P1_NORMAL — agent responses, cron deliveries — default
7643
+ * P2_COSMETIC — live-status streaming edits — dropped first on overflow
7644
+ *
7645
+ * Accepts `Priority | boolean` for backward compatibility during migration.
7646
+ * `true` maps to P0_CRITICAL, `false`/`undefined` maps to P1_NORMAL. */
7647
+ async send(chatId, label2, fn, priority) {
7648
+ const prio = priority === true ? 0 /* P0_CRITICAL */ : typeof priority === "number" ? priority : 1 /* P1_NORMAL */;
7649
+ if (prio === 2 /* P2_COSMETIC */) {
7650
+ if (this.isPaused()) {
7651
+ throw new Error("Throttle paused (rate limit active) \u2014 edit skipped");
7652
+ }
7653
+ if (this.queue.length >= EDIT_PRESSURE_THRESHOLD) {
7654
+ throw new Error("Throttle queue pressured \u2014 edit skipped");
7655
+ }
7656
+ }
7657
+ return new Promise((resolve3, reject) => {
7658
+ if (this.queue.length >= MAX_QUEUE_SIZE) {
7659
+ let dropIdx = -1;
7660
+ for (let i = this.queue.length - 1; i >= 0; i--) {
7661
+ if (this.queue[i].priority === 2 /* P2_COSMETIC */) {
7662
+ dropIdx = i;
7663
+ break;
7664
+ }
7665
+ }
7666
+ if (dropIdx === -1) {
7667
+ for (let i = this.queue.length - 1; i >= 0; i--) {
7668
+ if (this.queue[i].priority === 1 /* P1_NORMAL */) {
7669
+ dropIdx = i;
7670
+ break;
7671
+ }
7672
+ }
7673
+ }
7674
+ if (dropIdx === -1) dropIdx = this.queue.length - 1;
7675
+ const dropped = this.queue.splice(dropIdx, 1)[0];
7676
+ if (dropped) {
7677
+ this.decrementChatCount(dropped.chatId);
7678
+ warn(`[throttle] Queue full (${MAX_QUEUE_SIZE}), dropping P${dropped.priority}: ${dropped.label}`);
7679
+ dropped.reject(new Error("Dropped from send queue (overflow)"));
7680
+ }
7681
+ }
7682
+ const chatQueueCount = this.perChatQueueCount.get(chatId) ?? 0;
7683
+ if (chatQueueCount >= MAX_PER_CHAT_QUEUE && prio !== 0 /* P0_CRITICAL */) {
7684
+ if (prio === 2 /* P2_COSMETIC */) {
7685
+ reject(new Error(`Per-chat queue limit (${MAX_PER_CHAT_QUEUE}) reached \u2014 cosmetic edit dropped`));
7686
+ return;
7687
+ }
7688
+ const p2Idx = this.queue.findIndex((q) => q.chatId === chatId && q.priority === 2 /* P2_COSMETIC */);
7689
+ if (p2Idx >= 0) {
7690
+ const dropped = this.queue.splice(p2Idx, 1)[0];
7691
+ this.decrementChatCount(dropped.chatId);
7692
+ dropped.reject(new Error("Dropped (per-chat P2 eviction)"));
7693
+ } else {
7694
+ reject(new Error(`Per-chat queue limit (${MAX_PER_CHAT_QUEUE}) reached \u2014 normal send dropped`));
7695
+ return;
7696
+ }
7697
+ }
7698
+ const item = { chatId, label: label2, priority: prio, fn, resolve: resolve3, reject };
7699
+ const insertIdx = this.queue.findIndex((q) => q.priority > prio);
7700
+ if (insertIdx === -1) {
7701
+ this.queue.push(item);
7702
+ } else {
7703
+ this.queue.splice(insertIdx, 0, item);
7704
+ }
7705
+ this.perChatQueueCount.set(chatId, (this.perChatQueueCount.get(chatId) ?? 0) + 1);
7706
+ this.drain();
7707
+ });
7708
+ }
7709
+ /**
7710
+ * Best-effort send — drops silently if throttle is paused or queue is pressured.
7711
+ * Used for cosmetic calls (typing indicators, reactions) that should count toward
7712
+ * rate limits but must never queue up or amplify 429 spirals.
7713
+ */
7714
+ async tryBestEffort(chatId, label2, fn, opts) {
7715
+ if (this.isPaused()) return void 0;
7716
+ if (this.queue.length > 10) return void 0;
7717
+ const cosmeticUntil = this.cosmeticPausedUntil.get(chatId) ?? 0;
7718
+ if (Date.now() < cosmeticUntil) return void 0;
7719
+ if (cosmeticUntil > 0) this.cosmeticPausedUntil.delete(chatId);
7720
+ if (!opts?.skipRecord) {
7721
+ const lastChat = this.lastSendPerChat.get(chatId) ?? 0;
7722
+ if (Date.now() - lastChat < perChatInterval(chatId)) return void 0;
7723
+ if (Date.now() - this.lastGlobalSend < GLOBAL_INTERVAL_MS) return void 0;
7724
+ }
7725
+ try {
7726
+ const result = await fn();
7727
+ if (!opts?.skipRecord) this.recordSend(chatId);
7728
+ return result;
7729
+ } catch (err) {
7730
+ if (is429(err)) {
7731
+ const retrySec = err.parameters?.retry_after ?? 10;
7732
+ this.cosmeticPausedUntil.set(chatId, Date.now() + retrySec * 1e3);
7733
+ warn(`[throttle] 429 event (cosmetic)`, JSON.stringify({
7734
+ method: label2,
7735
+ chatId,
7736
+ retry_after: retrySec,
7737
+ queue_depth: this.queue.length,
7738
+ circuit_state: this.circuitState,
7739
+ type: "best_effort"
7740
+ }));
7741
+ }
7742
+ return void 0;
7743
+ }
7744
+ }
7745
+ /** Check whether the throttle is currently paused (rate-limited). */
7746
+ isPaused() {
7747
+ return Date.now() < this.pausedUntil;
7748
+ }
7749
+ /** Get structured state for diagnostics / health checks. */
7750
+ getState() {
7751
+ const now = Date.now();
7752
+ const paused = now < this.pausedUntil;
7753
+ return {
7754
+ isPaused: paused,
7755
+ queueDepth: this.queue.length,
7756
+ pausedUntilMs: this.pausedUntil,
7757
+ pauseRemainingSec: paused ? Math.ceil((this.pausedUntil - now) / 1e3) : 0,
7758
+ circuitState: this.circuitState
7759
+ };
7760
+ }
7761
+ // ── Queue processor ─────────────────────────────────────────────────
7762
+ async drain() {
7763
+ if (this.processing) return;
7764
+ this.processing = true;
7765
+ try {
7766
+ while (this.queue.length > 0) {
7767
+ while (this.isPaused()) {
7768
+ if (this.pauseStartedAt > 0 && Date.now() - this.pauseStartedAt > MAX_TOTAL_PAUSE_MS) {
7769
+ warn(`[throttle] Max pause duration exceeded (${MAX_TOTAL_PAUSE_MS / 6e4}min), dropping ${this.queue.length} items`);
7770
+ this.flushQueueWithError("Telegram rate limit exceeded max wait time");
7771
+ this.pausedUntil = 0;
7772
+ this.pauseStartedAt = 0;
7773
+ break;
7774
+ }
7775
+ const waitMs = Math.min(this.pausedUntil - Date.now(), 5e3);
7776
+ if (waitMs > 0) await sleep(waitMs);
7777
+ }
7778
+ if (this.queue.length === 0) break;
7779
+ this.updateCircuitState();
7780
+ const item = this.selectNextItem();
7781
+ if (!item) {
7782
+ await sleep(1e3);
7783
+ continue;
7784
+ }
7785
+ const lastChat = this.lastSendPerChat.get(item.chatId) ?? 0;
7786
+ const chatWait = perChatInterval(item.chatId) - (Date.now() - lastChat);
7787
+ if (chatWait > 0) await sleep(chatWait);
7788
+ const globalWait = GLOBAL_INTERVAL_MS - (Date.now() - this.lastGlobalSend);
7789
+ if (globalWait > 0) await sleep(globalWait);
7790
+ try {
7791
+ const result = await this.execWithRetry(item.label, item.fn);
7792
+ this.recordSend(item.chatId);
7793
+ this.pauseStartedAt = 0;
7794
+ this.onSuccessfulSend();
7795
+ item.resolve(result);
7796
+ } catch (err) {
7797
+ if (is429(err)) {
7798
+ const retrySec = err.parameters?.retry_after ?? 10;
7799
+ this.enterPause(retrySec, item);
7800
+ continue;
7801
+ }
7802
+ item.reject(err);
7803
+ }
7804
+ }
7805
+ } finally {
7806
+ this.processing = false;
7807
+ }
7808
+ }
7809
+ /**
7810
+ * Select the next queue item to process, respecting circuit breaker state.
7811
+ * Returns the item (already removed from queue) or null if nothing processable.
7812
+ */
7813
+ selectNextItem() {
7814
+ if (this.circuitState === "closed" /* CLOSED */) {
7815
+ return this.dequeue();
7816
+ }
7817
+ if (this.circuitState === "open" /* OPEN */) {
7818
+ while (this.queue.length > 0 && this.queue[0].priority === 2 /* P2_COSMETIC */) {
7819
+ const dropped = this.dequeue();
7820
+ warn(`[throttle] Circuit OPEN \u2014 dropping P2: ${dropped.label}`);
7821
+ dropped.reject(new Error("Circuit breaker OPEN \u2014 cosmetic item dropped"));
7822
+ }
7823
+ if (this.queue.length > 0 && this.queue[0].priority === 0 /* P0_CRITICAL */) {
7824
+ return this.dequeue();
7825
+ }
7826
+ return null;
7827
+ }
7828
+ if (this.circuitState === "half_open" /* HALF_OPEN */) {
7829
+ return this.dequeue();
7830
+ }
7831
+ return this.dequeue();
7832
+ }
7833
+ /**
7834
+ * Check if circuit breaker should transition states.
7835
+ * OPEN → HALF_OPEN when cooldown expires.
7836
+ */
7837
+ updateCircuitState() {
7838
+ if (this.circuitState === "open" /* OPEN */ && Date.now() >= this.circuitCooldownUntil) {
7839
+ this.circuitState = "half_open" /* HALF_OPEN */;
7840
+ log(`[throttle] Circuit breaker: OPEN \u2192 HALF_OPEN (cooldown expired, probing)`);
7841
+ }
7842
+ }
7843
+ /**
7844
+ * Handle successful send — manage circuit breaker recovery.
7845
+ */
7846
+ onSuccessfulSend() {
7847
+ this.lastSuccessfulSend = Date.now();
7848
+ if (this.circuitState === "half_open" /* HALF_OPEN */) {
7849
+ this.circuitState = "closed" /* CLOSED */;
7850
+ log(`[throttle] Circuit breaker: HALF_OPEN \u2192 CLOSED (probe succeeded)`);
7851
+ }
7852
+ if (this.circuitTrips.length > 0) {
7853
+ const lastTrip = this.circuitTrips[this.circuitTrips.length - 1];
7854
+ if (Date.now() - lastTrip > CIRCUIT_RESET_WINDOW_MS) {
7855
+ this.circuitTrips.length = 0;
7856
+ this.circuitTrips = [];
7857
+ log(`[throttle] Circuit breaker: trip count reset after ${CIRCUIT_RESET_WINDOW_MS / 6e4}min of success`);
7858
+ }
7859
+ }
7860
+ }
7861
+ // ── Retry logic (non-429 errors only) ───────────────────────────────
7862
+ async execWithRetry(label2, fn) {
7863
+ for (let attempt = 0; attempt <= MAX_RETRIES2; attempt++) {
7864
+ try {
7865
+ return await fn();
7866
+ } catch (err) {
7867
+ if (is429(err)) throw err;
7868
+ if (attempt < MAX_RETRIES2 && err instanceof GrammyError) {
7869
+ warn(`[throttle] ${label2} attempt ${attempt + 1}/${MAX_RETRIES2} failed (${err.error_code}), retrying`);
7870
+ await sleep(RETRY_DELAY_MS);
7871
+ continue;
7872
+ }
7873
+ throw err;
7874
+ }
7875
+ }
7876
+ throw new Error("unreachable");
7877
+ }
7878
+ // ── Pause management ────────────────────────────────────────────────
7879
+ enterPause(retrySec, failedItem) {
7880
+ this.queue.unshift(failedItem);
7881
+ const now = Date.now();
7882
+ this.circuitTrips.push(now);
7883
+ this.circuitTrips = this.circuitTrips.filter((t) => now - t < CIRCUIT_TRIP_WINDOW_MS);
7884
+ const bufferSec = this.circuitTrips.length * CIRCUIT_COOLDOWN_STEP_SEC;
7885
+ const totalPauseSec = retrySec + bufferSec;
7886
+ this.pausedUntil = now + totalPauseSec * 1e3;
7887
+ if (this.pauseStartedAt === 0) this.pauseStartedAt = now;
7888
+ if (this.circuitTrips.length >= CIRCUIT_TRIP_THRESHOLD && this.circuitState !== "open" /* OPEN */) {
7889
+ this.circuitState = "open" /* OPEN */;
7890
+ this.circuitCooldownUntil = now + totalPauseSec * 1e3;
7891
+ warn(`[throttle] Circuit breaker TRIPPED \u2192 OPEN (${this.circuitTrips.length} 429s in ${CIRCUIT_TRIP_WINDOW_MS / 6e4}min)`);
7892
+ } else if (this.circuitState === "half_open" /* HALF_OPEN */) {
7893
+ this.circuitState = "open" /* OPEN */;
7894
+ this.circuitCooldownUntil = now + totalPauseSec * 1e3;
7895
+ warn(`[throttle] Circuit breaker probe FAILED \u2192 OPEN (retry_after=${retrySec}s + ${bufferSec}s buffer)`);
7896
+ }
7897
+ warn(`[throttle] 429 event`, JSON.stringify({
7898
+ method: failedItem.label,
7899
+ chatId: failedItem.chatId,
7900
+ priority: failedItem.priority,
7901
+ retry_after: retrySec,
7902
+ buffer: bufferSec,
7903
+ total_pause: totalPauseSec,
7904
+ queue_depth: this.queue.length,
7905
+ circuit_state: this.circuitState,
7906
+ circuit_trip_count: this.circuitTrips.length
7907
+ }));
7908
+ }
7909
+ // ── Helpers ─────────────────────────────────────────────────────────
7910
+ recordSend(chatId) {
7911
+ const now = Date.now();
7912
+ this.lastSendPerChat.set(chatId, now);
7913
+ this.lastGlobalSend = now;
7914
+ }
7915
+ /** Remove and return the first item from the queue, updating per-chat counter. */
7916
+ dequeue() {
7917
+ const item = this.queue.shift();
7918
+ if (item) this.decrementChatCount(item.chatId);
7919
+ return item ?? null;
7920
+ }
7921
+ decrementChatCount(chatId) {
7922
+ const count = (this.perChatQueueCount.get(chatId) ?? 1) - 1;
7923
+ if (count <= 0) this.perChatQueueCount.delete(chatId);
7924
+ else this.perChatQueueCount.set(chatId, count);
7925
+ }
7926
+ flushQueueWithError(message) {
7927
+ while (this.queue.length > 0) {
7928
+ const item = this.dequeue();
7929
+ item.reject(new Error(message));
7930
+ }
7931
+ }
7932
+ };
7933
+ }
7934
+ });
7935
+
7562
7936
  // src/dashboard/routes/system.ts
7563
7937
  var handleHealth, handleJobs, handleMemories, handleStats, handleAgents, handleTasks, handleOrchestrations;
7564
7938
  var init_system = __esm({
@@ -7570,8 +7944,10 @@ var init_system = __esm({
7570
7944
  init_store5();
7571
7945
  init_store();
7572
7946
  init_version();
7947
+ init_telegram_throttle();
7573
7948
  handleHealth = (_req, res) => {
7574
- jsonResponse(res, { status: "ok", version: VERSION, uptime: process.uptime() });
7949
+ const throttle = getThrottleState();
7950
+ jsonResponse(res, { status: "ok", version: VERSION, uptime: process.uptime(), throttle: throttle ?? void 0 });
7575
7951
  };
7576
7952
  handleJobs = (_req, res) => {
7577
7953
  jsonResponse(res, listJobs());
@@ -7936,7 +8312,7 @@ function buildContextPrefix(msg) {
7936
8312
  }
7937
8313
  async function sendOrEditKeyboard(chatId, channel, messageId, text, buttons) {
7938
8314
  if (messageId && typeof channel.editKeyboard === "function") {
7939
- const ok = await channel.editKeyboard(chatId, messageId, text, buttons, { priority: true });
8315
+ const ok = await channel.editKeyboard(chatId, messageId, text, buttons, { priority: 0 /* P0_CRITICAL */ });
7940
8316
  if (ok) return messageId;
7941
8317
  }
7942
8318
  if (typeof channel.sendKeyboard === "function") {
@@ -7949,6 +8325,7 @@ var TONE_PATTERNS, BLOCKED_PATH_PATTERNS, CLI_INSTALL_HINTS, PERM_MODES, VERBOSE
7949
8325
  var init_helpers = __esm({
7950
8326
  "src/router/helpers.ts"() {
7951
8327
  "use strict";
8328
+ init_telegram_throttle();
7952
8329
  init_store5();
7953
8330
  init_backends();
7954
8331
  TONE_PATTERNS = [
@@ -8728,8 +9105,8 @@ function checkBudget(db3, orchestrationId) {
8728
9105
  }
8729
9106
  const percentUsed = totalCost / budgetLimit * 100;
8730
9107
  const exceeded = totalCost >= budgetLimit;
8731
- const warning4 = percentUsed >= BUDGET_WARNING_PERCENT * 100;
8732
- return { totalCost, budgetLimit, percentUsed, exceeded, warning: warning4 };
9108
+ const warning3 = percentUsed >= BUDGET_WARNING_PERCENT * 100;
9109
+ return { totalCost, budgetLimit, percentUsed, exceeded, warning: warning3 };
8733
9110
  }
8734
9111
  function recordAgentCost(db3, orchestrationId, agentCost) {
8735
9112
  updateOrchestrationCost(db3, orchestrationId, agentCost);
@@ -16392,251 +16769,6 @@ var init_health2 = __esm({
16392
16769
  }
16393
16770
  });
16394
16771
 
16395
- // src/channels/telegram-throttle.ts
16396
- var telegram_throttle_exports = {};
16397
- __export(telegram_throttle_exports, {
16398
- TelegramThrottle: () => TelegramThrottle,
16399
- getThrottleState: () => getThrottleState
16400
- });
16401
- import { GrammyError } from "grammy";
16402
- function isEditLabel(label2) {
16403
- return label2.startsWith("editText") || label2.startsWith("editKeyboard");
16404
- }
16405
- function perChatInterval(chatId) {
16406
- return parseInt(chatId) < 0 ? PER_GROUP_INTERVAL_MS : PER_DM_INTERVAL_MS;
16407
- }
16408
- function getThrottleState() {
16409
- if (!_activeThrottle) return null;
16410
- return _activeThrottle.getState();
16411
- }
16412
- function is429(err) {
16413
- return err instanceof GrammyError && err.error_code === 429;
16414
- }
16415
- function sleep(ms) {
16416
- return new Promise((r) => setTimeout(r, ms));
16417
- }
16418
- var PER_DM_INTERVAL_MS, PER_GROUP_INTERVAL_MS, GLOBAL_INTERVAL_MS, MAX_RETRIES2, RETRY_DELAY_MS, MAX_QUEUE_SIZE, EDIT_PRESSURE_THRESHOLD, MAX_TOTAL_PAUSE_MS, _activeThrottle, TelegramThrottle;
16419
- var init_telegram_throttle = __esm({
16420
- "src/channels/telegram-throttle.ts"() {
16421
- "use strict";
16422
- init_log();
16423
- PER_DM_INTERVAL_MS = 1e3;
16424
- PER_GROUP_INTERVAL_MS = 3500;
16425
- GLOBAL_INTERVAL_MS = 100;
16426
- MAX_RETRIES2 = 2;
16427
- RETRY_DELAY_MS = 1e3;
16428
- MAX_QUEUE_SIZE = 100;
16429
- EDIT_PRESSURE_THRESHOLD = MAX_QUEUE_SIZE / 2;
16430
- MAX_TOTAL_PAUSE_MS = 30 * 60 * 1e3;
16431
- _activeThrottle = null;
16432
- TelegramThrottle = class {
16433
- queue = [];
16434
- processing = false;
16435
- lastSendPerChat = /* @__PURE__ */ new Map();
16436
- lastGlobalSend = 0;
16437
- // Pause state
16438
- pausedUntil = 0;
16439
- pauseStartedAt = 0;
16440
- chatsPendingNotification = /* @__PURE__ */ new Set();
16441
- resumeNotifier;
16442
- constructor() {
16443
- _activeThrottle = this;
16444
- }
16445
- /**
16446
- * Register a callback that fires when the throttle resumes after a pause.
16447
- * The callback should send a message directly via bot.api (NOT through the throttle).
16448
- */
16449
- setResumeNotifier(fn) {
16450
- this.resumeNotifier = fn;
16451
- }
16452
- /** Enqueue a Telegram API call with automatic pacing and 429 handling.
16453
- * When `priority` is true the item jumps to the front of the queue —
16454
- * used by fast-path commands (/status, /stop, etc.) so their responses
16455
- * aren't delayed behind tool-notification or error-message floods. */
16456
- async send(chatId, label2, fn, priority) {
16457
- if (isEditLabel(label2)) {
16458
- if (this.isPaused()) {
16459
- throw new Error("Throttle paused (rate limit active) \u2014 edit skipped");
16460
- }
16461
- if (this.queue.length >= EDIT_PRESSURE_THRESHOLD) {
16462
- throw new Error("Throttle queue pressured \u2014 edit skipped");
16463
- }
16464
- }
16465
- return new Promise((resolve3, reject) => {
16466
- if (this.queue.length >= MAX_QUEUE_SIZE) {
16467
- const editIdx = this.queue.findIndex((q) => isEditLabel(q.label));
16468
- const dropIdx = editIdx >= 0 ? editIdx : 0;
16469
- const dropped = this.queue.splice(dropIdx, 1)[0];
16470
- if (dropped) {
16471
- warn(`[throttle] Queue full (${MAX_QUEUE_SIZE}), dropping: ${dropped.label}`);
16472
- dropped.reject(new Error("Dropped from send queue (overflow)"));
16473
- }
16474
- }
16475
- const item = { chatId, label: label2, fn, resolve: resolve3, reject };
16476
- if (priority) {
16477
- this.queue.unshift(item);
16478
- } else {
16479
- this.queue.push(item);
16480
- }
16481
- this.drain();
16482
- });
16483
- }
16484
- /**
16485
- * Best-effort send — drops silently if throttle is paused or queue is pressured.
16486
- * Used for cosmetic calls (typing indicators, reactions) that should count toward
16487
- * rate limits but must never queue up or amplify 429 spirals.
16488
- */
16489
- async tryBestEffort(chatId, label2, fn, opts) {
16490
- if (this.isPaused()) return void 0;
16491
- if (this.queue.length > 10) return void 0;
16492
- if (!opts?.skipRecord) {
16493
- const lastChat = this.lastSendPerChat.get(chatId) ?? 0;
16494
- if (Date.now() - lastChat < perChatInterval(chatId)) return void 0;
16495
- if (Date.now() - this.lastGlobalSend < GLOBAL_INTERVAL_MS) return void 0;
16496
- }
16497
- try {
16498
- const result = await fn();
16499
- if (!opts?.skipRecord) this.recordSend(chatId);
16500
- return result;
16501
- } catch (err) {
16502
- if (is429(err)) {
16503
- const retrySec = err.parameters?.retry_after ?? 10;
16504
- this.pausedUntil = Date.now() + retrySec * 1e3;
16505
- if (this.pauseStartedAt === 0) this.pauseStartedAt = Date.now();
16506
- warn(`[throttle] Best-effort ${label2} hit 429, pausing for ${retrySec}s`);
16507
- }
16508
- return void 0;
16509
- }
16510
- }
16511
- /** Check whether the throttle is currently paused (rate-limited). */
16512
- isPaused() {
16513
- return Date.now() < this.pausedUntil;
16514
- }
16515
- /** Get structured state for diagnostics / health checks. */
16516
- getState() {
16517
- const now = Date.now();
16518
- const paused = now < this.pausedUntil;
16519
- return {
16520
- isPaused: paused,
16521
- queueDepth: this.queue.length,
16522
- pausedUntilMs: this.pausedUntil,
16523
- pauseRemainingSec: paused ? Math.ceil((this.pausedUntil - now) / 1e3) : 0
16524
- };
16525
- }
16526
- // ── Queue processor ─────────────────────────────────────────────────
16527
- async drain() {
16528
- if (this.processing) return;
16529
- this.processing = true;
16530
- try {
16531
- while (this.queue.length > 0) {
16532
- while (this.isPaused()) {
16533
- if (this.pauseStartedAt > 0 && Date.now() - this.pauseStartedAt > MAX_TOTAL_PAUSE_MS) {
16534
- warn(`[throttle] Max pause duration exceeded (${MAX_TOTAL_PAUSE_MS / 6e4}min), dropping ${this.queue.length} items`);
16535
- this.flushQueueWithError("Telegram rate limit exceeded max wait time");
16536
- this.pausedUntil = 0;
16537
- this.pauseStartedAt = 0;
16538
- this.chatsPendingNotification.clear();
16539
- break;
16540
- }
16541
- const waitMs = Math.min(this.pausedUntil - Date.now(), 5e3);
16542
- if (waitMs > 0) await sleep(waitMs);
16543
- }
16544
- if (this.queue.length === 0) break;
16545
- if (this.chatsPendingNotification.size > 0) {
16546
- await this.sendResumeNotifications();
16547
- }
16548
- const item = this.queue[0];
16549
- const lastChat = this.lastSendPerChat.get(item.chatId) ?? 0;
16550
- const chatWait = perChatInterval(item.chatId) - (Date.now() - lastChat);
16551
- if (chatWait > 0) await sleep(chatWait);
16552
- const globalWait = GLOBAL_INTERVAL_MS - (Date.now() - this.lastGlobalSend);
16553
- if (globalWait > 0) await sleep(globalWait);
16554
- this.queue.shift();
16555
- try {
16556
- const result = await this.execWithRetry(item.label, item.fn);
16557
- this.recordSend(item.chatId);
16558
- this.pauseStartedAt = 0;
16559
- item.resolve(result);
16560
- } catch (err) {
16561
- if (is429(err)) {
16562
- const retrySec = err.parameters?.retry_after ?? 10;
16563
- this.enterPause(retrySec, item);
16564
- continue;
16565
- }
16566
- item.reject(err);
16567
- }
16568
- }
16569
- } finally {
16570
- this.processing = false;
16571
- }
16572
- }
16573
- // ── Retry logic (non-429 errors only) ───────────────────────────────
16574
- async execWithRetry(label2, fn) {
16575
- for (let attempt = 0; attempt <= MAX_RETRIES2; attempt++) {
16576
- try {
16577
- return await fn();
16578
- } catch (err) {
16579
- if (is429(err)) throw err;
16580
- if (attempt < MAX_RETRIES2 && err instanceof GrammyError) {
16581
- warn(`[throttle] ${label2} attempt ${attempt + 1}/${MAX_RETRIES2} failed (${err.error_code}), retrying`);
16582
- await sleep(RETRY_DELAY_MS);
16583
- continue;
16584
- }
16585
- throw err;
16586
- }
16587
- }
16588
- throw new Error("unreachable");
16589
- }
16590
- // ── Pause management ────────────────────────────────────────────────
16591
- enterPause(retrySec, failedItem) {
16592
- this.queue.unshift(failedItem);
16593
- const bufferedSec = Math.ceil(retrySec * 1.5);
16594
- this.pausedUntil = Date.now() + bufferedSec * 1e3;
16595
- if (this.pauseStartedAt === 0) this.pauseStartedAt = Date.now();
16596
- for (const qi of this.queue) {
16597
- this.chatsPendingNotification.add(qi.chatId);
16598
- }
16599
- warn(`[throttle] 429 \u2014 pausing ALL sends for ${bufferedSec}s (retry_after=${retrySec}s + 50% buffer, ${this.queue.length} items queued)`);
16600
- }
16601
- async sendResumeNotifications() {
16602
- const chats2 = new Set(this.chatsPendingNotification);
16603
- this.chatsPendingNotification.clear();
16604
- if (!this.resumeNotifier) return;
16605
- const pausedSec = this.pauseStartedAt > 0 ? Math.round((Date.now() - this.pauseStartedAt) / 1e3) : 0;
16606
- for (const chatId of chats2) {
16607
- const queuedForChat = this.queue.filter((q) => q.chatId === chatId).length;
16608
- if (queuedForChat === 0) continue;
16609
- try {
16610
- await this.resumeNotifier(chatId, pausedSec, queuedForChat);
16611
- this.recordSend(chatId);
16612
- } catch (err) {
16613
- if (is429(err)) {
16614
- const retrySec = err.parameters?.retry_after ?? 10;
16615
- this.pausedUntil = Date.now() + retrySec * 1e3;
16616
- warn(`[throttle] Resume notification hit 429, re-pausing for ${retrySec}s (skipping further notifications)`);
16617
- return;
16618
- }
16619
- warn(`[throttle] Resume notification failed for chat ${chatId}: ${err}`);
16620
- }
16621
- }
16622
- this.pauseStartedAt = 0;
16623
- }
16624
- // ── Helpers ─────────────────────────────────────────────────────────
16625
- recordSend(chatId) {
16626
- const now = Date.now();
16627
- this.lastSendPerChat.set(chatId, now);
16628
- this.lastGlobalSend = now;
16629
- }
16630
- flushQueueWithError(message) {
16631
- while (this.queue.length > 0) {
16632
- const item = this.queue.shift();
16633
- item.reject(new Error(message));
16634
- }
16635
- }
16636
- };
16637
- }
16638
- });
16639
-
16640
16772
  // src/health/checks.ts
16641
16773
  import { existsSync as existsSync16, statSync as statSync5, readFileSync as readFileSync11 } from "fs";
16642
16774
  import { execFileSync as execFileSync2, execSync as execSync2 } from "child_process";
@@ -17978,38 +18110,32 @@ async function sendThinkingKeyboard(chatId, channel, messageId, forModelId) {
17978
18110
  const currentModel = forModelId ?? getModel(chatId) ?? adapter.defaultModel;
17979
18111
  const modelInfo = adapter.availableModels[currentModel];
17980
18112
  const currentLevel = getThinkingLevel(chatId) || "auto";
17981
- if (!modelInfo || modelInfo.thinking !== "adjustable" || !modelInfo.thinkingLevels) {
17982
- await sendOrEditKeyboard(
17983
- chatId,
17984
- channel,
17985
- messageId,
17986
- `Model ${shortModelName(currentModel)} uses fixed thinking \u2014 no adjustment needed.`,
17987
- [[{ label: "\u2190 Back to Model", data: "menu:model" }]]
17988
- );
17989
- return;
17990
- }
17991
18113
  const showThinkingUi = getShowThinkingUi(chatId);
17992
- const buttons = modelInfo.thinkingLevels.map((level) => [{
17993
- label: `${level === currentLevel ? "\u2713 " : ""}${level === "auto" ? "Auto" : capitalize(level)}`,
17994
- data: `thinking:${level}`,
17995
- ...level === currentLevel ? { style: "primary" } : {}
17996
- }]);
18114
+ const canAdjust = modelInfo?.thinking === "adjustable" && modelInfo.thinkingLevels;
18115
+ const buttons = [];
18116
+ if (canAdjust) {
18117
+ for (const level of modelInfo.thinkingLevels) {
18118
+ buttons.push([{
18119
+ label: `${level === currentLevel ? "\u2713 " : ""}${level === "auto" ? "Auto" : capitalize(level)}`,
18120
+ data: `thinking:${level}`,
18121
+ ...level === currentLevel ? { style: "primary" } : {}
18122
+ }]);
18123
+ }
18124
+ }
17997
18125
  buttons.push([{
17998
18126
  label: `${showThinkingUi ? "\u2713 " : ""}\u{1F4AD} Show Thinking`,
17999
18127
  data: "thinking_show_ui:toggle",
18000
18128
  ...showThinkingUi ? { style: "primary" } : {}
18001
18129
  }]);
18002
- await sendOrEditKeyboard(
18003
- chatId,
18004
- channel,
18005
- messageId,
18006
- `\u{1F4AD} Thinking Level \u2014 ${shortModelName(currentModel)}
18130
+ const header2 = canAdjust ? `\u{1F4AD} Thinking Level \u2014 ${shortModelName(currentModel)}
18007
18131
  Current: ${capitalize(currentLevel)}
18008
- Show thinking tokens: ${showThinkingUi ? "On" : "Off"}${adapter.id === "cursor" ? `
18132
+ Show thinking tokens: ${showThinkingUi ? "On" : "Off"}` : `\u{1F4AD} Thinking \u2014 ${shortModelName(currentModel)}
18133
+ Level: Fixed
18134
+ Show thinking tokens: ${showThinkingUi ? "On" : "Off"}`;
18135
+ const note = adapter.id === "cursor" ? `
18009
18136
 
18010
- \u26A0\uFE0F ${adapter.displayName} doesn't expose thinking tokens` : ""}`,
18011
- buttons
18012
- );
18137
+ \u26A0\uFE0F ${adapter.displayName} doesn't expose thinking tokens` : "";
18138
+ await sendOrEditKeyboard(chatId, channel, messageId, `${header2}${note}`, buttons);
18013
18139
  }
18014
18140
  async function sendSkillsPage(chatId, channel, skills2, page, messageId) {
18015
18141
  const approved = skills2.filter((s) => s.status === "approved");
@@ -18810,7 +18936,8 @@ async function sendBackendModelPicker(chatId, backendId, channel, messageId) {
18810
18936
  const summary = backendConfigSummary(chatId, backendId, false);
18811
18937
  if (adapter.type === "api") {
18812
18938
  const apiModels = getApiModels(backendId);
18813
- if (apiModels.length === 0) {
18939
+ const adapterModelCount = Object.keys(adapter.availableModels).length;
18940
+ if (apiModels.length === 0 && adapterModelCount === 0) {
18814
18941
  await sendOrEditKeyboard(
18815
18942
  chatId,
18816
18943
  channel,
@@ -18825,23 +18952,25 @@ No models configured. Add one with \u2795`,
18825
18952
  );
18826
18953
  return;
18827
18954
  }
18828
- const modelButtons2 = [];
18829
- for (const m of apiModels) {
18830
- const isActive = m.modelId === currentModel;
18831
- const freeTag = m.isFree && !m.displayName.toLowerCase().includes("free") ? " (free)" : "";
18832
- modelButtons2.push([
18833
- {
18834
- label: `${isActive ? "\u2713 " : ""}${m.displayName}${freeTag}`,
18835
- data: `apimodel:sel:${m.id}`,
18836
- ...isActive ? { style: "primary" } : {}
18837
- },
18838
- { label: "\u{1F5D1}", data: `apimodel:del:${m.id}` }
18839
- ]);
18955
+ if (apiModels.length > 0) {
18956
+ const modelButtons2 = [];
18957
+ for (const m of apiModels) {
18958
+ const isActive = m.modelId === currentModel;
18959
+ const freeTag = m.isFree && !m.displayName.toLowerCase().includes("free") ? " (free)" : "";
18960
+ modelButtons2.push([
18961
+ {
18962
+ label: `${isActive ? "\u2713 " : ""}${m.displayName}${freeTag}`,
18963
+ data: `apimodel:sel:${m.id}`,
18964
+ ...isActive ? { style: "primary" } : {}
18965
+ },
18966
+ { label: "\u{1F5D1}", data: `apimodel:del:${m.id}` }
18967
+ ]);
18968
+ }
18969
+ modelButtons2.push([{ label: "\u2795 Add Model", data: `apimodel:add:${backendId}` }]);
18970
+ modelButtons2.push([{ label: "\u2190 Back", data: `bconf:panel:${backendId}` }]);
18971
+ await sendOrEditKeyboard(chatId, channel, messageId, summary, modelButtons2);
18972
+ return;
18840
18973
  }
18841
- modelButtons2.push([{ label: "\u2795 Add Model", data: `apimodel:add:${backendId}` }]);
18842
- modelButtons2.push([{ label: "\u2190 Back", data: `bconf:panel:${backendId}` }]);
18843
- await sendOrEditKeyboard(chatId, channel, messageId, summary, modelButtons2);
18844
- return;
18845
18974
  }
18846
18975
  const modelButtons = Object.entries(adapter.availableModels).map(([id, info]) => [{
18847
18976
  label: `${id === currentModel ? "\u2713 " : ""}${info.label}`,
@@ -22467,18 +22596,26 @@ var init_ollama2 = __esm({
22467
22596
  */
22468
22597
  async streamDirect(prompt, model2, opts) {
22469
22598
  const cleanPrompt = stripForLocalModel(prompt);
22470
- let forceThinkOff = false;
22599
+ let disableThinking = false;
22471
22600
  try {
22472
22601
  const { OllamaStore } = (init_ollama(), __toCommonJS(ollama_exports));
22473
22602
  const modelRecord = OllamaStore.getModelByName(model2);
22474
- forceThinkOff = modelRecord?.forceThinkOff ?? false;
22603
+ if (modelRecord?.forceThinkOff) {
22604
+ disableThinking = true;
22605
+ } else if (opts?.thinkingLevel === "off") {
22606
+ disableThinking = true;
22607
+ }
22475
22608
  } catch {
22476
22609
  }
22477
22610
  const apiOpts = {
22478
22611
  timeoutMs: opts?.timeoutMs,
22479
22612
  onStream: opts?.onStream,
22480
22613
  signal: opts?.signal,
22481
- ...forceThinkOff ? { providerOptions: { ollama: { think: false } } } : {}
22614
+ messageHistory: opts?.messageHistory,
22615
+ permMode: opts?.permMode,
22616
+ thinkingLevel: opts?.thinkingLevel,
22617
+ onThinking: opts?.onThinking,
22618
+ ...disableThinking ? { providerOptions: { ollama: { think: false } } } : {}
22482
22619
  };
22483
22620
  const result = await this.streamDirectWithHistory(
22484
22621
  cleanPrompt,
@@ -22503,9 +22640,10 @@ var init_ollama2 = __esm({
22503
22640
  const { OllamaStore } = (init_ollama(), __toCommonJS(ollama_exports));
22504
22641
  const models = OllamaStore.getAvailableModels();
22505
22642
  for (const m of models) {
22643
+ const isThinkingCapable = m.capability === "thinking" && !m.forceThinkOff;
22506
22644
  this.availableModels[m.name] = {
22507
22645
  label: `${m.name}${m.parameterSize ? ` (${m.parameterSize})` : ""}`,
22508
- thinking: "none"
22646
+ thinking: isThinkingCapable ? "adjustable" : "none"
22509
22647
  };
22510
22648
  this.pricing[m.name] = { in: 0, out: 0, cache: 0 };
22511
22649
  this.contextWindow[m.name] = m.contextWindow ?? 4096;
@@ -22800,7 +22938,7 @@ var init_backends = __esm({
22800
22938
  ollama: new OllamaAdapter(),
22801
22939
  openrouter: openRouterAdapter
22802
22940
  };
22803
- CHAT_BACKEND_IDS = ["claude", "gemini", "codex", "cursor", "openrouter"];
22941
+ CHAT_BACKEND_IDS = ["claude", "gemini", "codex", "cursor", "openrouter", "ollama"];
22804
22942
  availableSet = /* @__PURE__ */ new Set();
22805
22943
  }
22806
22944
  });
@@ -24397,13 +24535,152 @@ var init_session_log2 = __esm({
24397
24535
  }
24398
24536
  });
24399
24537
 
24400
- // src/router/live-status.ts
24401
- function canFlushGlobally() {
24402
- return Date.now() - globalLastFlushAt >= GLOBAL_MIN_GAP_MS;
24538
+ // src/channels/edit-coordinator.ts
24539
+ var edit_coordinator_exports = {};
24540
+ __export(edit_coordinator_exports, {
24541
+ getEditCoordinator: () => getEditCoordinator,
24542
+ resetEditCoordinator: () => resetEditCoordinator
24543
+ });
24544
+ function getEditCoordinator() {
24545
+ return EditCoordinator.getInstance();
24403
24546
  }
24404
- function markGlobalFlush() {
24405
- globalLastFlushAt = Date.now();
24547
+ function resetEditCoordinator() {
24548
+ EditCoordinator.resetInstance();
24406
24549
  }
24550
+ var TICK_INTERVAL_MS, MAX_EDITS_PER_WINDOW, EDIT_WINDOW_MS, EditCoordinator;
24551
+ var init_edit_coordinator = __esm({
24552
+ "src/channels/edit-coordinator.ts"() {
24553
+ "use strict";
24554
+ init_log();
24555
+ TICK_INTERVAL_MS = 1e3;
24556
+ MAX_EDITS_PER_WINDOW = 4;
24557
+ EDIT_WINDOW_MS = 6e4;
24558
+ EditCoordinator = class _EditCoordinator {
24559
+ static instance = null;
24560
+ /** Active streams indexed by messageId. */
24561
+ activeStreams = /* @__PURE__ */ new Map();
24562
+ /** Per-message edit tracking for the sliding window cap. */
24563
+ perMessageEditCount = /* @__PURE__ */ new Map();
24564
+ /** Single flush timer shared across all streams. */
24565
+ flushTimer = null;
24566
+ /** Round-robin index — cycles through stream keys. */
24567
+ roundRobinIndex = 0;
24568
+ /** Ordered list of stream keys for round-robin iteration.
24569
+ * Rebuilt on register/unregister to avoid iterator invalidation. */
24570
+ streamKeys = [];
24571
+ constructor() {
24572
+ }
24573
+ static getInstance() {
24574
+ if (!_EditCoordinator.instance) {
24575
+ _EditCoordinator.instance = new _EditCoordinator();
24576
+ }
24577
+ return _EditCoordinator.instance;
24578
+ }
24579
+ /** Reset the singleton (for testing only). */
24580
+ static resetInstance() {
24581
+ if (_EditCoordinator.instance) {
24582
+ _EditCoordinator.instance.shutdown();
24583
+ _EditCoordinator.instance = null;
24584
+ }
24585
+ }
24586
+ /** Register a stream to be managed by the coordinator.
24587
+ * Creates the flush timer if this is the first stream. */
24588
+ register(messageId, stream) {
24589
+ this.activeStreams.set(messageId, stream);
24590
+ this.rebuildKeys();
24591
+ log(`[edit-coordinator] registered stream ${messageId} (${this.activeStreams.size} active)`);
24592
+ if (!this.flushTimer) {
24593
+ this.flushTimer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
24594
+ log(`[edit-coordinator] timer started (${TICK_INTERVAL_MS}ms tick)`);
24595
+ }
24596
+ }
24597
+ /** Unregister a stream (called on finalization).
24598
+ * Cleans up per-message edit tracking. Stops timer if no streams remain. */
24599
+ unregister(messageId) {
24600
+ this.activeStreams.delete(messageId);
24601
+ this.perMessageEditCount.delete(messageId);
24602
+ this.rebuildKeys();
24603
+ log(`[edit-coordinator] unregistered stream ${messageId} (${this.activeStreams.size} remaining)`);
24604
+ if (this.activeStreams.size === 0 && this.flushTimer) {
24605
+ clearInterval(this.flushTimer);
24606
+ this.flushTimer = null;
24607
+ this.roundRobinIndex = 0;
24608
+ log(`[edit-coordinator] timer stopped (no active streams)`);
24609
+ }
24610
+ }
24611
+ /** Shut down the coordinator — clear timer, remove all streams. */
24612
+ shutdown() {
24613
+ if (this.flushTimer) {
24614
+ clearInterval(this.flushTimer);
24615
+ this.flushTimer = null;
24616
+ }
24617
+ this.activeStreams.clear();
24618
+ this.perMessageEditCount.clear();
24619
+ this.streamKeys = [];
24620
+ this.roundRobinIndex = 0;
24621
+ }
24622
+ /** Get the number of active streams (for diagnostics). */
24623
+ get streamCount() {
24624
+ return this.activeStreams.size;
24625
+ }
24626
+ /** Check whether a message can be edited (under the per-message cap). */
24627
+ canEditMessage(messageId) {
24628
+ const record = this.perMessageEditCount.get(messageId);
24629
+ if (!record) return true;
24630
+ const now = Date.now();
24631
+ if (now - record.windowStart >= EDIT_WINDOW_MS) {
24632
+ return true;
24633
+ }
24634
+ return record.count < MAX_EDITS_PER_WINDOW;
24635
+ }
24636
+ /** Record that an edit was made to a message. */
24637
+ recordEdit(messageId) {
24638
+ const now = Date.now();
24639
+ const record = this.perMessageEditCount.get(messageId);
24640
+ if (!record || now - record.windowStart >= EDIT_WINDOW_MS) {
24641
+ this.perMessageEditCount.set(messageId, { count: 1, windowStart: now });
24642
+ } else {
24643
+ record.count++;
24644
+ }
24645
+ }
24646
+ // ── Internal ──────────────────────────────────────────────────────────
24647
+ /** Rebuild the ordered keys array after registration changes. */
24648
+ rebuildKeys() {
24649
+ this.streamKeys = Array.from(this.activeStreams.keys());
24650
+ if (this.streamKeys.length > 0) {
24651
+ this.roundRobinIndex = this.roundRobinIndex % this.streamKeys.length;
24652
+ } else {
24653
+ this.roundRobinIndex = 0;
24654
+ }
24655
+ }
24656
+ /** Timer tick — pick the next stream via round-robin and flush it.
24657
+ * If the selected stream is at its per-message edit cap, try the next one. */
24658
+ async tick() {
24659
+ if (this.streamKeys.length === 0) return;
24660
+ const startIdx = this.roundRobinIndex;
24661
+ let tried = 0;
24662
+ while (tried < this.streamKeys.length) {
24663
+ const idx = (startIdx + tried) % this.streamKeys.length;
24664
+ const messageId = this.streamKeys[idx];
24665
+ const stream = this.activeStreams.get(messageId);
24666
+ if (stream && this.canEditMessage(messageId)) {
24667
+ this.roundRobinIndex = (idx + 1) % this.streamKeys.length;
24668
+ try {
24669
+ await stream.flush();
24670
+ this.recordEdit(messageId);
24671
+ } catch {
24672
+ }
24673
+ return;
24674
+ }
24675
+ tried++;
24676
+ }
24677
+ this.roundRobinIndex = (startIdx + 1) % this.streamKeys.length;
24678
+ }
24679
+ };
24680
+ }
24681
+ });
24682
+
24683
+ // src/router/live-status.ts
24407
24684
  function dedupThinking(entries) {
24408
24685
  const out = [];
24409
24686
  for (const e of entries) {
@@ -24467,18 +24744,15 @@ function makeLiveStatus(chatId, channel, modelLabel, verboseLevel, showThinking)
24467
24744
  };
24468
24745
  return { liveStatus, toolCb };
24469
24746
  }
24470
- var FLUSH_INTERVAL_DM_MS, FLUSH_INTERVAL_GROUP_MS, MAX_THINKING_CHARS, GLOBAL_MIN_GAP_MS, globalLastFlushAt, TRIM_THRESHOLD, MAX_ENTRIES, SPINNER_FRAMES, HEARTBEAT_TEXTS, LiveStatusMessage;
24747
+ var MAX_THINKING_CHARS, TRIM_THRESHOLD, MAX_ENTRIES, SPINNER_FRAMES, HEARTBEAT_TEXTS, LiveStatusMessage;
24471
24748
  var init_live_status = __esm({
24472
24749
  "src/router/live-status.ts"() {
24473
24750
  "use strict";
24474
24751
  init_log();
24475
24752
  init_helpers();
24476
24753
  init_telegram_throttle();
24477
- FLUSH_INTERVAL_DM_MS = 2e3;
24478
- FLUSH_INTERVAL_GROUP_MS = 5e3;
24754
+ init_edit_coordinator();
24479
24755
  MAX_THINKING_CHARS = 800;
24480
- GLOBAL_MIN_GAP_MS = 1e3;
24481
- globalLastFlushAt = 0;
24482
24756
  TRIM_THRESHOLD = 3500;
24483
24757
  MAX_ENTRIES = 200;
24484
24758
  SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -24494,7 +24768,6 @@ var init_live_status = __esm({
24494
24768
  messageId = null;
24495
24769
  entries = [];
24496
24770
  startTime = Date.now();
24497
- flushTimer = null;
24498
24771
  lastRendered = "";
24499
24772
  finalized = false;
24500
24773
  /** Earliest time the next flush is allowed (set after 429 backoff) */
@@ -24509,18 +24782,12 @@ var init_live_status = __esm({
24509
24782
  /** Spinner frame counter — advances on each flush for animation. */
24510
24783
  spinnerFrame = 0;
24511
24784
  /** Timestamp of last successful edit — used for heartbeat force-through. */
24512
- lastSuccessfulFlushAt = Date.now();
24513
24785
  /** Callback to restart typing indicator as fallback. */
24514
24786
  onTypingFallback;
24515
24787
  /** Set a callback that restarts the typing indicator loop as a fallback. */
24516
24788
  setTypingFallback(cb) {
24517
24789
  this.onTypingFallback = cb;
24518
24790
  }
24519
- /** Resolve flush interval based on chat type (group chats are rate-limited more aggressively). */
24520
- get flushIntervalMs() {
24521
- const numericId = parseInt(this.chatId);
24522
- return numericId < 0 ? FLUSH_INTERVAL_GROUP_MS : FLUSH_INTERVAL_DM_MS;
24523
- }
24524
24791
  /** Send the initial status message. Must be called before adding entries. */
24525
24792
  async init() {
24526
24793
  if (!this.channel.sendTextReturningId) return;
@@ -24528,9 +24795,7 @@ var init_live_status = __esm({
24528
24795
  const initial = `\u23F3 ${this.modelLabel} \xB7 Processing\u2026`;
24529
24796
  this.messageId = await this.channel.sendTextReturningId(this.chatId, initial, "plain") ?? null;
24530
24797
  if (this.messageId) {
24531
- this.lastSuccessfulFlushAt = Date.now();
24532
- this.flushTimer = setInterval(() => this.flush().catch(() => {
24533
- }), this.flushIntervalMs);
24798
+ getEditCoordinator().register(this.messageId, this);
24534
24799
  }
24535
24800
  } catch (err) {
24536
24801
  log(`[live-status] init failed: ${err}`);
@@ -24582,21 +24847,22 @@ var init_live_status = __esm({
24582
24847
  }
24583
24848
  /**
24584
24849
  * Finalize the status message: replace the spinner with ✅ and elapsed time.
24585
- * Stops the flush loop. No-op if no message was created (channel doesn't support editing).
24850
+ * Unregisters from EditCoordinator and sends one final P0_CRITICAL edit that
24851
+ * bypasses the coordinator entirely (finalization must never be skipped).
24852
+ * No-op if no message was created (channel doesn't support editing).
24586
24853
  */
24587
24854
  async finalize(elapsedMs) {
24588
24855
  this.finalized = true;
24589
24856
  this.pendingTools.clear();
24590
- if (this.flushTimer) {
24591
- clearInterval(this.flushTimer);
24592
- this.flushTimer = null;
24857
+ if (this.messageId) {
24858
+ getEditCoordinator().unregister(this.messageId);
24593
24859
  }
24594
24860
  if (!this.messageId || !this.channel.editText) return;
24595
24861
  const elapsedSec = (elapsedMs / 1e3).toFixed(1);
24596
24862
  const deduped = dedupThinking(this.entries);
24597
24863
  const body = renderFinal(deduped, this.modelLabel, elapsedSec, this.hasTrimmed);
24598
24864
  try {
24599
- await this.channel.editText(this.chatId, this.messageId, body, "plain");
24865
+ await this.channel.editText(this.chatId, this.messageId, body, "plain", 0 /* P0_CRITICAL */);
24600
24866
  } catch (err) {
24601
24867
  log(`[live-status] finalize edit failed: ${err}`);
24602
24868
  }
@@ -24605,7 +24871,16 @@ var init_live_status = __esm({
24605
24871
  getMessageId() {
24606
24872
  return this.messageId;
24607
24873
  }
24608
- // ── Internal ──────────────────────────────────────────────────────────
24874
+ // ── FlushableStream interface ──────────────────────────────────────────
24875
+ /** Return the chatId this stream belongs to (FlushableStream interface). */
24876
+ getChatId() {
24877
+ return this.chatId;
24878
+ }
24879
+ /**
24880
+ * Flush the current status to Telegram via editMessageText.
24881
+ * Called by the EditCoordinator on each round-robin tick.
24882
+ * Public to satisfy FlushableStream interface — do not call directly.
24883
+ */
24609
24884
  async flush() {
24610
24885
  if (this.finalized || !this.messageId || !this.channel.editText) return;
24611
24886
  if (this.consecutiveEditFailures >= _LiveStatusMessage.MAX_EDIT_FAILURES) {
@@ -24618,10 +24893,9 @@ var init_live_status = __esm({
24618
24893
  if (Date.now() < this.nextFlushAllowedAt) return;
24619
24894
  const throttleState = getThrottleState();
24620
24895
  if (throttleState?.isPaused) return;
24621
- if (!canFlushGlobally()) return;
24622
24896
  this.spinnerFrame++;
24623
24897
  const deduped = dedupThinking(this.entries);
24624
- const body = renderEntries(
24898
+ let body = renderEntries(
24625
24899
  deduped,
24626
24900
  this.modelLabel,
24627
24901
  Date.now() - this.startTime,
@@ -24629,17 +24903,21 @@ var init_live_status = __esm({
24629
24903
  this.pendingTools,
24630
24904
  this.spinnerFrame
24631
24905
  );
24906
+ if (throttleState && throttleState.queueDepth > 30) {
24907
+ body += `
24908
+ (queue: ${throttleState.queueDepth})`;
24909
+ }
24632
24910
  if (body === this.lastRendered) return;
24633
24911
  this.lastRendered = body;
24634
- markGlobalFlush();
24635
24912
  try {
24636
- await this.channel.editText(this.chatId, this.messageId, body, "plain");
24913
+ await this.channel.editText(this.chatId, this.messageId, body, "plain", 2 /* P2_COSMETIC */);
24637
24914
  this.consecutiveEditFailures = 0;
24638
24915
  this.lastSuccessfulFlushAt = Date.now();
24639
24916
  } catch (err) {
24640
24917
  this.handleRateLimit(err);
24641
24918
  }
24642
24919
  }
24920
+ // ── Internal ──────────────────────────────────────────────────────────
24643
24921
  /**
24644
24922
  * Trim entries from the BEGINNING when the rendered body exceeds the threshold.
24645
24923
  * This is the core of the single-message pattern: always show the most recent
@@ -25070,6 +25348,88 @@ var init_response = __esm({
25070
25348
  }
25071
25349
  });
25072
25350
 
25351
+ // src/channels/typing-manager.ts
25352
+ var typing_manager_exports = {};
25353
+ __export(typing_manager_exports, {
25354
+ getTypingManager: () => getTypingManager,
25355
+ resetTypingManager: () => resetTypingManager
25356
+ });
25357
+ function getTypingManager() {
25358
+ return TypingManager.getInstance();
25359
+ }
25360
+ function resetTypingManager() {
25361
+ TypingManager.resetInstance();
25362
+ }
25363
+ var TypingManager;
25364
+ var init_typing_manager = __esm({
25365
+ "src/channels/typing-manager.ts"() {
25366
+ "use strict";
25367
+ TypingManager = class _TypingManager {
25368
+ static instance = null;
25369
+ activeChats = /* @__PURE__ */ new Map();
25370
+ static getInstance() {
25371
+ if (!_TypingManager.instance) {
25372
+ _TypingManager.instance = new _TypingManager();
25373
+ }
25374
+ return _TypingManager.instance;
25375
+ }
25376
+ /**
25377
+ * Start showing typing indicator for this chat.
25378
+ * If already showing (another agent acquired), just increments refCount.
25379
+ */
25380
+ acquire(chatId, channel) {
25381
+ const entry = this.activeChats.get(chatId);
25382
+ if (entry) {
25383
+ entry.refCount++;
25384
+ return;
25385
+ }
25386
+ channel.sendTyping?.(chatId).catch(() => {
25387
+ });
25388
+ const timer = setInterval(() => {
25389
+ channel.sendTyping?.(chatId).catch(() => {
25390
+ });
25391
+ }, 4e3);
25392
+ this.activeChats.set(chatId, { refCount: 1, timer });
25393
+ }
25394
+ /**
25395
+ * Stop showing typing for this agent's perspective.
25396
+ * Only stops the timer when refCount reaches 0.
25397
+ */
25398
+ release(chatId) {
25399
+ const entry = this.activeChats.get(chatId);
25400
+ if (!entry) return;
25401
+ entry.refCount--;
25402
+ if (entry.refCount <= 0) {
25403
+ clearInterval(entry.timer);
25404
+ this.activeChats.delete(chatId);
25405
+ }
25406
+ }
25407
+ /** Clean shutdown — clear all timers. */
25408
+ shutdown() {
25409
+ for (const [, entry] of this.activeChats) {
25410
+ clearInterval(entry.timer);
25411
+ }
25412
+ this.activeChats.clear();
25413
+ }
25414
+ /** Expose active chat count for testing/diagnostics. */
25415
+ get size() {
25416
+ return this.activeChats.size;
25417
+ }
25418
+ /** Get ref count for a chat (for testing). */
25419
+ getRefCount(chatId) {
25420
+ return this.activeChats.get(chatId)?.refCount ?? 0;
25421
+ }
25422
+ /** Reset singleton (for testing only). */
25423
+ static resetInstance() {
25424
+ if (_TypingManager.instance) {
25425
+ _TypingManager.instance.shutdown();
25426
+ _TypingManager.instance = null;
25427
+ }
25428
+ }
25429
+ };
25430
+ }
25431
+ });
25432
+
25073
25433
  // src/shell/exec.ts
25074
25434
  import { execFile as execFile4 } from "child_process";
25075
25435
  function resolveShell() {
@@ -25685,18 +26045,7 @@ async function handleSideQuest(parentChatId, msg, channel) {
25685
26045
  [{ label: "\u274C Cancel", data: `sq:cancel:${sqId}` }]
25686
26046
  ]);
25687
26047
  const startTime = Date.now();
25688
- let typingActive = true;
25689
- const typingLoop = async () => {
25690
- while (typingActive) {
25691
- try {
25692
- await channel.sendTyping?.(parentChatId);
25693
- } catch {
25694
- }
25695
- await new Promise((r) => setTimeout(r, 4e3));
25696
- }
25697
- };
25698
- typingLoop().catch(() => {
25699
- });
26048
+ getTypingManager().acquire(parentChatId, channel);
25700
26049
  try {
25701
26050
  const backend2 = getBackend(parentChatId);
25702
26051
  const model2 = getModel(parentChatId);
@@ -25712,7 +26061,6 @@ async function handleSideQuest(parentChatId, msg, channel) {
25712
26061
  entityType: "main",
25713
26062
  bootstrapProfile: "interactive"
25714
26063
  });
25715
- typingActive = false;
25716
26064
  const userText = msg.text ?? "";
25717
26065
  const truncated = userText.length > 60 ? userText.slice(0, 57) + "\u2026" : userText;
25718
26066
  const header2 = `\u{1F5FA} <b>Side quest: "${truncated}"</b>
@@ -25736,10 +26084,9 @@ async function handleSideQuest(parentChatId, msg, channel) {
25736
26084
  log(`[reflection] Side quest signal detection error: ${e}`);
25737
26085
  }
25738
26086
  } catch (err) {
25739
- typingActive = false;
25740
26087
  await channel.sendText(parentChatId, `\u{1F5FA} Side quest failed: ${err.message}`, { parseMode: "plain" });
25741
26088
  } finally {
25742
- typingActive = false;
26089
+ getTypingManager().release(parentChatId);
25743
26090
  const activeSet = activeSideQuests.get(parentChatId);
25744
26091
  if (activeSet) {
25745
26092
  activeSet.delete(sqId);
@@ -25751,6 +26098,7 @@ async function handleSideQuest(parentChatId, msg, channel) {
25751
26098
  var init_sidequest = __esm({
25752
26099
  "src/router/sidequest.ts"() {
25753
26100
  "use strict";
26101
+ init_typing_manager();
25754
26102
  init_agent();
25755
26103
  init_log();
25756
26104
  init_store5();
@@ -26192,19 +26540,27 @@ async function handleEvolveCallback(chatId, data, channel, messageId) {
26192
26540
  break;
26193
26541
  }
26194
26542
  case "apply": {
26195
- const { applyInsight: applyInsight2 } = await Promise.resolve().then(() => (init_apply(), apply_exports));
26196
- const { advanceReviewSession: advanceReviewSession2 } = await Promise.resolve().then(() => (init_store4(), store_exports4));
26197
- const result = await applyInsight2(parseInt(idStr, 10));
26198
- advanceReviewSession2(getDb(), chatId, parseInt(idStr, 10), "applied");
26199
- await sendOrEditKeyboard(
26200
- chatId,
26201
- channel,
26202
- messageId,
26203
- `\u2705 ${result.message}`,
26204
- []
26205
- );
26206
- await new Promise((r) => setTimeout(r, 800));
26207
- await sendCurrentProposalInPlace(chatId, channel, messageId);
26543
+ const id = parseInt(idStr, 10);
26544
+ if (processingInsights.has(id)) break;
26545
+ processingInsights.add(id);
26546
+ await sendOrEditKeyboard(chatId, channel, messageId, "\u23F3 Applying...", []);
26547
+ try {
26548
+ const { applyInsight: applyInsight2 } = await Promise.resolve().then(() => (init_apply(), apply_exports));
26549
+ const { advanceReviewSession: advanceReviewSession2 } = await Promise.resolve().then(() => (init_store4(), store_exports4));
26550
+ const result = await applyInsight2(id);
26551
+ advanceReviewSession2(getDb(), chatId, id, "applied");
26552
+ await sendOrEditKeyboard(
26553
+ chatId,
26554
+ channel,
26555
+ messageId,
26556
+ `\u2705 ${result.message}`,
26557
+ []
26558
+ );
26559
+ await new Promise((r) => setTimeout(r, 800));
26560
+ await sendCurrentProposalInPlace(chatId, channel, messageId);
26561
+ } finally {
26562
+ processingInsights.delete(id);
26563
+ }
26208
26564
  break;
26209
26565
  }
26210
26566
  case "skip": {
@@ -26214,10 +26570,17 @@ async function handleEvolveCallback(chatId, data, channel, messageId) {
26214
26570
  break;
26215
26571
  }
26216
26572
  case "reject": {
26217
- const { updateInsightStatus: updateInsightStatus2, advanceReviewSession: advanceReviewSession2 } = await Promise.resolve().then(() => (init_store4(), store_exports4));
26218
- updateInsightStatus2(getDb(), parseInt(idStr, 10), "rejected");
26219
- advanceReviewSession2(getDb(), chatId, parseInt(idStr, 10), "rejected");
26220
- await sendCurrentProposalInPlace(chatId, channel, messageId);
26573
+ const rejId = parseInt(idStr, 10);
26574
+ if (processingInsights.has(rejId)) break;
26575
+ processingInsights.add(rejId);
26576
+ try {
26577
+ const { updateInsightStatus: updateInsightStatus2, advanceReviewSession: advanceReviewSession2 } = await Promise.resolve().then(() => (init_store4(), store_exports4));
26578
+ updateInsightStatus2(getDb(), rejId, "rejected");
26579
+ advanceReviewSession2(getDb(), chatId, rejId, "rejected");
26580
+ await sendCurrentProposalInPlace(chatId, channel, messageId);
26581
+ } finally {
26582
+ processingInsights.delete(rejId);
26583
+ }
26221
26584
  break;
26222
26585
  }
26223
26586
  case "discuss": {
@@ -26464,11 +26827,13 @@ async function handleReflectCallback(chatId, data, channel, messageId) {
26464
26827
  );
26465
26828
  }
26466
26829
  }
26830
+ var processingInsights;
26467
26831
  var init_evolve2 = __esm({
26468
26832
  "src/router/evolve.ts"() {
26469
26833
  "use strict";
26470
26834
  init_store5();
26471
26835
  init_helpers();
26836
+ processingInsights = /* @__PURE__ */ new Set();
26472
26837
  }
26473
26838
  });
26474
26839
 
@@ -28637,12 +29002,12 @@ async function handleStopCommand(chatId, commandArgs, msg, channel) {
28637
29002
  await channel.sendText(
28638
29003
  chatId,
28639
29004
  stopped ? "Stopping current task..." : "Nothing is running.",
28640
- { parseMode: "plain", priority: true }
29005
+ { parseMode: "plain", priority: 0 /* P0_CRITICAL */ }
28641
29006
  );
28642
29007
  if (stopped && typeof channel.sendKeyboard === "function") {
28643
29008
  await channel.sendKeyboard(chatId, "", [
28644
29009
  [{ label: "\u{1F195} New Chat", data: "menu:newchat" }]
28645
- ], { priority: true });
29010
+ ], { priority: 0 /* P0_CRITICAL */ });
28646
29011
  }
28647
29012
  }
28648
29013
  async function handleVoiceCommand(chatId, commandArgs, msg, channel) {
@@ -29009,7 +29374,7 @@ async function handleClearCommand(chatId, _commandArgs, _msg, channel) {
29009
29374
  clearChatPaidSlots(chatId);
29010
29375
  setSessionStartedAt(chatId);
29011
29376
  logActivity(getDb(), { chatId, source: "telegram", eventType: "config_changed", summary: "Session cleared (no summary)", detail: { field: "session", action: "clear" } });
29012
- await channel.sendText(chatId, "\u{1F9FD} Session cleared. No summary saved.", { parseMode: "plain", priority: true });
29377
+ await channel.sendText(chatId, "\u{1F9FD} Session cleared. No summary saved.", { parseMode: "plain", priority: 0 /* P0_CRITICAL */ });
29013
29378
  }
29014
29379
  async function handleSummarizeCommand(chatId, commandArgs, msg, channel) {
29015
29380
  if (commandArgs?.toLowerCase() === "all") {
@@ -29192,9 +29557,9 @@ async function handleStatusCommand(chatId, commandArgs, msg, channel) {
29192
29557
  { label: "Change Mode", data: "menu:permissions" },
29193
29558
  { label: "Change Style", data: "menu:style" }
29194
29559
  ]
29195
- ], { priority: true });
29560
+ ], { priority: 0 /* P0_CRITICAL */ });
29196
29561
  } else {
29197
- await channel.sendText(chatId, lines.join("\n"), { parseMode: "plain", priority: true });
29562
+ await channel.sendText(chatId, lines.join("\n"), { parseMode: "plain", priority: 0 /* P0_CRITICAL */ });
29198
29563
  }
29199
29564
  }
29200
29565
  async function handleBackendCommand2(chatId, commandArgs, msg, channel) {
@@ -30202,6 +30567,7 @@ async function handleCouncilCommand(chatId, commandArgs, msg, channel) {
30202
30567
  var init_command_handlers = __esm({
30203
30568
  "src/router/command-handlers.ts"() {
30204
30569
  "use strict";
30570
+ init_telegram_throttle();
30205
30571
  init_format();
30206
30572
  init_log();
30207
30573
  init_format_time();
@@ -33194,7 +33560,9 @@ function withThread(channel, threadId) {
33194
33560
  // These operate on existing messages — no threadId needed
33195
33561
  editText: channel.editText?.bind(channel),
33196
33562
  editKeyboard: channel.editKeyboard?.bind(channel),
33197
- reactToMessage: channel.reactToMessage?.bind(channel)
33563
+ reactToMessage: channel.reactToMessage?.bind(channel),
33564
+ isDraftCapable: channel.isDraftCapable?.bind(channel),
33565
+ sendMessageDraft: channel.sendMessageDraft?.bind(channel)
33198
33566
  };
33199
33567
  }
33200
33568
  var init_thread_wrapper = __esm({
@@ -34116,18 +34484,7 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34116
34484
  isSideQuest: hasSqPrefix
34117
34485
  })) {
34118
34486
  const planDirective = buildPlanningDirective();
34119
- let typingActive2 = true;
34120
- const typingLoop2 = async () => {
34121
- while (typingActive2) {
34122
- try {
34123
- await channel.sendTyping?.(chatId);
34124
- } catch {
34125
- }
34126
- await new Promise((r) => setTimeout(r, 4e3));
34127
- }
34128
- };
34129
- typingLoop2().catch(() => {
34130
- });
34487
+ getTypingManager().acquire(chatId, channel);
34131
34488
  try {
34132
34489
  const planResponse = await askAgent(chatId, cleanText || text, {
34133
34490
  cwd: settings.getCwd(),
@@ -34137,7 +34494,7 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34137
34494
  agentMode: effectiveAgentMode,
34138
34495
  planningDirective: planDirective
34139
34496
  });
34140
- typingActive2 = false;
34497
+ getTypingManager().release(chatId);
34141
34498
  if (planResponse.text) {
34142
34499
  let planText = planResponse.text.replace(/\[REACT:.+?\]/g, "").replace(/\[SEND_FILE:.+?\]/g, "").replace(/\[GENERATE_IMAGE:.+?\]/g, "").replace(/\[HISTORY_SEARCH:[^\]]+\]/g, "").trim();
34143
34500
  const PLAN_DISPLAY_LIMIT = 3500;
@@ -34162,23 +34519,12 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34162
34519
  await channel.sendText(chatId, "(No plan generated \u2014 proceeding normally)", { parseMode: "plain" });
34163
34520
  }
34164
34521
  } catch (err) {
34165
- typingActive2 = false;
34522
+ getTypingManager().release(chatId);
34166
34523
  await channel.sendText(chatId, `\u26A0\uFE0F Planning error: ${err.message}`, { parseMode: "plain" });
34167
34524
  }
34168
34525
  return;
34169
34526
  }
34170
- let typingActive = true;
34171
- const typingLoop = async () => {
34172
- while (typingActive) {
34173
- try {
34174
- await channel.sendTyping?.(chatId);
34175
- } catch {
34176
- }
34177
- await new Promise((r) => setTimeout(r, 4e3));
34178
- }
34179
- };
34180
- typingLoop().catch(() => {
34181
- });
34527
+ getTypingManager().acquire(chatId, channel);
34182
34528
  try {
34183
34529
  const tMode = settings.getMode();
34184
34530
  const tVerbose = settings.getVerboseLevel();
@@ -34222,6 +34568,28 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34222
34568
  }
34223
34569
  };
34224
34570
  }
34571
+ const DRAFT_FLUSH_INTERVAL_MS = 500;
34572
+ const useDraftStreaming = channel.sendMessageDraft && channel.isDraftCapable?.(chatId);
34573
+ const draftState = useDraftStreaming ? { accumulated: "", draftId: Date.now() & 2147483647, dirty: false, flushTimer: null } : null;
34574
+ let onStreamCb;
34575
+ if (draftState) {
34576
+ draftState.flushTimer = setInterval(() => {
34577
+ if (draftState.dirty) {
34578
+ draftState.dirty = false;
34579
+ channel.sendMessageDraft(chatId, draftState.draftId, draftState.accumulated);
34580
+ }
34581
+ }, DRAFT_FLUSH_INTERVAL_MS);
34582
+ onStreamCb = (chunk) => {
34583
+ draftState.accumulated += chunk;
34584
+ draftState.dirty = true;
34585
+ };
34586
+ }
34587
+ const stopDraftTimer2 = () => {
34588
+ if (draftState?.flushTimer) {
34589
+ clearInterval(draftState.flushTimer);
34590
+ draftState.flushTimer = null;
34591
+ }
34592
+ };
34225
34593
  let toolUseCount = 0;
34226
34594
  const sigT0 = Date.now();
34227
34595
  const response = await askAgent(chatId, cleanText || text, {
@@ -34234,6 +34602,7 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34234
34602
  agentMode: effectiveAgentMode,
34235
34603
  ...effectiveThinking ? { thinkingLevel: effectiveThinking } : {},
34236
34604
  chatContext: { chatTitle: msg.chatTitle, threadId: msg.threadId },
34605
+ onStream: onStreamCb,
34237
34606
  onThinking: liveStatus || sessionLog ? (chunk) => {
34238
34607
  if (liveStatus) liveStatus.addThinking(chunk);
34239
34608
  if (sessionLog) sessionLog.logThinking(chunk);
@@ -34271,6 +34640,7 @@ You're still in discussion mode \u2014 try again or click a button to exit.`, {
34271
34640
  });
34272
34641
  }
34273
34642
  });
34643
+ stopDraftTimer2();
34274
34644
  const elapsedMs = Date.now() - sigT0;
34275
34645
  const elapsedSec = (elapsedMs / 1e3).toFixed(1);
34276
34646
  if (liveStatus && response.thinkingText?.trim()) {
@@ -34436,7 +34806,8 @@ Approve paid usage for this session?`,
34436
34806
  const userMsg = diagnoseAgentError(errMsg, chatId);
34437
34807
  await channel.sendText(chatId, userMsg, { parseMode: "plain" });
34438
34808
  } finally {
34439
- typingActive = false;
34809
+ stopDraftTimer();
34810
+ getTypingManager().release(chatId);
34440
34811
  const pending = pendingInterrupts.get(chatId);
34441
34812
  if (pending) {
34442
34813
  pendingInterrupts.delete(chatId);
@@ -34471,6 +34842,7 @@ var init_router2 = __esm({
34471
34842
  init_gate();
34472
34843
  init_helpers();
34473
34844
  init_response();
34845
+ init_typing_manager();
34474
34846
  init_shell();
34475
34847
  init_ui();
34476
34848
  init_api_models();
@@ -35765,6 +36137,11 @@ function isFastPathMessage(msg) {
35765
36137
  function sanitizeForTelegram(text) {
35766
36138
  return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFFD\uFFFE\uFFFF]/g, "");
35767
36139
  }
36140
+ function isDraftCapable(chatId) {
36141
+ if (isSyntheticChatId(chatId)) return false;
36142
+ const numId = numericChatId(chatId);
36143
+ return !isNaN(numId) && numId > 0;
36144
+ }
35768
36145
  function numericChatId(chatId) {
35769
36146
  if (chatId.startsWith("sq:") || chatId.startsWith("cron:")) {
35770
36147
  throw new Error(`Synthetic chatId "${chatId}" passed to Telegram API`);
@@ -35780,6 +36157,7 @@ var init_telegram2 = __esm({
35780
36157
  init_log();
35781
36158
  init_health3();
35782
36159
  init_store5();
36160
+ init_agent();
35783
36161
  init_telegram_throttle();
35784
36162
  FAST_PATH_COMMANDS = /* @__PURE__ */ new Set(["stop", "status", "new", "newchat", "clear"]);
35785
36163
  TelegramChannel = class _TelegramChannel {
@@ -35798,18 +36176,23 @@ var init_telegram2 = __esm({
35798
36176
  mediaGroupBuffer = /* @__PURE__ */ new Map();
35799
36177
  static MEDIA_GROUP_DEBOUNCE_MS = 500;
35800
36178
  // ── Polling health tracking ─────────────────────────────────────────
35801
- /** Timestamp of last update received from Telegram (message, callback, reaction) */
35802
- lastUpdateAt = 0;
36179
+ /** Timestamp of last successful API keepalive ping (updated every 2 minutes) */
36180
+ lastPollingCheckAt = 0;
35803
36181
  /** True while polling is expected to be active (between start() and stop()) */
35804
36182
  pollingExpected = false;
35805
36183
  /** Watchdog interval that detects silent polling death */
35806
36184
  pollingWatchdog = null;
35807
- /** Max time without any update before we consider polling dead (ms) */
35808
- static POLLING_SILENCE_THRESHOLD_MS = 5 * 60 * 1e3;
35809
- // 5 minutes
36185
+ /** Keepalive interval: pings bot.api.getMe() to confirm API connection is alive */
36186
+ keepaliveInterval = null;
36187
+ /** Max time without a successful keepalive ping before we consider polling dead (ms) */
36188
+ static POLLING_SILENCE_THRESHOLD_MS = 10 * 60 * 1e3;
36189
+ // 10 minutes
35810
36190
  /** How often the watchdog checks for polling health (ms) */
35811
36191
  static POLLING_WATCHDOG_INTERVAL_MS = 60 * 1e3;
35812
36192
  // 60 seconds
36193
+ /** How often to ping bot.api.getMe() as a keepalive (ms) */
36194
+ static POLLING_KEEPALIVE_INTERVAL_MS = 2 * 60 * 1e3;
36195
+ // 2 minutes
35813
36196
  constructor() {
35814
36197
  const token = process.env.TELEGRAM_BOT_TOKEN;
35815
36198
  if (!token) {
@@ -35927,7 +36310,6 @@ var init_telegram2 = __esm({
35927
36310
  { command: "council", description: "Multi-model debate (select models, anonymous rounds)" }
35928
36311
  ]);
35929
36312
  this.bot.on("message", async (ctx) => {
35930
- this.lastUpdateAt = Date.now();
35931
36313
  const chatId = ctx.chat.id.toString();
35932
36314
  const senderId = ctx.from?.id?.toString() ?? "";
35933
36315
  const authorized = this.isAuthorized(chatId) || this.isAuthorized(senderId);
@@ -35968,7 +36350,6 @@ var init_telegram2 = __esm({
35968
36350
  });
35969
36351
  });
35970
36352
  this.bot.on("callback_query:data", (ctx) => {
35971
- this.lastUpdateAt = Date.now();
35972
36353
  const userId = ctx.from.id.toString();
35973
36354
  const chatId = ctx.callbackQuery.message?.chat?.id?.toString() ?? userId;
35974
36355
  log(`[telegram] Callback from user ${userId} in chat ${chatId}: ${ctx.callbackQuery.data}`);
@@ -35996,7 +36377,6 @@ var init_telegram2 = __esm({
35996
36377
  });
35997
36378
  });
35998
36379
  this.bot.on("message_reaction", async (ctx) => {
35999
- this.lastUpdateAt = Date.now();
36000
36380
  const chatId = String(ctx.chat.id);
36001
36381
  const messageId = ctx.messageReaction.message_id;
36002
36382
  if (!this.agentMessageIds.has(messageId)) return;
@@ -36013,7 +36393,6 @@ var init_telegram2 = __esm({
36013
36393
  }
36014
36394
  });
36015
36395
  this.bot.on("inline_query", (ctx) => {
36016
- this.lastUpdateAt = Date.now();
36017
36396
  if (!this.isAuthorized(ctx.from.id.toString())) return;
36018
36397
  this.handleInlineQuery(ctx).catch((err) => {
36019
36398
  error("[telegram] Inline query error:", err);
@@ -36028,7 +36407,16 @@ var init_telegram2 = __esm({
36028
36407
  }
36029
36408
  });
36030
36409
  this.pollingExpected = true;
36031
- this.lastUpdateAt = Date.now();
36410
+ this.lastPollingCheckAt = Date.now();
36411
+ this.keepaliveInterval = setInterval(async () => {
36412
+ if (!this.pollingExpected) return;
36413
+ try {
36414
+ await this.bot.api.getMe();
36415
+ this.lastPollingCheckAt = Date.now();
36416
+ } catch (err) {
36417
+ error("[telegram] Keepalive ping failed:", err);
36418
+ }
36419
+ }, _TelegramChannel.POLLING_KEEPALIVE_INTERVAL_MS);
36032
36420
  const pollingPromise = this.bot.start({
36033
36421
  allowed_updates: [...API_CONSTANTS.ALL_UPDATE_TYPES],
36034
36422
  onStart: () => log("[telegram] Polling for messages...")
@@ -36051,13 +36439,13 @@ var init_telegram2 = __esm({
36051
36439
  );
36052
36440
  this.pollingWatchdog = setInterval(() => {
36053
36441
  if (!this.pollingExpected) return;
36054
- const silenceMs = Date.now() - this.lastUpdateAt;
36442
+ const silenceMs = Date.now() - this.lastPollingCheckAt;
36055
36443
  if (silenceMs > _TelegramChannel.POLLING_SILENCE_THRESHOLD_MS) {
36056
36444
  log(
36057
- `[telegram] No updates received for ${Math.round(silenceMs / 1e3)}s \u2014 triggering reconnect`
36445
+ `[telegram] No polling confirmation for ${Math.round(silenceMs / 1e3)}s \u2014 triggering reconnect`
36058
36446
  );
36059
- markChannelDown("telegram", `No updates for ${Math.round(silenceMs / 1e3)}s`);
36060
- this.lastUpdateAt = Date.now();
36447
+ markChannelDown("telegram", `No polling confirmation for ${Math.round(silenceMs / 1e3)}s`);
36448
+ this.lastPollingCheckAt = Date.now();
36061
36449
  }
36062
36450
  }, _TelegramChannel.POLLING_WATCHDOG_INTERVAL_MS);
36063
36451
  }
@@ -36067,6 +36455,10 @@ var init_telegram2 = __esm({
36067
36455
  clearInterval(this.pollingWatchdog);
36068
36456
  this.pollingWatchdog = null;
36069
36457
  }
36458
+ if (this.keepaliveInterval) {
36459
+ clearInterval(this.keepaliveInterval);
36460
+ this.keepaliveInterval = null;
36461
+ }
36070
36462
  try {
36071
36463
  await this.bot.stop();
36072
36464
  } catch {
@@ -36180,15 +36572,18 @@ var init_telegram2 = __esm({
36180
36572
  return void 0;
36181
36573
  }
36182
36574
  }
36183
- async editText(chatId, messageId, text, parseMode) {
36575
+ async editText(chatId, messageId, text, parseMode, priority) {
36184
36576
  const formatted = sanitizeForTelegram(parseMode === "html" ? text : formatForTelegram(text));
36577
+ const isCritical = priority === true || priority === 0 /* P0_CRITICAL */;
36578
+ const label2 = isCritical ? "finalizeStatus" : "editText:html";
36185
36579
  try {
36186
36580
  await this.throttle.send(
36187
36581
  chatId,
36188
- "editText:html",
36582
+ label2,
36189
36583
  () => this.bot.api.editMessageText(numericChatId(chatId), parseInt(messageId), formatted, {
36190
36584
  parse_mode: "HTML"
36191
- })
36585
+ }),
36586
+ priority
36192
36587
  );
36193
36588
  return true;
36194
36589
  } catch (err) {
@@ -36201,12 +36596,13 @@ var init_telegram2 = __esm({
36201
36596
  try {
36202
36597
  await this.throttle.send(
36203
36598
  chatId,
36204
- "editText:fallback",
36599
+ priority ? "finalizeStatus:fallback" : "editText:fallback",
36205
36600
  () => this.bot.api.editMessageText(
36206
36601
  numericChatId(chatId),
36207
36602
  parseInt(messageId),
36208
36603
  formatted.replace(/<[^>]+>/g, "")
36209
- )
36604
+ ),
36605
+ priority
36210
36606
  );
36211
36607
  return true;
36212
36608
  } catch (err2) {
@@ -36352,6 +36748,44 @@ var init_telegram2 = __esm({
36352
36748
  log(`[telegram] reactToMessage failed (chat=${chatId} msg=${messageId}): ${err}`);
36353
36749
  }
36354
36750
  }
36751
+ /**
36752
+ * Check whether a chat supports native draft streaming.
36753
+ * sendMessageDraft only works in private/DM chats (positive chat IDs).
36754
+ */
36755
+ isDraftCapable(chatId) {
36756
+ return isDraftCapable(chatId);
36757
+ }
36758
+ /**
36759
+ * Send a streaming draft update to a DM chat using Telegram's native
36760
+ * sendMessageDraft API (Bot API 9.3+). The draft shows an animated
36761
+ * typing bubble with the text content, replacing editMessageText for
36762
+ * DM streaming. Subsequent calls with the same draft_id produce smooth
36763
+ * animated transitions.
36764
+ *
36765
+ * Draft updates are cosmetic — if one gets dropped, the next update
36766
+ * will contain the full accumulated text. Uses tryBestEffort so drafts
36767
+ * never block critical sends.
36768
+ *
36769
+ * @param chatId - Private chat ID (must be positive / draft-capable)
36770
+ * @param draftId - Non-zero integer, consistent per response for smooth animation
36771
+ * @param text - Plain text content (no parse_mode during streaming)
36772
+ */
36773
+ async sendMessageDraft(chatId, draftId, text) {
36774
+ if (!this.isDraftCapable(chatId)) {
36775
+ log(`[telegram] sendMessageDraft skipped \u2014 chat ${chatId} is not draft-capable`);
36776
+ return;
36777
+ }
36778
+ try {
36779
+ await this.throttle.tryBestEffort(
36780
+ chatId,
36781
+ "draft",
36782
+ () => this.bot.api.sendMessageDraft(numericChatId(chatId), draftId, sanitizeForTelegram(text))
36783
+ );
36784
+ log(`[telegram] sendMessageDraft sent (chat=${chatId}, draftId=${draftId}, len=${text.length})`);
36785
+ } catch (err) {
36786
+ log(`[telegram] sendMessageDraft failed (chat=${chatId}): ${err}`);
36787
+ }
36788
+ }
36355
36789
  /** Get the underlying Grammy Bot instance (for scheduler, etc.) */
36356
36790
  getBot() {
36357
36791
  return this.bot;
@@ -37571,6 +38005,16 @@ ${lines.join("\n")}`;
37571
38005
  } catch {
37572
38006
  }
37573
38007
  ;
38008
+ try {
38009
+ const { getEditCoordinator: getEditCoordinator2 } = await Promise.resolve().then(() => (init_edit_coordinator(), edit_coordinator_exports));
38010
+ getEditCoordinator2().shutdown();
38011
+ } catch {
38012
+ }
38013
+ try {
38014
+ const { getTypingManager: getTypingManager2 } = await Promise.resolve().then(() => (init_typing_manager(), typing_manager_exports));
38015
+ getTypingManager2().shutdown();
38016
+ } catch {
38017
+ }
37574
38018
  shutdownOrchestrator();
37575
38019
  shutdownScheduler();
37576
38020
  flushMemoryHalfLifeUpdates();
@@ -38130,6 +38574,7 @@ async function statusCommand(globalOpts, localOpts) {
38130
38574
  const dbStat = existsSync35(DB_PATH) ? statSync10(DB_PATH) : null;
38131
38575
  let daemonRunning = false;
38132
38576
  let daemonInfo = {};
38577
+ let throttleData;
38133
38578
  try {
38134
38579
  const { apiGet: apiGet2 } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
38135
38580
  const healthRes = await apiGet2("/api/health");
@@ -38137,6 +38582,9 @@ async function statusCommand(globalOpts, localOpts) {
38137
38582
  if (healthRes.ok && healthRes.data?.uptime) {
38138
38583
  daemonInfo.uptime_seconds = Math.floor(healthRes.data.uptime);
38139
38584
  }
38585
+ if (healthRes.ok && healthRes.data?.throttle) {
38586
+ throttleData = healthRes.data.throttle;
38587
+ }
38140
38588
  } catch {
38141
38589
  }
38142
38590
  const contextUsed = (usageRow?.last_input_tokens ?? 0) + (usageRow?.last_cache_read_tokens ?? 0);
@@ -38158,7 +38606,8 @@ async function statusCommand(globalOpts, localOpts) {
38158
38606
  output_tokens: usageRow?.output_tokens ?? 0,
38159
38607
  cache_read_tokens: usageRow?.cache_read_tokens ?? 0
38160
38608
  },
38161
- db: { path: DB_PATH, sizeBytes: dbStat?.size ?? 0, exists: !!dbStat }
38609
+ db: { path: DB_PATH, sizeBytes: dbStat?.size ?? 0, exists: !!dbStat },
38610
+ throttle: throttleData
38162
38611
  };
38163
38612
  try {
38164
38613
  const { OllamaStore } = await Promise.resolve().then(() => (init_ollama(), ollama_exports));
@@ -38190,6 +38639,14 @@ async function statusCommand(globalOpts, localOpts) {
38190
38639
  if (localOpts.deep) {
38191
38640
  lines.push(kvLine("Daemon", s.daemon.running ? success(`running${s.daemon.uptime_seconds ? ` (uptime ${formatUptime2(s.daemon.uptime_seconds)})` : ""}`) : error2("offline")));
38192
38641
  }
38642
+ if (s.throttle) {
38643
+ const t = s.throttle;
38644
+ const queueStr = t.queueDepth > 0 ? warning(`${t.queueDepth} queued`) : success("0 queued");
38645
+ const pauseStr = t.isPaused ? error2(`PAUSED (${t.pauseRemainingSec}s remaining)`) : "";
38646
+ const circuitStr = t.circuitState !== "closed" ? warning(t.circuitState.toUpperCase()) : "";
38647
+ const parts = [queueStr, pauseStr, circuitStr].filter(Boolean).join(", ");
38648
+ lines.push(kvLine("Throttle", parts));
38649
+ }
38193
38650
  lines.push(
38194
38651
  "",
38195
38652
  divider("Usage (this session)"),
@@ -38585,9 +39042,9 @@ async function sessionLogsList(opts) {
38585
39042
  `));
38586
39043
  console.log(` ${"Filename".padEnd(55)} ${"Size".padStart(8)} Chat ID`);
38587
39044
  console.log(` ${"\u2500".repeat(55)} ${"\u2500".repeat(8)} ${"\u2500".repeat(15)}`);
38588
- for (const log6 of logs) {
38589
- const size = log6.sizeBytes < 1024 ? `${log6.sizeBytes}B` : log6.sizeBytes < 1024 * 1024 ? `${(log6.sizeBytes / 1024).toFixed(1)}K` : `${(log6.sizeBytes / 1024 / 1024).toFixed(1)}M`;
38590
- console.log(` ${log6.filename.padEnd(55)} ${size.padStart(8)} ${log6.chatId}`);
39045
+ for (const log5 of logs) {
39046
+ const size = log5.sizeBytes < 1024 ? `${log5.sizeBytes}B` : log5.sizeBytes < 1024 * 1024 ? `${(log5.sizeBytes / 1024).toFixed(1)}K` : `${(log5.sizeBytes / 1024 / 1024).toFixed(1)}M`;
39047
+ console.log(` ${log5.filename.padEnd(55)} ${size.padStart(8)} ${log5.chatId}`);
38591
39048
  }
38592
39049
  console.log(muted(`
38593
39050
  Path: ${SESSION_LOGS_PATH}`));