polygram 0.12.0-rc.9 → 0.12.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.
- package/config.example.json +3 -1
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1322 -71
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +224 -78
package/config.example.json
CHANGED
|
@@ -72,7 +72,9 @@
|
|
|
72
72
|
"effort": "medium",
|
|
73
73
|
"cwd": "/Users/you/admin-agent",
|
|
74
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."
|
|
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
|
|
76
78
|
},
|
|
77
79
|
|
|
78
80
|
"-1000000000001": {
|
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
|
-
|
|
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 =
|
|
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
|
package/lib/error/classify.js
CHANGED
|
@@ -84,6 +84,12 @@ const PATTERNS = {
|
|
|
84
84
|
// to "unknown".
|
|
85
85
|
format: /invalid[_ ]request|invalid[_ ]json|malformed|bad request/i,
|
|
86
86
|
|
|
87
|
+
// CLI/channels backend: the claude process exited unexpectedly mid-life
|
|
88
|
+
// ("Claude Code process exited with code N" — e.g. 129/SIGHUP seen on
|
|
89
|
+
// shumabit). A respawn fixes it, so it's transient; the user gets a calm
|
|
90
|
+
// line, never the raw exit code. (known-issue #2.1)
|
|
91
|
+
processExit: /process exited with code|claude code process exited|exited with (signal|code)/i,
|
|
92
|
+
|
|
87
93
|
// Transient HTTP (5xx upstream Anthropic outage / overload). Only
|
|
88
94
|
// these get retried by pm. 521-524/529 are Cloudflare codes seen
|
|
89
95
|
// when Anthropic's edge is degraded.
|
|
@@ -104,6 +110,7 @@ const USER_MESSAGES = {
|
|
|
104
110
|
imageProcess: '🖼 One of the images in this conversation can\'t be re-processed by Claude — likely an older one in the history. Starting a fresh session for this chat.',
|
|
105
111
|
timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
|
|
106
112
|
format: '⚠️ Invalid request format. Try rephrasing or /new.',
|
|
113
|
+
processExit: '🔄 My Claude process stopped unexpectedly — resend in a moment and I\'ll restart it.',
|
|
107
114
|
// Used both for in-flight retry attempts AND for the post-retry-failed
|
|
108
115
|
// bubble-up message. Avoid promising "retrying once" since by the
|
|
109
116
|
// time the user reads it pm has already retried and given up.
|
|
@@ -195,12 +202,26 @@ const CODES = {
|
|
|
195
202
|
isTransient: false,
|
|
196
203
|
autoRecover: null,
|
|
197
204
|
},
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
205
|
+
// TMUX_SESSION_GONE: claude exited during spawn so the tmux session vanished
|
|
206
|
+
// before the channel went live (the startup-gate's captureWide hit "can't
|
|
207
|
+
// find pane"). Usual cause: an unresumable aged session whose "Resume from
|
|
208
|
+
// summary?" /compact exits code 0. The dispatcher poison-clears the session
|
|
209
|
+
// on this code, so a resend genuinely starts fresh and works — hence the
|
|
210
|
+
// calm "send it again" copy instead of the old raw "[startup-gate]…" leak.
|
|
211
|
+
TMUX_SESSION_GONE: {
|
|
212
|
+
kind: 'tmuxSessionGone',
|
|
213
|
+
userMessage: '🔄 That chat got stuck starting up, so I reset it. Send your message again and I\'ll pick it up fresh.',
|
|
214
|
+
isTransient: false,
|
|
215
|
+
autoRecover: null,
|
|
216
|
+
},
|
|
217
|
+
// TURN_TIMEOUT: per-turn time cap (idle default 10 min, configurable per
|
|
218
|
+
// chat/topic — UMI runs 60 min). Mirror of the tmux wall-clock ceiling —
|
|
219
|
+
// typically a runaway, not a wedge. Not transient (auto-retry would just
|
|
220
|
+
// runaway again). Copy must not name a number: the 2026-06-11 UMI false-⏱
|
|
221
|
+
// rendered "10-minute" under a 60-minute cap.
|
|
201
222
|
TURN_TIMEOUT: {
|
|
202
223
|
kind: 'turnTimeout',
|
|
203
|
-
userMessage: '⏱
|
|
224
|
+
userMessage: '⏱ This one ran past its time cap with no reply. Resend if the answer still matters.',
|
|
204
225
|
isTransient: false,
|
|
205
226
|
autoRecover: null,
|
|
206
227
|
},
|
|
@@ -251,7 +272,7 @@ function classify(err) {
|
|
|
251
272
|
return {
|
|
252
273
|
kind,
|
|
253
274
|
userMessage: USER_MESSAGES[kind],
|
|
254
|
-
isTransient: kind === 'transient5xx' || kind === 'rateLimit',
|
|
275
|
+
isTransient: kind === 'transient5xx' || kind === 'rateLimit' || kind === 'processExit',
|
|
255
276
|
autoRecover: AUTO_RECOVER[kind] ?? null,
|
|
256
277
|
};
|
|
257
278
|
}
|
|
@@ -271,13 +292,15 @@ function classify(err) {
|
|
|
271
292
|
if (sdkResultSubtype) return sdkResultSubtype;
|
|
272
293
|
}
|
|
273
294
|
|
|
274
|
-
// Fall-through:
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
295
|
+
// Fall-through: an error we couldn't classify. Do NOT echo the raw text
|
|
296
|
+
// to the user (known-issue #2.2) — it leaks internal identifiers (tmux
|
|
297
|
+
// names, gate vocabulary, pane dumps; the 2026-06-03 incident). The full
|
|
298
|
+
// raw string is preserved by callers in the events log
|
|
299
|
+
// (handler-error.detail_json) for forensics; the user gets a calm,
|
|
300
|
+
// generic, actionable line.
|
|
278
301
|
return {
|
|
279
302
|
kind: 'unknown',
|
|
280
|
-
userMessage:
|
|
303
|
+
userMessage: '⚠️ Something went wrong on my end — try resending. If it keeps happening, send /new.',
|
|
281
304
|
isTransient: false,
|
|
282
305
|
autoRecover: null,
|
|
283
306
|
};
|
|
@@ -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 };
|
package/lib/handlers/abort.js
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
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
|
|
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
|
|
|
@@ -96,20 +101,75 @@ function createHandleAbort({
|
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// drainQueue() rejects every queued pending with
|
|
103
|
-
// err.code='INTERRUPTED' so the abort-grace classifier
|
|
104
|
-
// suppresses error replies.
|
|
105
|
-
await pm.interrupt(sessionKey).catch((err) =>
|
|
106
|
-
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.
|
|
107
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
|
+
}
|
|
108
164
|
|
|
109
165
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
110
166
|
logEvent('abort-requested', {
|
|
111
167
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
112
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,
|
|
113
173
|
killed_background_shell: killedBackgroundShell,
|
|
114
174
|
// "Stop" incident forensics: the raw busy-probe signals at decision
|
|
115
175
|
// time. Lets us query, across real aborts, where the esc-hint /
|
|
@@ -127,35 +187,22 @@ function createHandleAbort({
|
|
|
127
187
|
trigger: cleanText.slice(0, 40),
|
|
128
188
|
});
|
|
129
189
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// background shell → "Stopped the background task"; neither →
|
|
147
|
-
// "Nothing to stop".
|
|
148
|
-
const reply = hadActive ? strs.stopped
|
|
149
|
-
: killedBackgroundShell ? strs.bgStopped
|
|
150
|
-
: strs.nothing;
|
|
151
|
-
try {
|
|
152
|
-
await tg(bot, 'sendMessage', {
|
|
153
|
-
chat_id: chatId, text: reply,
|
|
154
|
-
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
155
|
-
...(threadId && { message_thread_id: threadId }),
|
|
156
|
-
}, { source: 'abort-ack', botName });
|
|
157
|
-
} catch (err) {
|
|
158
|
-
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
|
+
}
|
|
159
206
|
}
|
|
160
207
|
return true;
|
|
161
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
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
19
|
const { toTelegramHtml } = require('../telegram/format');
|
|
20
|
+
const { getTopicConfig, getConfigWriteScope } = require('../session-key');
|
|
20
21
|
|
|
21
22
|
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
22
23
|
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
@@ -29,6 +30,7 @@ function createHandleConfigCallback({
|
|
|
29
30
|
getSessionKey,
|
|
30
31
|
formatConfigInfoText,
|
|
31
32
|
buildConfigKeyboard,
|
|
33
|
+
saveConfig = () => {},
|
|
32
34
|
botName,
|
|
33
35
|
logger = console,
|
|
34
36
|
} = {}) {
|
|
@@ -57,17 +59,29 @@ function createHandleConfigCallback({
|
|
|
57
59
|
return;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
// Write to the scope the card belongs to: a topic card targets THAT topic
|
|
63
|
+
// (so Music ≠ General), a chat-level card the chat root. Resolving the
|
|
64
|
+
// thread BEFORE the already-set check so "Already X" compares against the
|
|
65
|
+
// topic's effective value, not the chat root (2026-06-12 bug).
|
|
66
|
+
const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
67
|
+
const { scope: writeScope, threadId: writeThreadId } =
|
|
68
|
+
getConfigWriteScope(chatConfig, callbackThreadIdEarly);
|
|
69
|
+
const oldValue = writeScope[setting] != null ? writeScope[setting] : chatConfig[setting];
|
|
61
70
|
if (oldValue === value) {
|
|
62
71
|
await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
|
|
63
72
|
return;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
writeScope[setting] = value;
|
|
76
|
+
// Persist to config.json so the change survives a restart — without this,
|
|
77
|
+
// every /model tap was lost on the next deploy (2026-06-12). Best-effort:
|
|
78
|
+
// never let a disk hiccup swallow the in-memory change + live application.
|
|
79
|
+
try { saveConfig(); }
|
|
80
|
+
catch (err) { logger.error?.(`[${botName}] config-callback saveConfig failed: ${err.message}`); }
|
|
67
81
|
const cmdUserId = ctx.callbackQuery.from?.id || null;
|
|
68
82
|
const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
|
|
69
83
|
dbWrite(() => db.logConfigChange({
|
|
70
|
-
chat_id: chatId, thread_id:
|
|
84
|
+
chat_id: chatId, thread_id: writeThreadId, field: setting,
|
|
71
85
|
old_value: oldValue, new_value: value,
|
|
72
86
|
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
73
87
|
}), `log ${setting} change`);
|
|
@@ -76,7 +90,7 @@ function createHandleConfigCallback({
|
|
|
76
90
|
// via setModel / applyFlagSettings; chatConfig is already updated
|
|
77
91
|
// on disk above so a missing live session still picks up the new
|
|
78
92
|
// value on its next cold spawn.
|
|
79
|
-
const callbackThreadId =
|
|
93
|
+
const callbackThreadId = callbackThreadIdEarly;
|
|
80
94
|
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
81
95
|
let applied = false;
|
|
82
96
|
if (setting === 'effort') {
|
|
@@ -92,8 +106,13 @@ function createHandleConfigCallback({
|
|
|
92
106
|
// tapped into.
|
|
93
107
|
const existingRows = ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
|
|
94
108
|
const showRow = existingRows >= 2 ? 'all' : setting;
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
// Re-render with per-topic overrides resolved (topic > chat), so the agent
|
|
110
|
+
// line doesn't flip back to the chat-level default after a button tap —
|
|
111
|
+
// mirrors the /model command card (polygram.js). getTopicConfig returns {}
|
|
112
|
+
// for the chat-level card.
|
|
113
|
+
const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
|
|
114
|
+
const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg);
|
|
115
|
+
const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg);
|
|
97
116
|
try {
|
|
98
117
|
const { text: html, parseMode } = toTelegramHtml(newInfo);
|
|
99
118
|
await ctx.editMessageText(html, {
|