polygram 0.12.0-rc.4 → 0.12.0-rc.40

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 (50) hide show
  1. package/config.example.json +5 -1
  2. package/lib/attachments.js +46 -2
  3. package/lib/claude-bin.js +14 -1
  4. package/lib/compaction-warn.js +59 -0
  5. package/lib/context-usage.js +93 -0
  6. package/lib/db.js +1 -1
  7. package/lib/error/classify.js +18 -4
  8. package/lib/feedback/session-feedback.js +91 -0
  9. package/lib/handlers/abort.js +125 -41
  10. package/lib/handlers/autosteer.js +4 -0
  11. package/lib/handlers/config-callback.js +25 -6
  12. package/lib/handlers/config-ui.js +39 -10
  13. package/lib/handlers/dispatcher.js +83 -0
  14. package/lib/handlers/download.js +101 -58
  15. package/lib/handlers/drop-redeliver.js +69 -0
  16. package/lib/handlers/edit-correction.js +2 -0
  17. package/lib/handlers/edit-redelivery.js +136 -0
  18. package/lib/handlers/gate-inbound.js +188 -0
  19. package/lib/handlers/questions.js +289 -0
  20. package/lib/handlers/redeliver.js +122 -0
  21. package/lib/handlers/slash-commands.js +43 -30
  22. package/lib/history-preload.js +6 -0
  23. package/lib/history.js +7 -1
  24. package/lib/ipc/file-validator.js +8 -1
  25. package/lib/model-costs.js +4 -0
  26. package/lib/process/channels-bridge-protocol.js +22 -1
  27. package/lib/process/channels-bridge.mjs +128 -7
  28. package/lib/process/channels-tool-dispatcher.js +65 -8
  29. package/lib/process/cli-process.js +1397 -69
  30. package/lib/process/hook-event-tail.js +7 -0
  31. package/lib/process/hook-settings.js +7 -0
  32. package/lib/process/process.js +22 -0
  33. package/lib/process-guard.js +57 -1
  34. package/lib/process-manager.js +120 -35
  35. package/lib/questions/questions.js +187 -0
  36. package/lib/questions/store.js +105 -0
  37. package/lib/rewind/execute.js +89 -0
  38. package/lib/rewind/fork.js +112 -0
  39. package/lib/rewind/rewind.js +174 -0
  40. package/lib/sdk/callbacks.js +165 -167
  41. package/lib/session-key.js +20 -0
  42. package/lib/telegram/album-reactions.js +50 -0
  43. package/lib/telegram/api.js +9 -0
  44. package/lib/telegram/input-file.js +76 -0
  45. package/lib/telegram/parse.js +9 -2
  46. package/lib/telegram/typing.js +17 -2
  47. package/lib/tmux/startup-gate.js +44 -14
  48. package/migrations/012-pending-questions.sql +30 -0
  49. package/package.json +1 -1
  50. package/polygram.js +256 -80
