polygram 0.17.2 → 0.17.4
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/handlers/gate-inbound.js +1 -1
- package/lib/handlers/questions.js +1 -1
- package/lib/handlers/slash-commands.js +2 -2
- package/lib/process/cli-process.js +27 -7
- package/lib/questions/store.js +7 -6
- package/lib/sdk/callbacks.js +12 -2
- package/lib/telegram/reactions.js +22 -18
- package/lib/telegram/typing.js +8 -1
- package/package.json +1 -1
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* `let x = null; wired in main()` pattern, made explicit.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
|
|
41
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|clear|context|compact)(\s|$)/;
|
|
42
42
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
43
43
|
|
|
44
44
|
function createGateInbound({
|
|
@@ -203,7 +203,7 @@ function createQuestionHandlers({
|
|
|
203
203
|
// every group member's message for the whole question lifetime).
|
|
204
204
|
if (!row.awaiting_other) return { consumed: false };
|
|
205
205
|
// /stop, /new and other commands are never consumed as a free-text answer.
|
|
206
|
-
if (/^\/(stop|new|reset|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
|
|
206
|
+
if (/^\/(stop|new|reset|clear|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
|
|
207
207
|
// Identity: only the claimed owner supplies the free-text answer.
|
|
208
208
|
const auth = questions.claimOrCheck(row.id, fromId);
|
|
209
209
|
if (!auth.ok) {
|
|
@@ -157,8 +157,8 @@ function createSlashCommands({
|
|
|
157
157
|
return true;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// /new + /reset — fresh session
|
|
161
|
-
if (botAllowsCommands && (text === '/new' || text === '/reset')) {
|
|
160
|
+
// /new + /reset + /clear — fresh session (all synonyms)
|
|
161
|
+
if (botAllowsCommands && (text === '/new' || text === '/reset' || text === '/clear')) {
|
|
162
162
|
let drained = 0;
|
|
163
163
|
try {
|
|
164
164
|
const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
|
|
@@ -2186,14 +2186,20 @@ class CliProcess extends Process {
|
|
|
2186
2186
|
const fireTimeout = (reason, probeResult = null) => {
|
|
2187
2187
|
if (!this.pendingTurns.has(turnId)) return;
|
|
2188
2188
|
const pending = this.pendingTurns.get(turnId);
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2189
|
+
// A question waits for the user: while an `ask` is open the turn must NOT
|
|
2190
|
+
// time out and die mid-question. Defer — re-arm the absolute checkpoint and
|
|
2191
|
+
// keep waiting; the question store's long safety backstop is the only bound
|
|
2192
|
+
// (a truly-abandoned question eventually expires {timedout}). Pre-0.17.4 this
|
|
2193
|
+
// force-answered {timedout} at the ~30-min ceiling and killed the turn.
|
|
2194
|
+
// docs/progress-is-not-turn-end-spec.md
|
|
2192
2195
|
if (this._openQuestions.size > 0) {
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2196
|
+
this._logEvent('cli-question-wait-extended', { reason, open_count: this._openQuestions.size });
|
|
2197
|
+
// Reached via the idle hardTimer too — clear any still-armed absoluteTimer
|
|
2198
|
+
// before re-arming so we don't orphan a ref-holding handle teardown can't see.
|
|
2199
|
+
if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
|
|
2200
|
+
pending.absoluteTimer = setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs);
|
|
2201
|
+
pending.absoluteTimer.unref?.();
|
|
2202
|
+
return;
|
|
2197
2203
|
}
|
|
2198
2204
|
this.pendingTurns.delete(turnId);
|
|
2199
2205
|
const idx = this.pendingQueue.findIndex(e => e.turnId === turnId);
|
|
@@ -2476,6 +2482,15 @@ class CliProcess extends Process {
|
|
|
2476
2482
|
async _checkpointAbsolute(turnId) {
|
|
2477
2483
|
if (!this.pendingTurns.has(turnId)) return;
|
|
2478
2484
|
let pending = this.pendingTurns.get(turnId);
|
|
2485
|
+
// A question is open → the turn is waiting on the USER, not stalled. Don't probe
|
|
2486
|
+
// or time out: re-arm and keep waiting (the question store's long backstop is the
|
|
2487
|
+
// bound). docs/progress-is-not-turn-end-spec.md
|
|
2488
|
+
if (this._openQuestions.size > 0) {
|
|
2489
|
+
this._logEvent('cli-question-wait-extended', { reason: 'absolute-checkpoint', open_count: this._openQuestions.size });
|
|
2490
|
+
pending.absoluteTimer = setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs);
|
|
2491
|
+
pending.absoluteTimer.unref?.();
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2479
2494
|
// Turn with a FINAL reply (or consumed-acked): the ceiling RESOLVES it, never
|
|
2480
2495
|
// extends. An interim-only turn (status promise, no final reply) is still
|
|
2481
2496
|
// working — fall through to the busy-aware probe so it extends, not resolves.
|
|
@@ -3493,6 +3508,11 @@ class CliProcess extends Process {
|
|
|
3493
3508
|
try { pending.reject(err); } catch {}
|
|
3494
3509
|
}
|
|
3495
3510
|
this.pendingTurns.clear();
|
|
3511
|
+
// Drop interactive-question state too (parity with _doKill /
|
|
3512
|
+
// _handleBridgeDisconnected) — else the 60s keep-alive interval leaks and
|
|
3513
|
+
// _openQuestions is left stale on the reset session.
|
|
3514
|
+
this._stopQuestionKeepAlive();
|
|
3515
|
+
this._openQuestions.clear();
|
|
3496
3516
|
// Now drain pendingQueue. Skip matching turnIds (already counted), reject
|
|
3497
3517
|
// the rest (entries pushed by callers other than this.send — contract
|
|
3498
3518
|
// test, tmux/sdk pm callback path).
|
package/lib/questions/store.js
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
const { newToken, tokensEqual } = require('../approvals/store');
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
14
|
+
// A question waits for the user — the turn no longer times out while an `ask` is open
|
|
15
|
+
// (cli-process defers its ceilings during a question wait, docs/progress-is-not-turn-end-spec.md),
|
|
16
|
+
// so this is only the long SAFETY BACKSTOP: a forgotten/abandoned question eventually
|
|
17
|
+
// expires {timedout} instead of pinning the session forever. Generous (a full day) so a
|
|
18
|
+
// real user answering hours later is never cut off; tune via the `questionTimeoutMs` config
|
|
19
|
+
// if a chat needs shorter/longer.
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
20
21
|
|
|
21
22
|
function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
22
23
|
const insertStmt = rawDb.prepare(`
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -351,6 +351,9 @@ function createSdkCallbacks({
|
|
|
351
351
|
ctx?.typing?.resume?.();
|
|
352
352
|
const r = ctx?.reactor;
|
|
353
353
|
if (r && typeof r.setState === 'function') {
|
|
354
|
+
// 0.17.4: release the question-wait hold (a concurrent sub-agent hold, if
|
|
355
|
+
// any, keeps its own — owner-scoped so they don't stomp each other).
|
|
356
|
+
if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight(false, 'question');
|
|
354
357
|
r.setState('THINKING');
|
|
355
358
|
logEvent('question-resumed', { chat_id: getChatIdFromKey(sessionKey), session_key: sessionKey });
|
|
356
359
|
}
|
|
@@ -367,6 +370,13 @@ function createSdkCallbacks({
|
|
|
367
370
|
// loop) alive through the whole wait, so without this pause every
|
|
368
371
|
// ask-wait would show continuous typing. Guarded no-op on dead turns.
|
|
369
372
|
try { entry?.pendingQueue?.[0]?.context?.typing?.pause?.(); } catch { /* guarded */ }
|
|
373
|
+
// 0.17.4: hold the reaction through the question wait — it's waiting on the
|
|
374
|
+
// USER, not stalled, so don't let it decay to the 🥱/😨 stall faces (reuses
|
|
375
|
+
// the B3 work-in-flight hold). Released on the answer in onQuestionResumed.
|
|
376
|
+
try {
|
|
377
|
+
const r = entry?.pendingQueue?.[0]?.context?.reactor;
|
|
378
|
+
if (r && typeof r.setWorkInFlight === 'function') r.setWorkInFlight(true, 'question');
|
|
379
|
+
} catch { /* guarded */ }
|
|
370
380
|
if (typeof renderQuestion !== 'function') return;
|
|
371
381
|
await renderQuestion({ sessionKey, ...payload });
|
|
372
382
|
} catch (err) {
|
|
@@ -792,7 +802,7 @@ function createSdkCallbacks({
|
|
|
792
802
|
// B3: hold a "working" face for the whole sub-agent run — the quiet
|
|
793
803
|
// stretch between its tool hooks is expected, not a stall, so suppress
|
|
794
804
|
// the 🥱/😨 decay until it finishes. docs/progress-is-not-turn-end-spec.md
|
|
795
|
-
if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight(true);
|
|
805
|
+
if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight(true, 'subagent');
|
|
796
806
|
}
|
|
797
807
|
} catch (err) {
|
|
798
808
|
logger.error?.(`[${botName}] subagent-start handler: ${err.message}`);
|
|
@@ -807,7 +817,7 @@ function createSdkCallbacks({
|
|
|
807
817
|
if (r) {
|
|
808
818
|
// B3: release the working-hold only when the LAST sub-agent finishes
|
|
809
819
|
// (inFlight === 0) — nested/parallel sub-agents keep it held.
|
|
810
|
-
if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight((payload?.inFlight ?? 0) > 0);
|
|
820
|
+
if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight((payload?.inFlight ?? 0) > 0, 'subagent');
|
|
811
821
|
if (typeof r.heartbeat === 'function') r.heartbeat();
|
|
812
822
|
}
|
|
813
823
|
logEvent('subagent-done', {
|
|
@@ -226,9 +226,11 @@ function createReactionManager({
|
|
|
226
226
|
// Chaining all applies through `applyChain` guarantees they're sent
|
|
227
227
|
// to Telegram in setState() invocation order.
|
|
228
228
|
let applyChain = Promise.resolve();
|
|
229
|
-
// B3
|
|
230
|
-
//
|
|
231
|
-
let
|
|
229
|
+
// B3 / 0.17.4: independent "hold the reaction, suppress the 🥱/😨 decay" owners —
|
|
230
|
+
// a sub-agent run AND an open question can each hold concurrently. A boolean would
|
|
231
|
+
// let one release while the other still needs the hold (review MUST-FIX), so track
|
|
232
|
+
// the set of active owners; the decay is suppressed while ANY owner holds.
|
|
233
|
+
const workOwners = new Set();
|
|
232
234
|
// States the auto-stall path may transition to. Once we've already
|
|
233
235
|
// shown STALL or TIMEOUT we don't downgrade or rearm — only an
|
|
234
236
|
// explicit setState() call (Claude resumed) can move us forward.
|
|
@@ -333,10 +335,10 @@ function createReactionManager({
|
|
|
333
335
|
const armStallTimers = () => {
|
|
334
336
|
clearStallTimers();
|
|
335
337
|
if (stopped) return;
|
|
336
|
-
// B3: while a sub-agent
|
|
337
|
-
//
|
|
338
|
-
// 🥱/😨 decay; hold the current
|
|
339
|
-
if (
|
|
338
|
+
// B3 / 0.17.4: while any owner holds (a sub-agent in flight, or an open question
|
|
339
|
+
// waiting on the user), a quiet stretch is EXPECTED — not stalled. Don't arm the
|
|
340
|
+
// 🥱/😨 decay; hold the current face until every owner releases.
|
|
341
|
+
if (workOwners.size > 0) return;
|
|
340
342
|
if (!STALL_PROMOTABLE.has(currentState)) return;
|
|
341
343
|
stallTimer = setTimeout(() => {
|
|
342
344
|
stallTimer = null;
|
|
@@ -439,7 +441,7 @@ function createReactionManager({
|
|
|
439
441
|
|
|
440
442
|
const stop = () => {
|
|
441
443
|
stopped = true;
|
|
442
|
-
|
|
444
|
+
workOwners.clear(); // B3: defense-in-depth if a reactor is ever reused
|
|
443
445
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
444
446
|
clearStallTimers();
|
|
445
447
|
clearDeepeningTimers();
|
|
@@ -460,16 +462,18 @@ function createReactionManager({
|
|
|
460
462
|
armStallTimers();
|
|
461
463
|
};
|
|
462
464
|
|
|
463
|
-
// B3:
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
465
|
+
// B3 / 0.17.4: a named owner ('subagent', 'question', …) holds/releases the
|
|
466
|
+
// reaction. While ANY owner holds, the silence is expected (work in flight, or
|
|
467
|
+
// waiting on the user), so the stall/freeze decay is suppressed and the reactor
|
|
468
|
+
// holds its face. The cascade resumes only when the LAST owner releases. A boolean
|
|
469
|
+
// couldn't represent two concurrent owners. docs/progress-is-not-turn-end-spec.md
|
|
470
|
+
const setWorkInFlight = (active, owner = 'default') => {
|
|
471
|
+
const wasHeld = workOwners.size > 0;
|
|
472
|
+
if (active) workOwners.add(owner); else workOwners.delete(owner);
|
|
473
|
+
const isHeld = workOwners.size > 0;
|
|
474
|
+
if (isHeld === wasHeld) return;
|
|
475
|
+
if (isHeld) clearStallTimers(); // first owner → cancel any pending 🥱/😨 decay
|
|
476
|
+
else armStallTimers(); // last owner released → resume the cascade
|
|
473
477
|
};
|
|
474
478
|
|
|
475
479
|
return {
|
package/lib/telegram/typing.js
CHANGED
|
@@ -133,8 +133,15 @@ function startTyping({
|
|
|
133
133
|
// tearing the loop down; resume() restarts immediately (the answer landed,
|
|
134
134
|
// claude is working again). Attached to the stop function so every existing
|
|
135
135
|
// `stopTyping()` call site keeps working unchanged.
|
|
136
|
-
stop.pause = () => {
|
|
136
|
+
stop.pause = () => {
|
|
137
|
+
if (paused) return;
|
|
138
|
+
paused = true;
|
|
139
|
+
// 0.17.4: instrument the question pause/resume so "typing disappeared after I
|
|
140
|
+
// answered" is diagnosable (typing pings themselves aren't logged).
|
|
141
|
+
onEvent?.({ kind: 'typing-state', chat_id: key, detail: { state: 'paused' } });
|
|
142
|
+
};
|
|
137
143
|
stop.resume = () => {
|
|
144
|
+
onEvent?.({ kind: 'typing-state', chat_id: key, detail: { state: 'resume-called', stopped } });
|
|
138
145
|
if (stopped) return;
|
|
139
146
|
paused = false;
|
|
140
147
|
tick();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
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": {
|