polygram 0.12.0-rc.37 → 0.12.0-rc.39
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/lib/claude-bin.js +14 -8
- package/lib/error/classify.js +6 -4
- package/lib/handlers/abort.js +77 -43
- package/lib/process/cli-process.js +180 -27
- package/lib/telegram/parse.js +9 -2
- package/lib/tmux/startup-gate.js +18 -4
- package/package.json +1 -1
package/lib/claude-bin.js
CHANGED
|
@@ -7,14 +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
|
-
// 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// newer claude
|
|
15
|
-
//
|
|
16
|
-
// bump
|
|
17
|
-
|
|
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';
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* Resolve + verify the pinned claude CLI binary.
|
package/lib/error/classify.js
CHANGED
|
@@ -207,12 +207,14 @@ const CODES = {
|
|
|
207
207
|
isTransient: false,
|
|
208
208
|
autoRecover: null,
|
|
209
209
|
},
|
|
210
|
-
// TURN_TIMEOUT:
|
|
211
|
-
// of the tmux wall-clock ceiling —
|
|
212
|
-
// Not transient (auto-retry would just
|
|
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.
|
|
213
215
|
TURN_TIMEOUT: {
|
|
214
216
|
kind: 'turnTimeout',
|
|
215
|
-
userMessage: '⏱
|
|
217
|
+
userMessage: '⏱ This one ran past its time cap with no reply. Resend if the answer still matters.',
|
|
216
218
|
isTransient: false,
|
|
217
219
|
autoRecover: null,
|
|
218
220
|
},
|
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
|
|
|
@@ -100,21 +105,59 @@ function createHandleAbort({
|
|
|
100
105
|
// classifier suppresses their error replies AND each turn's finally clears
|
|
101
106
|
// its reactor + typing), THEN stop the live work.
|
|
102
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;
|
|
103
122
|
if (hadActive && proc && proc.backend === 'cli') {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
157
|
} else {
|
|
116
158
|
// SDK (or nothing active): non-destructive interrupt cancels the in-flight
|
|
117
159
|
// Query turn WITHOUT tearing down the Query (cheap to reuse next message).
|
|
160
|
+
if (hadActive) cancelMode = 'interrupt';
|
|
118
161
|
await pm.interrupt(sessionKey).catch((err) =>
|
|
119
162
|
logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
|
|
120
163
|
}
|
|
@@ -123,6 +166,10 @@ function createHandleAbort({
|
|
|
123
166
|
logEvent('abort-requested', {
|
|
124
167
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
125
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,
|
|
126
173
|
killed_background_shell: killedBackgroundShell,
|
|
127
174
|
// "Stop" incident forensics: the raw busy-probe signals at decision
|
|
128
175
|
// time. Lets us query, across real aborts, where the esc-hint /
|
|
@@ -140,35 +187,22 @@ function createHandleAbort({
|
|
|
140
187
|
trigger: cleanText.slice(0, 40),
|
|
141
188
|
});
|
|
142
189
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// background shell → "Stopped the background task"; neither →
|
|
160
|
-
// "Nothing to stop".
|
|
161
|
-
const reply = hadActive ? strs.stopped
|
|
162
|
-
: killedBackgroundShell ? strs.bgStopped
|
|
163
|
-
: strs.nothing;
|
|
164
|
-
try {
|
|
165
|
-
await tg(bot, 'sendMessage', {
|
|
166
|
-
chat_id: chatId, text: reply,
|
|
167
|
-
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
168
|
-
...(threadId && { message_thread_id: threadId }),
|
|
169
|
-
}, { source: 'abort-ack', botName });
|
|
170
|
-
} catch (err) {
|
|
171
|
-
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
|
+
}
|
|
172
206
|
}
|
|
173
207
|
return true;
|
|
174
208
|
};
|
|
@@ -122,7 +122,10 @@ const DEFAULT_QUEUE_CAP = 50; // Parity P2: match SDK/tmux pendin
|
|
|
122
122
|
// catalog when new dialogs are observed in production.
|
|
123
123
|
const SESSION_AGE_PROMPT_RE = /Resuming the full session[\s\S]*Resume from summary/i;
|
|
124
124
|
const MID_TURN_PROMPTS = [
|
|
125
|
-
|
|
125
|
+
// Review F2 (resume-dialog fix): bare Enter selects the pre-selected
|
|
126
|
+
// "Resume from summary" — which literally runs /compact. Navigate to
|
|
127
|
+
// "Resume full session as-is" instead, same as the startup-gate trigger.
|
|
128
|
+
{ name: 'session-age', regex: SESSION_AGE_PROMPT_RE, action: 'keys', keys: ['Down', 'Enter'] },
|
|
126
129
|
];
|
|
127
130
|
|
|
128
131
|
// 0.12 Phase 3.2 (Finding 0.1.A): rc.45 esc-to-interrupt liveness heartbeat.
|
|
@@ -864,6 +867,20 @@ class CliProcess extends Process {
|
|
|
864
867
|
cwd: resolvedCwd || opts.cwd || process.cwd(),
|
|
865
868
|
command: this.claudeBin,
|
|
866
869
|
args: claudeArgs,
|
|
870
|
+
envExtras: {
|
|
871
|
+
// Resume-dialog suppression (docs/0.13-resume-dialog-fix-spec.md B1):
|
|
872
|
+
// claude's session-age "resume-return" dialog fires when sessionAge ≥
|
|
873
|
+
// this many minutes AND est. tokens ≥ CLAUDE_CODE_RESUME_TOKEN_THRESHOLD
|
|
874
|
+
// (defaults 70 / 1e5, binary-verified on 2.1.158). Its pre-selected
|
|
875
|
+
// option literally runs /compact — silently compacting every aged
|
|
876
|
+
// --resume (and breaking the /model "conversation kept" guarantee).
|
|
877
|
+
// A huge threshold (1 year) means the dialog never triggers and resume
|
|
878
|
+
// is always full-session-as-is. Per-process env — the operator's own
|
|
879
|
+
// interactive claude is untouched. Belt-and-braces: the session-age
|
|
880
|
+
// gate trigger below still navigates to "full" if a future binary bump
|
|
881
|
+
// renames this var.
|
|
882
|
+
CLAUDE_CODE_RESUME_THRESHOLD_MINUTES: '525600',
|
|
883
|
+
},
|
|
867
884
|
});
|
|
868
885
|
|
|
869
886
|
// Dialog handling (Phase 0 finding) — poll capture-pane and Enter through:
|
|
@@ -880,7 +897,7 @@ class CliProcess extends Process {
|
|
|
880
897
|
* lives in the shared helper.
|
|
881
898
|
*/
|
|
882
899
|
async _handleStartupDialogs(tmuxName) {
|
|
883
|
-
await runStartupGate({
|
|
900
|
+
const gateResult = await runStartupGate({
|
|
884
901
|
runner: this.runner,
|
|
885
902
|
tmuxName,
|
|
886
903
|
triggers: [
|
|
@@ -893,15 +910,33 @@ class CliProcess extends Process {
|
|
|
893
910
|
// pre-selected "trust" option). The older "trust the files in this folder"
|
|
894
911
|
// wording is kept for back-compat; both anchor on "trust … this folder".
|
|
895
912
|
{ name: 'trust', regex: /trust (?:the files in )?this folder/i, key: 'Enter' },
|
|
896
|
-
// Review F#12: session-age
|
|
897
|
-
// aged sessions
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
913
|
+
// Review F#12 + 2026-06-11 resume-dialog fix: session-age
|
|
914
|
+
// "resume-return" prompt on aged sessions. Bare Enter selects the
|
|
915
|
+
// pre-selected "Resume from summary" — which literally runs /compact
|
|
916
|
+
// on the resumed session (silent context degradation; the original
|
|
917
|
+
// F#12 dismissal compacted every aged resume). Navigate to option 2
|
|
918
|
+
// "Resume full session as-is" instead. This is the FALLBACK path:
|
|
919
|
+
// spawn env (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES above) suppresses
|
|
920
|
+
// the dialog entirely; this trigger firing at all means suppression
|
|
921
|
+
// failed (upstream renamed the env var?) — surfaced via the
|
|
922
|
+
// session-age-dialog-fallback event below.
|
|
923
|
+
{ name: 'session-age', regex: SESSION_AGE_PROMPT_RE, keys: ['Down', 'Enter'] },
|
|
903
924
|
],
|
|
904
|
-
|
|
925
|
+
// 2.1.173 reworked the channels UI banner (live-captured 2026-06-11):
|
|
926
|
+
// "Channels (experimental) messages from server:polygram-bridge inject
|
|
927
|
+
// directly in this session · …". Keep the 2.1.158 text too so a
|
|
928
|
+
// POLYGRAM_CLAUDE_BIN override to an older binary still gates correctly.
|
|
929
|
+
//
|
|
930
|
+
// 2026-06-12 (caught by the cancel-cheap E2E before prod): in 2.1.173
|
|
931
|
+
// the banner lives in a COLLAPSIBLE notice list — with ≥3 notices the
|
|
932
|
+
// pane shows "+N more · /status" and the banner is hidden, stalling a
|
|
933
|
+
// banner-only gate into a false CHANNELS_DIALOG_TIMEOUT. An interactive
|
|
934
|
+
// prompt footer ("(shift+tab to cycle)" / "? for shortcuts") with no
|
|
935
|
+
// pending dialog is equally READY: the gate's job is dialog navigation;
|
|
936
|
+
// channel liveness is separately guaranteed by mcp-ready (send() gate)
|
|
937
|
+
// + the delivery watchdog. Dialog panes render "Enter to confirm"
|
|
938
|
+
// instead of the footer, so the footer can't match mid-dialog.
|
|
939
|
+
readySignal: /(?:Listening for channel messages from:|Channels \(experimental\) messages from) server:polygram-bridge|shift\+tab to cycle|\? for shortcuts/i,
|
|
905
940
|
timeoutCode: 'CHANNELS_DIALOG_TIMEOUT',
|
|
906
941
|
// Progress-aware gate (shumorobot General incident 2026-05-30): a
|
|
907
942
|
// cold spawn that's mid-download (runtime fetch, "24%" progress bar)
|
|
@@ -917,9 +952,25 @@ class CliProcess extends Process {
|
|
|
917
952
|
// only bounds a TUI that rendered and then truly hung.
|
|
918
953
|
stallMs: this.startupGateStallMs ?? 60_000,
|
|
919
954
|
deadlineMs: this.startupGateDeadlineMs ?? 180_000,
|
|
955
|
+
// Review F4: fire-time, NOT gate-resolution — the 2026-06-10 incident
|
|
956
|
+
// matched session-age and THEN died (TMUX_SESSION_GONE), which a
|
|
957
|
+
// success-path check would miss. The dialog appearing AT ALL means the
|
|
958
|
+
// env suppression (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES in
|
|
959
|
+
// _spawnTmuxClaude) stopped working — almost certainly an upstream
|
|
960
|
+
// rename on a binary bump. The gate handles it (full resume picked);
|
|
961
|
+
// this makes the regression visible.
|
|
962
|
+
onTrigger: (name) => {
|
|
963
|
+
if (name !== 'session-age') return;
|
|
964
|
+
this.logger.warn?.(
|
|
965
|
+
`[${this.label}] channels: session-age resume dialog appeared despite env suppression — ` +
|
|
966
|
+
'check CLAUDE_CODE_RESUME_THRESHOLD_MINUTES against the pinned claude binary',
|
|
967
|
+
);
|
|
968
|
+
this._logEvent('session-age-dialog-fallback', { tmux_name: tmuxName, phase: 'startup-gate' });
|
|
969
|
+
},
|
|
920
970
|
logger: this.logger,
|
|
921
971
|
label: `${this.label}:startup-gate`,
|
|
922
972
|
});
|
|
973
|
+
return gateResult;
|
|
923
974
|
}
|
|
924
975
|
|
|
925
976
|
// 0.12 Phase 1.6: TWO-handshake gate. The original implementation only
|
|
@@ -1201,6 +1252,34 @@ class CliProcess extends Process {
|
|
|
1201
1252
|
return;
|
|
1202
1253
|
}
|
|
1203
1254
|
|
|
1255
|
+
// Dropped-"4" fix A2 (docs/0.13-resume-dialog-fix-spec.md): resolve the
|
|
1256
|
+
// reply's originating TG message so the dispatcher has a target for solo
|
|
1257
|
+
// reactions (and reply-quoting). Resolution order strictly mirrors
|
|
1258
|
+
// _recordReplyForPendingTurn so quote/reaction attribution can never
|
|
1259
|
+
// disagree with reply attribution: echoed turn_id → InputLedger entry's
|
|
1260
|
+
// msgId (registered at send/inject time); no echo → the single pending
|
|
1261
|
+
// turn's ledger entry. Anything else stays null — an unattributable
|
|
1262
|
+
// reply must never react to / quote an unrelated message.
|
|
1263
|
+
//
|
|
1264
|
+
// Review F1: quote only the FIRST delivered reply per turn. On SDK,
|
|
1265
|
+
// deliverReplies fires once per turn → one quote; the channels dispatcher
|
|
1266
|
+
// fires per reply tool call, and an N-reply turn must not produce N
|
|
1267
|
+
// bubbles all quoting the same user message.
|
|
1268
|
+
let sourceMsgId = null;
|
|
1269
|
+
let sourceEntry = null;
|
|
1270
|
+
if (args.turn_id && this.inputLedger.has(args.turn_id)) {
|
|
1271
|
+
sourceEntry = this.inputLedger.get(args.turn_id);
|
|
1272
|
+
} else if (this.pendingTurns.size === 1) {
|
|
1273
|
+
const [[onlyTurnId]] = this.pendingTurns;
|
|
1274
|
+
sourceEntry = this.inputLedger.get(onlyTurnId) || null;
|
|
1275
|
+
}
|
|
1276
|
+
if (sourceEntry && !sourceEntry._quoteUsed) {
|
|
1277
|
+
// Review F6: ledger stores msgId stringified; every other delivery call
|
|
1278
|
+
// site passes numeric message_id — coerce rather than lean on TG leniency.
|
|
1279
|
+
const n = Number(sourceEntry.msgId);
|
|
1280
|
+
sourceMsgId = Number.isFinite(n) && n > 0 ? n : null;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1204
1283
|
let result;
|
|
1205
1284
|
try {
|
|
1206
1285
|
result = await this.toolDispatcher({
|
|
@@ -1211,6 +1290,7 @@ class CliProcess extends Process {
|
|
|
1211
1290
|
text: args.text,
|
|
1212
1291
|
files: args.files,
|
|
1213
1292
|
messageId: args.message_id, // 0.13: edit_message target bubble
|
|
1293
|
+
sourceMsgId, // reaction/quote target (A2)
|
|
1214
1294
|
sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
|
|
1215
1295
|
maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
|
|
1216
1296
|
});
|
|
@@ -1219,6 +1299,12 @@ class CliProcess extends Process {
|
|
|
1219
1299
|
return;
|
|
1220
1300
|
}
|
|
1221
1301
|
|
|
1302
|
+
// Review F1: the quote target is spent once a reply actually delivered
|
|
1303
|
+
// with it. A FAILED delivery doesn't consume it — the retry still quotes.
|
|
1304
|
+
if (msg.name === 'reply' && result?.ok && sourceMsgId != null && sourceEntry) {
|
|
1305
|
+
sourceEntry._quoteUsed = true;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1222
1308
|
// 0.13: carry the delivered message_id back so the bridge hands it to claude
|
|
1223
1309
|
// (reply → edit_message progressive status).
|
|
1224
1310
|
this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error, message_id: result?.message_id });
|
|
@@ -1449,6 +1535,21 @@ class CliProcess extends Process {
|
|
|
1449
1535
|
this._ledgerTransition(id, 'resolved');
|
|
1450
1536
|
this._logEvent('cli-input-acked', { turn_id: id, source: e.source });
|
|
1451
1537
|
}
|
|
1538
|
+
// UMI 2026-06-11 19:49 false ⏱ timeout: when claude answers a
|
|
1539
|
+
// primary+fold in ONE reply but echoes the FOLD's turn_id, the reply
|
|
1540
|
+
// routes via late-reply correlation and the PRIMARY pending absorbs
|
|
1541
|
+
// nothing — yet this ack names the primary. Mark it consumed so the
|
|
1542
|
+
// finalizer rungs treat it as replied (resolve already-delivered)
|
|
1543
|
+
// instead of rejecting it at a ceiling AFTER the user got the answer.
|
|
1544
|
+
const pending = this.pendingTurns.get(id);
|
|
1545
|
+
if (pending) {
|
|
1546
|
+
pending._consumedAcked = true;
|
|
1547
|
+
// The ack itself flips rung-2 eligibility on — arm now. (The turn's
|
|
1548
|
+
// last _noteActivity ran BEFORE this flag was set, so without this
|
|
1549
|
+
// a quiet tail would never re-arm and the turn would sit until a
|
|
1550
|
+
// ceiling.)
|
|
1551
|
+
this._armActivityQuiet(id, pending);
|
|
1552
|
+
}
|
|
1452
1553
|
}
|
|
1453
1554
|
}
|
|
1454
1555
|
|
|
@@ -1607,7 +1708,10 @@ class CliProcess extends Process {
|
|
|
1607
1708
|
*/
|
|
1608
1709
|
_armActivityQuiet(turnId, pending) {
|
|
1609
1710
|
if (!this._sawHookStream) return;
|
|
1610
|
-
|
|
1711
|
+
// ≥1 reply, OR seen + consumed-acked (the answer rode a sibling turn_id —
|
|
1712
|
+
// fold-id echo; see _ledgerAckConsumed). Same eligibility as the fire site.
|
|
1713
|
+
if ((!pending.replies || pending.replies.length === 0)
|
|
1714
|
+
&& !(pending.seen === true && pending._consumedAcked === true)) return;
|
|
1611
1715
|
if (this._openQuestions.size > 0) return;
|
|
1612
1716
|
if (pending._stopGracePending) return;
|
|
1613
1717
|
if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
|
|
@@ -1637,11 +1741,15 @@ class CliProcess extends Process {
|
|
|
1637
1741
|
if (!pending) return;
|
|
1638
1742
|
if (pending._stopGracePending) return;
|
|
1639
1743
|
if (this._openQuestions.size > 0) return; // re-check at fire time
|
|
1640
|
-
|
|
1744
|
+
// Eligibility: ≥1 bound reply, OR seen + consumed-acked (the answer went
|
|
1745
|
+
// out under a sibling turn_id — fold-id echo; see _ledgerAckConsumed).
|
|
1746
|
+
const consumedAcked = pending.seen === true && pending._consumedAcked === true;
|
|
1747
|
+
if ((!pending.replies || pending.replies.length === 0) && !consumedAcked) return;
|
|
1641
1748
|
const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
|
|
1642
1749
|
this._logEvent('cli-activity-quiet-finalize', {
|
|
1643
1750
|
turn_id: turnId,
|
|
1644
1751
|
reply_count: pending.replies.length,
|
|
1752
|
+
consumed_acked: consumedAcked,
|
|
1645
1753
|
last_hook_age_ms: lastHookAgeMs,
|
|
1646
1754
|
had_stop: !!pending._stopHookData,
|
|
1647
1755
|
});
|
|
@@ -1792,8 +1900,11 @@ class CliProcess extends Process {
|
|
|
1792
1900
|
// BUT a turn finalized via the Stop fallback (no reply tool calls — the
|
|
1793
1901
|
// stuck-turn case) has delivered NOTHING; marking it alreadyDelivered
|
|
1794
1902
|
// would resolve the turn silently and the user still sees nothing. So
|
|
1795
|
-
// only claim already-delivered when reply tool calls actually fired
|
|
1796
|
-
|
|
1903
|
+
// only claim already-delivered when reply tool calls actually fired —
|
|
1904
|
+
// or when claude ACKED consuming this turn in a sibling reply
|
|
1905
|
+
// (consumed_turn_ids; the fold-id-echo case): re-sending the Stop
|
|
1906
|
+
// fallback there would duplicate the delivered answer.
|
|
1907
|
+
alreadyDelivered: hadReplyToolCalls || pending._consumedAcked === true,
|
|
1797
1908
|
sessionId: this.claudeSessionId,
|
|
1798
1909
|
cost: null, // Channels protocol doesn't expose per-turn cost
|
|
1799
1910
|
duration,
|
|
@@ -1931,10 +2042,14 @@ class CliProcess extends Process {
|
|
|
1931
2042
|
// would send a scary timeout error AFTER a successful reply (round-2
|
|
1932
2043
|
// panel finding: the v2 soak gate contradicted the design's own
|
|
1933
2044
|
// ask-timeout-then-ceiling path). TURN_TIMEOUT rejection is reserved
|
|
1934
|
-
// for turns with ZERO delivered replies.
|
|
1935
|
-
|
|
2045
|
+
// for turns with ZERO delivered replies. Consumed-acked counts as
|
|
2046
|
+
// replied: the answer rode a sibling turn_id (fold-id echo — the UMI
|
|
2047
|
+
// 2026-06-11 19:49 false ⏱; see _ledgerAckConsumed).
|
|
2048
|
+
if ((pending.replies?.length || 0) > 0
|
|
2049
|
+
|| (pending.seen === true && pending._consumedAcked === true)) {
|
|
1936
2050
|
this._logEvent('cli-turn-ceiling-resolved', {
|
|
1937
|
-
reason, turnTimeoutMs, reply_count: pending.replies
|
|
2051
|
+
reason, turnTimeoutMs, reply_count: pending.replies?.length || 0,
|
|
2052
|
+
consumed_acked: pending._consumedAcked === true,
|
|
1938
2053
|
});
|
|
1939
2054
|
this.emit('idle');
|
|
1940
2055
|
resolve({
|
|
@@ -2042,6 +2157,13 @@ class CliProcess extends Process {
|
|
|
2042
2157
|
async interrupt() {
|
|
2043
2158
|
if (this.closed) return;
|
|
2044
2159
|
if (!this.tmuxSession) return;
|
|
2160
|
+
// Cancel-cheap C2 (spec Finding 7): a cancel is already in flight — a
|
|
2161
|
+
// SECOND C-c would land at the now-idle prompt, which is claude's exit
|
|
2162
|
+
// chord ("press ctrl+c again to exit") and would convert the cheap cancel
|
|
2163
|
+
// into an accidental process exit. Also: resetting the grace timer would
|
|
2164
|
+
// DELAY the synthetic resolution for a user double-tapping "stop".
|
|
2165
|
+
// Idempotent no-op instead.
|
|
2166
|
+
if (this._interruptGraceTimer) return;
|
|
2045
2167
|
// tmux SIGINT — hard interrupt for the running turn.
|
|
2046
2168
|
try {
|
|
2047
2169
|
await this.runner.sendControl(this.tmuxSession, 'C-c');
|
|
@@ -2052,18 +2174,37 @@ class CliProcess extends Process {
|
|
|
2052
2174
|
this.emit('interrupt-applied', { backend: this.backend });
|
|
2053
2175
|
this._logEvent('interrupt-applied', {});
|
|
2054
2176
|
|
|
2177
|
+
// Cancel-cheap C1 — the spec's O2 BLOCKER: the cancelled work's inputs
|
|
2178
|
+
// must never re-deliver. The grace below synthesizes the resolution
|
|
2179
|
+
// WITHOUT _finalizeTurn, so without this, an autosteer/fold entry stays
|
|
2180
|
+
// 'written' and a LATER cycle-end sweep declares it dropped →
|
|
2181
|
+
// drop-redeliver re-injects the user's CANCELLED message minutes later.
|
|
2182
|
+
// 'cancelled' is terminal: the sweep only targets 'written', and
|
|
2183
|
+
// _ledgerTransition clears the entry's drop/watchdog timers.
|
|
2184
|
+
for (const [id, e] of this.inputLedger) {
|
|
2185
|
+
if (e.state === 'written' || e.state === 'seen') {
|
|
2186
|
+
this._ledgerTransition(id, 'cancelled');
|
|
2187
|
+
this._logEvent('cli-input-cancelled', { turn_id: id, source: e.source });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2055
2191
|
// Review P3 C8: after Ctrl-C, Claude may or may not call reply with an
|
|
2056
2192
|
// "I was interrupted" message. If it doesn't (5s grace), resolve pending
|
|
2057
2193
|
// turns with subtype 'interrupted' instead of letting them wait the full
|
|
2058
|
-
// 10-min hardTimer.
|
|
2059
|
-
if (this._interruptGraceTimer) clearTimeout(this._interruptGraceTimer);
|
|
2194
|
+
// 10-min hardTimer.
|
|
2060
2195
|
this._interruptGraceTimer = setTimeout(() => {
|
|
2061
2196
|
let resolvedAny = false;
|
|
2062
2197
|
for (const [turnId, pending] of this.pendingTurns) {
|
|
2063
2198
|
// Synthesize an interrupted resolution: empty text, 'interrupted' subtype.
|
|
2199
|
+
// Cancel-cheap C3: clear ALL per-pending machinery (mirrors
|
|
2200
|
+
// _finalizeTurn) — stray timers/listeners on the kept-warm proc are
|
|
2201
|
+
// exactly what the cheap-cancel design must not leak.
|
|
2064
2202
|
if (pending.quietTimer) clearTimeout(pending.quietTimer);
|
|
2065
2203
|
if (pending.hardTimer) clearTimeout(pending.hardTimer);
|
|
2066
|
-
|
|
2204
|
+
if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
|
|
2205
|
+
if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
|
|
2206
|
+
if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
|
|
2207
|
+
if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
|
|
2067
2208
|
this.pendingTurns.delete(turnId);
|
|
2068
2209
|
const qIdx = this.pendingQueue.findIndex(e => e.turnId === turnId);
|
|
2069
2210
|
if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
|
|
@@ -3389,16 +3530,28 @@ class CliProcess extends Process {
|
|
|
3389
3530
|
pending_count: this.pendingTurns.size,
|
|
3390
3531
|
});
|
|
3391
3532
|
|
|
3392
|
-
if (prompt.action === 'enter') {
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3533
|
+
if (prompt.action === 'enter' || prompt.action === 'keys') {
|
|
3534
|
+
// 'keys' sends a navigation sequence (e.g. Down,Enter to pick a
|
|
3535
|
+
// non-default dialog option); 'enter' stays the single-key dismissal.
|
|
3536
|
+
const keySeq = prompt.action === 'keys' ? prompt.keys : ['Enter'];
|
|
3537
|
+
for (let ki = 0; ki < keySeq.length; ki++) {
|
|
3538
|
+
if (ki > 0) await new Promise(r => setTimeout(r, 120)); // Ink can swallow same-batch keys
|
|
3539
|
+
try {
|
|
3540
|
+
await this.runner.sendControl(this.tmuxSession, keySeq[ki]);
|
|
3541
|
+
} catch (err) {
|
|
3542
|
+
this.logger.warn?.(
|
|
3543
|
+
`[${this.label}] cli: mid-turn ${keySeq[ki]} failed for ${prompt.name}: ${err.message}`,
|
|
3544
|
+
);
|
|
3545
|
+
}
|
|
3399
3546
|
}
|
|
3400
3547
|
}
|
|
3401
3548
|
// 'emit-only': telemetry-only; operator decides next step.
|
|
3549
|
+
// Resume-dialog fix: the session-age dialog escaping to MID-TURN means
|
|
3550
|
+
// env suppression failed AND the startup gate didn't see it — same
|
|
3551
|
+
// soak-queryable event kind as the startup-gate fallback.
|
|
3552
|
+
if (prompt.name === 'session-age') {
|
|
3553
|
+
this._logEvent('session-age-dialog-fallback', { tmux_name: this.tmuxSession, phase: 'mid-turn' });
|
|
3554
|
+
}
|
|
3402
3555
|
}
|
|
3403
3556
|
|
|
3404
3557
|
// 0.12 Phase 3.3 (Q1 resolution): unknown-prompt heuristic. If the pane
|
package/lib/telegram/parse.js
CHANGED
|
@@ -94,8 +94,15 @@ function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Solo-emoji shortcuts (single emoji → sticker if mapped, else reaction).
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// Keycap-base guard (2026-06-10 "2+2 → 4" dropped reply): Unicode \p{Emoji}
|
|
98
|
+
// includes 0-9/#/* (keycap bases), so a bare single-digit answer parsed as
|
|
99
|
+
// a reaction with text:'' and the channels dispatcher dropped it. A solo
|
|
100
|
+
// digit/hash/asterisk is always TEXT; real keycap emoji (4️⃣) are
|
|
101
|
+
// multi-codepoint and never hit this branch anyway. The optional ️
|
|
102
|
+
// also catches a stray variation selector on a digit ("4️") — same class.
|
|
103
|
+
const emojiOnly = !/^[0-9#*]️?$/.test(trimmed)
|
|
104
|
+
&& (/^\p{Emoji_Presentation}$/u.test(trimmed)
|
|
105
|
+
|| /^\p{Emoji}️?$/u.test(trimmed));
|
|
99
106
|
|
|
100
107
|
if (emojiOnly && trimmed) {
|
|
101
108
|
if (emojiToSticker[trimmed]) {
|
package/lib/tmux/startup-gate.js
CHANGED
|
@@ -60,6 +60,10 @@ const DEFAULT_SETTLE_MS = 500;
|
|
|
60
60
|
* @param {number} [opts.pollMs=300]
|
|
61
61
|
* @param {number} [opts.settleMs=500]
|
|
62
62
|
* @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
|
|
63
|
+
* @param {Function} [opts.onTrigger] — (name) => void, called AT FIRE
|
|
64
|
+
* TIME (not gate resolution). Telemetry hung off the success-path return
|
|
65
|
+
* misses the matched-then-died sequence (2026-06-10 prod: gate matched
|
|
66
|
+
* session-age, then TMUX_SESSION_GONE). Errors are swallowed.
|
|
63
67
|
* @param {object} [opts.logger=console]
|
|
64
68
|
* @param {string} [opts.label='startup-gate']
|
|
65
69
|
* @returns {Promise<{matchedTriggers: string[], elapsedMs: number}>}
|
|
@@ -74,6 +78,7 @@ async function runStartupGate({
|
|
|
74
78
|
pollMs = DEFAULT_POLL_MS,
|
|
75
79
|
settleMs = DEFAULT_SETTLE_MS,
|
|
76
80
|
timeoutCode = 'TUI_STARTUP_TIMEOUT',
|
|
81
|
+
onTrigger = null,
|
|
77
82
|
logger = console,
|
|
78
83
|
label = 'startup-gate',
|
|
79
84
|
} = {}) {
|
|
@@ -174,13 +179,22 @@ async function runStartupGate({
|
|
|
174
179
|
for (const trigger of triggers) {
|
|
175
180
|
if (seen.has(trigger.name)) continue;
|
|
176
181
|
if (!trigger.regex.test(pane)) continue;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
// `keys: [...]` sends a sequence (dialog navigation — e.g. Down,Enter
|
|
183
|
+
// to pick a non-default option); `key:` remains the single-key form.
|
|
184
|
+
// Sequence keys go as separate send-keys calls with a short delay —
|
|
185
|
+
// Ink dialogs can swallow the second key of a same-batch sequence.
|
|
186
|
+
const keySeq = Array.isArray(trigger.keys) ? trigger.keys : [trigger.key];
|
|
187
|
+
for (let ki = 0; ki < keySeq.length; ki++) {
|
|
188
|
+
if (ki > 0) await new Promise(r => setTimeout(r, Math.min(settleMs, 120)));
|
|
189
|
+
try {
|
|
190
|
+
await runner.sendControl(tmuxName, keySeq[ki]);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
logger.warn?.(`[${label}] sendControl(${keySeq[ki]}) failed for trigger=${trigger.name}: ${err.message}`);
|
|
193
|
+
}
|
|
181
194
|
}
|
|
182
195
|
seen.add(trigger.name);
|
|
183
196
|
matchedTriggers.push(trigger.name);
|
|
197
|
+
try { onTrigger?.(trigger.name); } catch { /* telemetry must not break the gate */ }
|
|
184
198
|
matched = true;
|
|
185
199
|
// Sending a key is activity — navigating the TUI counts as progress
|
|
186
200
|
// even if the pre-transition pane text was static (e.g. a dialog we
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.39",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|