@@ -4,6 +4,7 @@
4
4
  "bots": {
5
5
  "admin-bot": {
6
6
  "token": "REPLACE_WITH_BOT_TOKEN_FROM_BOTFATHER",
7
+ "_comment_apiRoot": "Optional. Point grammy at a self-hosted Telegram Bot API server (e.g. 'http://localhost:8082' from a local `telegram-bot-api --local` process) to raise file send/receive limits from cloud's 50MB-out / 20MB-in to 2GB both ways. Omit for cloud Telegram (default, unchanged). The server is a separate localhost-only companion daemon — see docs/0.12.0-file-send.md.",
7
8
  "allowConfigCommands": true,
8
9
  "_comment_adminChatId": "Required when allowConfigCommands is true for pairing commands (/pair-code, /pairings, /unpair) to work. These grant cross-chat trust and are gated to the admin chat only.",
9
10
  "adminChatId": "123456789",
@@ -70,7 +71,10 @@
70
71
  "model": "opus",
71
72
  "effort": "medium",
72
73
  "cwd": "/Users/you/admin-agent",
73
- "timeout": 600
74
+ "timeout": 600,
75
+ "_comment_maxFileBytes": "OPTIONAL per-chat (or per-topic; topic wins) file-size cap in BYTES. There is NO fixed default — the default is backend-derived: cloud Telegram = 50MB send / 20MB receive; with a local Bot API server (bot.apiRoot set) = 2GB both ways. This key only LOWERS that ceiling for this chat (Telegram rejects anything above the backend limit regardless); omit it to use the full backend default. To set one, add e.g. \"maxFileBytes\": 104857600 (=100MB) — only meaningful when apiRoot is set, since cloud already clamps to 50/20MB.",
76
+ "_comment_compactionWarnings": "OPTIONAL per-chat (or per-topic; topic wins). CLI/channels backend (pm:'cli') only. Default OFF. When ON, polygram warns the chat as Claude's context fills so you can /compact on your terms BEFORE an auto-compaction interrupts a turn (auto-compaction detaches the channels MCP bridge mid-turn — see docs/0.12.0-compaction-warnings.md). Two forms: `true` (enable at the 75% default threshold) or `{ \"enabled\": true, \"thresholdPct\": 80 }` (custom 1-99 threshold). Proactive: at the threshold it posts 'context ~N% full, run /compact or /new at a break'. Reactive backstop: when claude auto-compacts anyway it posts 'compacting now, resend if quiet'. Manual /compact never warns. Requires the bot to allow /compact + /new commands.",
77
+ "compactionWarnings": true
74
78
  },
75
79
 
76
80
  "-1000000000001": {
@@ -22,8 +22,48 @@
22
22
  * extension — the fallback only kicks in when MIME is unhelpful.
23
23
  */
24
24
 
25
- const MAX_FILE_BYTES = 10 * 1024 * 1024;
26
- const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
25
+ // Inbound (user bot) per-file cap. Telegram's cloud Bot API hard-caps
26
+ // bot file DOWNLOADS (getFile) at 20 MB, so 20 MB is the real ceiling on
27
+ // cloud — raised from 10 MB so users can send larger tracks/docs. With a
28
+ // self-hosted Bot API server (config.bot.apiRoot) the Telegram limit rises
29
+ // to 2 GB; resolveFileCaps() raises the default accordingly.
30
+ const MAX_FILE_BYTES = 20 * 1024 * 1024;
31
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
32
+
33
+ // ─── Backend-derived file-size caps (cloud vs local Bot API server) ──
34
+ //
35
+ // These are the HARD ceilings Telegram itself enforces — a per-chat
36
+ // override can lower them but never exceed them (Telegram rejects beyond
37
+ // regardless). NOT "adaptive": there is no intermediate tier. Cloud is a
38
+ // flat 20 in / 50 out; a local `telegram-bot-api --local` server is a flat
39
+ // 2 GB both ways.
40
+ const CLOUD_MAX_IN_BYTES = 20 * 1024 * 1024; // getFile download limit
41
+ const CLOUD_MAX_OUT_BYTES = 50 * 1024 * 1024; // sendDocument upload limit
42
+ const LOCAL_MAX_BYTES = 2000 * 1024 * 1024; // --local server, both ways
43
+
44
+ /**
45
+ * Resolve the effective per-file caps for a chat/topic.
46
+ *
47
+ * @param {object} opts
48
+ * @param {boolean} opts.localApi — true when config.bot.apiRoot is set
49
+ * (a local Bot API server is in use → 2 GB ceiling).
50
+ * @param {...number} opts.override — per-chat/topic maxFileBytes (bytes).
51
+ * Resolved by the caller from topic → chat → undefined; clamped to the
52
+ * backend ceiling.
53
+ * @returns {{ inBytes:number, outBytes:number, ceiling:number, localApi:boolean }}
54
+ */
55
+ function resolveFileCaps({ localApi = false, override = null } = {}) {
56
+ const ceiling = localApi ? LOCAL_MAX_BYTES : null;
57
+ const defIn = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_IN_BYTES;
58
+ const defOut = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_OUT_BYTES;
59
+ // A numeric override sets BOTH directions to the same value, clamped to
60
+ // the backend hard ceiling (cloud uses the per-direction default as the
61
+ // clamp so an override can't push past Telegram's own limit).
62
+ const ovr = (typeof override === 'number' && override > 0) ? override : null;
63
+ const inBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_IN_BYTES)) : defIn;
64
+ const outBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_OUT_BYTES)) : defOut;
65
+ return { inBytes, outBytes, ceiling: ceiling ?? CLOUD_MAX_OUT_BYTES, localApi };
66
+ }
27
67
  const MIME_ALLOW = [
28
68
  /^image\//, /^audio\//, /^video\//,
29
69
  /^application\/pdf$/, /^text\/plain$/,
@@ -109,8 +149,12 @@ function filterAttachments(attachments, opts = {}) {
109
149
 
110
150
  module.exports = {
111
151
  filterAttachments,
152
+ resolveFileCaps,
112
153
  MAX_FILE_BYTES,
113
154
  MAX_TOTAL_BYTES,
155
+ CLOUD_MAX_IN_BYTES,
156
+ CLOUD_MAX_OUT_BYTES,
157
+ LOCAL_MAX_BYTES,
114
158
  MIME_ALLOW,
115
159
  EXTENSION_ALLOW,
116
160
  FALLBACK_MIMES,
package/lib/claude-bin.js CHANGED
@@ -7,7 +7,20 @@ const fs = require('fs');
7
7
  // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
8
8
  // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
9
9
  // + spike scripts + polygram boot all import from here now.
10
- const CLAUDE_CLI_PINNED_VERSION = '2.1.142';
10
+ // 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed) chasing the
11
+ // dev-channels reliability issues (see docs/0.12.0-known-issues.md).
12
+ // 0.12.0-rc.38: bumped 2.1.158 → 2.1.173. Two reasons: (1) the ~32s startup
13
+ // deaths root-caused 2026-06-11 to a stale MCP connect-timeout racing the
14
+ // --resume session-id swap — a newer claude may fix the timer (2.1.173 also
15
+ // adds "Channel notifications re-registered after reconnect"); (2) keep the
16
+ // research-preview channels current. Per-bump re-validation done 2026-06-11:
17
+ // resume-dialog env vars survive (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES /
18
+ // _TOKEN_THRESHOLD), trust + dev-channels dialogs unchanged, "esc to
19
+ // interrupt" hint unchanged (template-rendered), but the channels READY
20
+ // banner text CHANGED → readySignal in cli-process.js matches both forms.
21
+ // Re-validate the channel flow on each bump via
22
+ // tests/e2e-channels-real-claude.test.js (run with E2E_REAL_CLAUDE=1).
23
+ const CLAUDE_CLI_PINNED_VERSION = '2.1.173';
11
24
 
12
25
  /**
13
26
  * Resolve + verify the pinned claude CLI binary.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * compaction-warn — per-chat config resolution + warn-once state for the
3
+ * compaction warning (0.12.0-rc.13).
4
+ *
5
+ * The warning is OFF by default. A chat (or topic) opts in via
6
+ * `compactionWarnings`:
7
+ * true → enabled, default threshold
8
+ * { enabled: true, thresholdPct: 80 } → enabled, custom threshold
9
+ * false / absent / object w/o enabled → off
10
+ *
11
+ * `thresholdPct` is the context-fill % at which the PROACTIVE warning fires
12
+ * (propose /compact before claude auto-compacts mid-turn). Default 75 — below
13
+ * claude's own auto-compact threshold so the user gets a window to act.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const DEFAULT_THRESHOLD_PCT = 75;
19
+
20
+ /**
21
+ * @param {object|undefined} cfg resolved topic/chat config (getTopicConfig result)
22
+ * @returns {{enabled: boolean, thresholdPct: number}}
23
+ */
24
+ function resolveCompactionWarnConfig(cfg) {
25
+ const raw = cfg?.compactionWarnings;
26
+ const off = { enabled: false, thresholdPct: DEFAULT_THRESHOLD_PCT };
27
+
28
+ if (raw === true) return { enabled: true, thresholdPct: DEFAULT_THRESHOLD_PCT };
29
+ if (raw && typeof raw === 'object' && raw.enabled === true) {
30
+ const t = Number(raw.thresholdPct);
31
+ const thresholdPct = (Number.isFinite(t) && t > 0 && t < 100) ? t : DEFAULT_THRESHOLD_PCT;
32
+ return { enabled: true, thresholdPct };
33
+ }
34
+ return off;
35
+ }
36
+
37
+ /**
38
+ * Per-session "have we already warned on this climb?" state. Warn ONCE per
39
+ * session until reset — without this the proactive warning would re-fire on
40
+ * every turn-end while the context stays high. Reset on a successful
41
+ * compaction (PostCompact → context dropped) or a fresh session so the next
42
+ * climb can warn again. Mirrors the autoResumeTracker shape.
43
+ */
44
+ function createCompactionWarnTracker() {
45
+ const warned = new Set();
46
+ return {
47
+ shouldWarn(sessionKey) { return !warned.has(sessionKey); },
48
+ markWarned(sessionKey) { warned.add(sessionKey); },
49
+ reset(sessionKey) { warned.delete(sessionKey); },
50
+ resetAll() { warned.clear(); },
51
+ _size() { return warned.size; },
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ resolveCompactionWarnConfig,
57
+ createCompactionWarnTracker,
58
+ DEFAULT_THRESHOLD_PCT,
59
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * context-usage — read live context occupancy from a Claude Code session
3
+ * transcript (JSONL).
4
+ *
5
+ * Used by the per-chat compaction warning (0.12.0-rc.13). polygram has no
6
+ * usage payload on the channels/CLI backend (hook events carry none — see
7
+ * the rc.13 spike), so the only source of "how full is the context" is the
8
+ * transcript itself. We read it ONCE per turn-end (Stop hook), not on a
9
+ * poll loop, so a single streamed pass is fine.
10
+ *
11
+ * What "occupancy" means: Claude's own context-% / auto-compact threshold is
12
+ * measured against what's fed INTO the model each turn —
13
+ * input_tokens + cache_read_input_tokens + cache_creation_input_tokens
14
+ * (cache_read dominates once the conversation is warm). output_tokens is the
15
+ * reply, not context, so it's excluded.
16
+ *
17
+ * We take the LAST main-thread (non-sidechain) assistant frame with a usage
18
+ * block. Subagents write to their own agent_transcript_path so sidechain
19
+ * frames don't normally appear here, but we skip them defensively: a format
20
+ * change that inlined a subagent's large usage would otherwise spike the
21
+ * parent's apparent context and trigger a false "you're full" warning.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const readline = require('node:readline');
28
+
29
+ // Standard Claude context window (sonnet/opus, non-beta). The warning is a
30
+ // heuristic ("you're getting full"), so an approximate denominator is fine;
31
+ // callers can pass a different window for 1M-beta sessions.
32
+ const DEFAULT_WINDOW_TOKENS = 200_000;
33
+
34
+ /**
35
+ * @param {string} transcriptPath
36
+ * @returns {Promise<{inputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, total:number} | null>}
37
+ * null when the path is falsy/unreadable or no usable usage frame exists.
38
+ */
39
+ async function readContextTokens(transcriptPath) {
40
+ if (!transcriptPath) return null;
41
+
42
+ let stream;
43
+ try {
44
+ stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
45
+ } catch {
46
+ return null;
47
+ }
48
+
49
+ return new Promise((resolve) => {
50
+ let last = null;
51
+ // Resolve only once — error and close can both fire.
52
+ let done = false;
53
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
54
+
55
+ stream.on('error', () => finish(null));
56
+
57
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
58
+ // readline forwards the input stream's 'error' (e.g. ENOENT on open) to
59
+ // the interface; without this handler that re-emit is unhandled and
60
+ // crashes the process even though we resolved null on the stream error.
61
+ rl.on('error', () => finish(null));
62
+ rl.on('line', (line) => {
63
+ if (!line) return;
64
+ let o;
65
+ try { o = JSON.parse(line); } catch { return; } // skip partial/non-JSON lines
66
+ if (!o || o.type !== 'assistant' || o.isSidechain === true) return;
67
+ const u = o.message?.usage;
68
+ if (!u) return;
69
+ const inputTokens = Number(u.input_tokens) || 0;
70
+ const cacheReadTokens = Number(u.cache_read_input_tokens) || 0;
71
+ const cacheCreationTokens = Number(u.cache_creation_input_tokens) || 0;
72
+ const total = inputTokens + cacheReadTokens + cacheCreationTokens;
73
+ if (total > 0) last = { inputTokens, cacheReadTokens, cacheCreationTokens, total };
74
+ });
75
+ rl.on('close', () => finish(last));
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Fraction (0..1) of the context window currently occupied. Clamps to 0 on
81
+ * non-positive / non-finite inputs so callers never see NaN/Infinity.
82
+ *
83
+ * @param {number} totalTokens
84
+ * @param {number} [windowTokens=DEFAULT_WINDOW_TOKENS]
85
+ * @returns {number}
86
+ */
87
+ function contextPct(totalTokens, windowTokens = DEFAULT_WINDOW_TOKENS) {
88
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return 0;
89
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0) return 0;
90
+ return totalTokens / windowTokens;
91
+ }
92
+
93
+ module.exports = { readContextTokens, contextPct, DEFAULT_WINDOW_TOKENS };
package/lib/db.js CHANGED
@@ -19,7 +19,7 @@ const Database = require('better-sqlite3');
19
19
  // SCHEMA_VERSION; the early-return on line ~42 then skipped the
20
20
  // migration loop on any DB already at user_version=8 → turn_metrics
21
21
  // table never created → INSERT prepare at startup crashed polygram.
22
- const SCHEMA_VERSION = 11;
22
+ const SCHEMA_VERSION = 12;
23
23
 
24
24
  // Sentinel `error` value for outbound rows whose API call may or may not
25
25
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -195,12 +195,26 @@ const CODES = {
195
195
  isTransient: false,
196
196
  autoRecover: null,
197
197
  },
198
- // TURN_TIMEOUT: 10-min wall-clock cap on a single channels turn. Mirror
199
- // of the tmux wall-clock ceiling typically a runaway, not a wedge.
200
- // Not transient (auto-retry would just runaway again).
198
+ // TMUX_SESSION_GONE: claude exited during spawn so the tmux session vanished
199
+ // before the channel went live (the startup-gate's captureWide hit "can't
200
+ // find pane"). Usual cause: an unresumable aged session whose "Resume from
201
+ // summary?" /compact exits code 0. The dispatcher poison-clears the session
202
+ // on this code, so a resend genuinely starts fresh and works — hence the
203
+ // calm "send it again" copy instead of the old raw "[startup-gate]…" leak.
204
+ TMUX_SESSION_GONE: {
205
+ kind: 'tmuxSessionGone',
206
+ userMessage: '🔄 That chat got stuck starting up, so I reset it. Send your message again and I\'ll pick it up fresh.',
207
+ isTransient: false,
208
+ autoRecover: null,
209
+ },
210
+ // TURN_TIMEOUT: per-turn time cap (idle default 10 min, configurable per
211
+ // chat/topic — UMI runs 60 min). Mirror of the tmux wall-clock ceiling —
212
+ // typically a runaway, not a wedge. Not transient (auto-retry would just
213
+ // runaway again). Copy must not name a number: the 2026-06-11 UMI false-⏱
214
+ // rendered "10-minute" under a 60-minute cap.
201
215
  TURN_TIMEOUT: {
202
216
  kind: 'turnTimeout',
203
- userMessage: '⏱ The turn ran past the 10-minute cap. Resend if the answer still matters.',
217
+ userMessage: '⏱ This one ran past its time cap with no reply. Resend if the answer still matters.',
204
218
  isTransient: false,
205
219
  autoRecover: null,
206
220
  },
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session-scoped feedback controller (0.13 D3,
5
+ * docs/0.13-channels-lifecycle-design.md §3 D3).
6
+ *
7
+ * The per-turn reactor/typing pair lives in handleMessage's closure and dies
8
+ * with the turn (ROOT C). D1 extended the turn to claude's real cycle end —
9
+ * which closed the dead-air class for PRIMARY turns — but cycles with NO
10
+ * pending turn still had zero feedback surface:
11
+ *
12
+ * - autonomous/wakeup cycles (ScheduleWakeup, fireUserMessage self-checks):
13
+ * minutes of work with nothing visible until text lands;
14
+ * - an injected follow-up picked up as its OWN next cycle: its message sat
15
+ * with no indicator while claude worked it.
16
+ *
17
+ * This controller owns those: a session-level typing loop for the cycle's
18
+ * duration, plus — when the InputLedger knows which message the cycle picked
19
+ * up — a 🤔 anchored to that message, cleared at cycle end. Inputs are the
20
+ * previously-unconsumed lifecycle edges: 'turn-start' (UPS) with no pending,
21
+ * and 'idle'/'close' as the end signals (wired via lib/sdk/callbacks.js).
22
+ *
23
+ * Per-turn feedback (reactor cascade, streamer, waiting-on-user typing pause)
24
+ * stays where it is — this controller deliberately covers only the
25
+ * no-pending gap; it never touches a session that has a head pending.
26
+ */
27
+
28
+ const { startTyping } = require('../telegram/typing');
29
+
30
+ function createSessionFeedback({
31
+ bot,
32
+ tg,
33
+ getChatIdFromKey,
34
+ getThreadIdFromKey,
35
+ botName,
36
+ typingIntervalMs = undefined, // override for tests; default = typing.js default
37
+ logEvent = () => {},
38
+ logger = console,
39
+ } = {}) {
40
+ const active = new Map(); // sessionKey → { stop, anchor: {chatId, msgId}|null }
41
+
42
+ function startAutonomousCycle(sessionKey, { anchorMsgId = null } = {}) {
43
+ if (active.has(sessionKey)) return;
44
+ const chatId = getChatIdFromKey(sessionKey);
45
+ if (!chatId || !bot) return;
46
+ const threadIdRaw = getThreadIdFromKey?.(sessionKey);
47
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
48
+
49
+ const stop = startTyping({
50
+ bot, chatId,
51
+ ...(Number.isInteger(threadId) ? { threadId } : {}),
52
+ ...(typingIntervalMs ? { intervalMs: typingIntervalMs } : {}),
53
+ logger: { error: (m) => logger.error?.(`[${botName}] autonomous-typing: ${m}`) },
54
+ });
55
+
56
+ let anchor = null;
57
+ if (anchorMsgId != null) {
58
+ anchor = { chatId, msgId: Number(anchorMsgId) };
59
+ tg(bot, 'setMessageReaction', {
60
+ chat_id: chatId, message_id: anchor.msgId,
61
+ reaction: [{ type: 'emoji', emoji: '🤔' }],
62
+ }, { source: 'autonomous-cycle-anchor', botName }).catch(() => {});
63
+ }
64
+
65
+ active.set(sessionKey, { stop, anchor });
66
+ logEvent('autonomous-cycle-visuals', {
67
+ chat_id: chatId, session_key: sessionKey, state: 'start',
68
+ anchor_msg_id: anchor?.msgId ?? null,
69
+ });
70
+ }
71
+
72
+ function endCycle(sessionKey) {
73
+ const entry = active.get(sessionKey);
74
+ if (!entry) return;
75
+ active.delete(sessionKey);
76
+ try { entry.stop(); } catch { /* best-effort */ }
77
+ if (entry.anchor && bot) {
78
+ tg(bot, 'setMessageReaction', {
79
+ chat_id: entry.anchor.chatId, message_id: entry.anchor.msgId, reaction: [],
80
+ }, { source: 'autonomous-cycle-anchor-clear', botName }).catch(() => {});
81
+ }
82
+ logEvent('autonomous-cycle-visuals', {
83
+ chat_id: entry.anchor?.chatId ?? getChatIdFromKey(sessionKey),
84
+ session_key: sessionKey, state: 'end',
85
+ });
86
+ }
87
+
88
+ return { startAutonomousCycle, endCycle };
89
+ }
90
+
91
+ module.exports = { createSessionFeedback };
@@ -8,14 +8,16 @@
8
8
  * 1. Mark the session aborted BEFORE the SDK interrupt fires —
9
9
  * pm-sdk's close handler races; if we marked after, the
10
10
  * generic error-reply could slip through.
11
- * 2. pm.interrupt() non-destructive cancel of the in-flight
12
- * turn (preserves Query for the next user message).
11
+ * 2. Tiered cancel (cheap by default, 2026-06-12): in-place
12
+ * interrupt keeps the proc warm (no --resume); kill only for
13
+ * ghosts / detached background shells / unverifiable state.
13
14
  * 3. pm.drainQueue() — rejects queued pendings with
14
15
  * err.code='INTERRUPTED' so the abort-grace classifier
15
16
  * suppresses error replies on the way out.
16
17
  * 4. Clear ✍ reactions on already-autosteered messages from
17
18
  * this turn (now dead context).
18
- * 5. Acknowledge in the language the user aborted in (en/ru).
19
+ * 5. Acknowledge with a 👍 reaction on the stop message when
20
+ * something was stopped; silence otherwise. Never text.
19
21
  *
20
22
  * Returns true when the message was handled as an abort, false
21
23
  * otherwise. Caller short-circuits on true.
@@ -33,6 +35,9 @@ function createHandleAbort({
33
35
  clearAutosteeredReactions,
34
36
  getSessionKey,
35
37
  botName,
38
+ // Cancel-cheap (spec Finding 5): delay before the second background-shell
39
+ // probe — catches a shell whose TUI mode-line hadn't rendered at probe #1.
40
+ dualProbeDelayMs = 1000,
36
41
  logger = console,
37
42
  } = {}) {
38
43
 
@@ -42,13 +47,37 @@ function createHandleAbort({
42
47
  const threadId = msg.message_thread_id?.toString();
43
48
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
44
49
  const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
45
- const hadActive = !!proc?.inFlight;
50
+ let hadActive = !!proc?.inFlight;
46
51
 
47
52
  // Mark BEFORE killing: the 'close' event fires almost immediately
48
53
  // after interrupt, and the surrounding handleMessage's catch
49
54
  // needs to see the flag to skip the generic error-reply.
50
55
  if (hadActive) markSessionAborted(sessionKey);
51
56
 
57
+ // "Stop" incident (shumorobot Music, 2026-05-31 13:08): on the
58
+ // CliProcess/channels backend a turn resolves on the quiet-window
59
+ // after claude's last reply tool call (inFlight → false), but claude
60
+ // can still be working (subagent, long Bash). Keying the ack on
61
+ // inFlight alone made "Stop" say "Nothing to stop" while a subagent
62
+ // download churned. probeBusyState() reads the TUI "esc to interrupt"
63
+ // hint — the truthful signal — so detection, the abort mark, and the
64
+ // ack all agree. The probe result is logged below (forensics) so the
65
+ // heuristic can be refined against real states later. Channels analog
66
+ // of the (deleted) tmux hasBackgroundShell branch; typeof-guarded so
67
+ // it's a no-op on backends without it.
68
+ let busyProbe = null;
69
+ if (!hadActive && proc && typeof proc.probeBusyState === 'function') {
70
+ try {
71
+ busyProbe = await proc.probeBusyState();
72
+ if (busyProbe?.busy) {
73
+ hadActive = true;
74
+ markSessionAborted(sessionKey);
75
+ }
76
+ } catch (err) {
77
+ logger.error?.(`[${botName}] busy-probe failed: ${err.message}`);
78
+ }
79
+ }
80
+
52
81
  // Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
53
82
  // looked at an in-flight TURN. But the agent can leave a DETACHED
54
83
  // background shell running (a `run_in_background:true` Bash) that
@@ -72,53 +101,108 @@ function createHandleAbort({
72
101
  }
73
102
  }
74
103
 
75
- // SDK abort: interrupt() + drainQueue(). interrupt() cancels
76
- // the in-flight turn at SDK level WITHOUT tearing down the
77
- // Query (cheap to reuse for the user's next message);
78
- // drainQueue() rejects every queued pending with
79
- // err.code='INTERRUPTED' so the abort-grace classifier
80
- // suppresses error replies.
81
- await pm.interrupt(sessionKey).catch((err) =>
82
- logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
104
+ // Reject queued pendings first (err.code='INTERRUPTED' the abort-grace
105
+ // classifier suppresses their error replies AND each turn's finally clears
106
+ // its reactor + typing), THEN stop the live work.
83
107
  pm.drainQueue(sessionKey, 'INTERRUPTED');
108
+ // Cancel-cheap tiered gate (docs/0.13-cancel-efficiency-and-delete-trigger-
109
+ // spec.md, locked 2026-06-12; supersedes the 2026-06-04 always-kill
110
+ // decision). kill→--resume is the resume-death-race path AND a full
111
+ // re-prefill on aged sessions, so the cli backend now interrupts IN PLACE
112
+ // (C-c; claude stays resident, next message reuses the warm proc — live-
113
+ // verified on 2.1.173: subagents die with the turn) and kills ONLY when
114
+ // an in-place interrupt genuinely can't reach the work:
115
+ // - a GHOST busy-state (no pending turn but still streaming — interrupt
116
+ // can't clear its feedback; the close-drain can),
117
+ // - detached run_in_background shells (they survive C-c — live-verified;
118
+ // the pane scrape false-negatives, so cross-check the bg-work watchdog
119
+ // and dual-probe, and FAIL TOWARD KILL when unverifiable).
120
+ let cancelMode = 'none';
121
+ let killReason = null;
122
+ if (hadActive && proc && proc.backend === 'cli') {
123
+ let mustKill = false;
124
+ if (!proc.inFlight) {
125
+ // hadActive came from the busy probe with no pending turn = ghost.
126
+ mustKill = true; killReason = 'ghost';
127
+ } else if (typeof proc.probeBusyState !== 'function') {
128
+ mustKill = true; killReason = 'no-probe';
129
+ } else {
130
+ try {
131
+ const p1 = busyProbe || await proc.probeBusyState();
132
+ let bg = !!p1?.backgroundShell;
133
+ if (!bg && typeof proc.hasActiveBackgroundWork === 'function'
134
+ && await proc.hasActiveBackgroundWork()) {
135
+ bg = true;
136
+ }
137
+ if (!bg) {
138
+ await new Promise((r) => setTimeout(r, dualProbeDelayMs));
139
+ const p2 = await proc.probeBusyState();
140
+ bg = !!p2?.backgroundShell;
141
+ }
142
+ if (bg) { mustKill = true; killReason = 'background-shell'; }
143
+ } catch (err) {
144
+ logger.error?.(`[${botName}] cancel bg-probe failed: ${err.message}`);
145
+ mustKill = true; killReason = 'probe-failed';
146
+ }
147
+ }
148
+ if (mustKill) {
149
+ cancelMode = 'kill';
150
+ await pm.kill(sessionKey, 'abort').catch((err) =>
151
+ logger.error?.(`[${botName}] abort kill failed: ${err.message}`));
152
+ } else {
153
+ cancelMode = 'interrupt';
154
+ await pm.interrupt(sessionKey).catch((err) =>
155
+ logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
156
+ }
157
+ } else {
158
+ // SDK (or nothing active): non-destructive interrupt cancels the in-flight
159
+ // Query turn WITHOUT tearing down the Query (cheap to reuse next message).
160
+ if (hadActive) cancelMode = 'interrupt';
161
+ await pm.interrupt(sessionKey).catch((err) =>
162
+ logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
163
+ }
84
164
 
85
165
  clearAutosteeredReactions(sessionKey).catch(() => {});
86
166
  logEvent('abort-requested', {
87
167
  chat_id: chatId, user_id: msg.from?.id || null,
88
168
  had_active: hadActive,
169
+ // Cancel-cheap soak signals: which tier fired, and why a kill was chosen
170
+ // (ghost / background-shell / no-probe / probe-failed / null).
171
+ cancel_mode: cancelMode,
172
+ kill_reason: killReason,
89
173
  killed_background_shell: killedBackgroundShell,
174
+ // "Stop" incident forensics: the raw busy-probe signals at decision
175
+ // time. Lets us query, across real aborts, where the esc-hint /
176
+ // inFlight / pending-turn signals agreed vs diverged and refine the
177
+ // heuristic later. null when no probe ran (turn was already inFlight,
178
+ // or the backend has no probeBusyState).
179
+ busy_probe: busyProbe ? {
180
+ busy: busyProbe.busy,
181
+ streaming: busyProbe.streaming,
182
+ in_flight: busyProbe.inFlight,
183
+ pending_turns: busyProbe.pendingTurns,
184
+ captured: busyProbe.captured,
185
+ pane_tail: busyProbe.paneTail,
186
+ } : null,
90
187
  trigger: cleanText.slice(0, 40),
91
188
  });
92
189
 
93
- // Reply in the same language the user aborted in. Cyrillic-
94
- // detection is crude but reliable for ru/en.
95
- const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
96
- const strs = {
97
- en: {
98
- stopped: 'Stopped.',
99
- bgStopped: 'Stopped the background task.',
100
- nothing: 'Nothing to stop.',
101
- },
102
- ru: {
103
- stopped: 'Остановлено.',
104
- bgStopped: 'Фоновая задача остановлена.',
105
- nothing: 'Нечего останавливать.',
106
- },
107
- }[lang];
108
- // Truthful ack: a stopped in-flight turn → "Stopped"; a stopped
109
- // background shell → "Stopped the background task"; neither →
110
- // "Nothing to stop".
111
- const reply = hadActive ? strs.stopped
112
- : killedBackgroundShell ? strs.bgStopped
113
- : strs.nothing;
114
- try {
115
- await tg(bot, 'sendMessage', {
116
- chat_id: chatId, text: reply,
117
- reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
118
- ...(threadId && { message_thread_id: threadId }),
119
- }, { source: 'abort-ack', botName });
120
- } catch (err) {
121
- logger.error?.(`[${botName}] abort-ack send failed: ${err.message}`);
190
+ // Ack (locked design 2026-06-12, Ivan): a 👍 reaction on the user's stop
191
+ // message when something was actually stopped — NEVER a text reply. The
192
+ // old "Stopped." text was eventually-true at best (the interrupt settles
193
+ // up to graceMs later) and chat noise at worst; the reaction is just
194
+ // "got it, stopping" and is language-neutral. Nothing-to-stop → complete
195
+ // silence (a 👍 there would lie).
196
+ if (hadActive || killedBackgroundShell) {
197
+ try {
198
+ await tg(bot, 'setMessageReaction', {
199
+ chat_id: chatId,
200
+ message_id: msg.message_id,
201
+ reaction: [{ type: 'emoji', emoji: '👍' }],
202
+ }, { source: 'abort-ack', botName });
203
+ } catch (err) {
204
+ logger.error?.(`[${botName}] abort-ack reaction failed: ${err.message}`);
205
+ }
122
206
  }
123
207
  return true;
124
208
  };
@@ -78,6 +78,7 @@ function createAutosteerHandlers({
78
78
  content: prompt,
79
79
  priority,
80
80
  msgId: msg.message_id,
81
+ source: 'autosteer', // 0.13 D2: ledger source — drop detection + redelivery eligibility
81
82
  });
82
83
  if (!ok) return { autosteered: false };
83
84
 
@@ -86,6 +87,9 @@ function createAutosteerHandlers({
86
87
  chat_id: chatId, msg_id: msg.message_id,
87
88
  text_len: prompt?.length ?? 0,
88
89
  priority,
90
+ // 0.13 P1: per-event backend. The 14d fold/drop investigation had to
91
+ // reconstruct the cli-vs-sdk split by joining chats — never again.
92
+ backend: typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null,
89
93
  });
90
94
  return { autosteered: true, priority };
91
95
  }