polygram 0.17.3 → 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.
@@ -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
- // 0.13 D1 (S9): unblock any open ask FIRST claude must never stay
2190
- // hung on a question whose turn we are about to end. The card cleanup
2191
- // stays with the question sweep; this only resolves the blocking tool.
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
- for (const tc of [...this._openQuestions]) {
2194
- this._logEvent('cli-question-timedout-at-ceiling', { tool_call_id: tc, reason });
2195
- try { this.writeQuestionAnswer(tc, { timedout: true }); } catch { /* best-effort */ }
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).
@@ -11,12 +11,13 @@
11
11
 
12
12
  const { newToken, tokensEqual } = require('../approvals/store');
13
13
 
14
- // Option A (2026-06-09): don't expire a question before the turn that's blocking on
15
- // it can. A blocking `ask` can live at most the 30-min turn ABSOLUTE cap
16
- // (DEFAULT_TURN_ABSOLUTE_MS) the keep-alive resets the idle cap but not the absolute
17
- // so align here. The user answers any time within the turn's life, not an arbitrary
18
- // 8-min window. (Truly-unbounded "answer hours later" needs the non-blocking redesign.)
19
- const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
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(`
@@ -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: set true while a sub-agent / background work is in flight suppresses the
230
- // stall/freeze decay so a working-but-quiet turn never shows 🥱/😨.
231
- let workInFlight = false;
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 (or background work) is genuinely in flight, a quiet
337
- // stretch is EXPECTED the turn is working, not stalled. Don't arm the
338
- // 🥱/😨 decay; hold the current working face until work drains.
339
- if (workInFlight) return;
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
- workInFlight = false; // B3: defense-in-depth if a reactor is ever reused
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: mark whether work (a sub-agent / background shell) is in flight. While
464
- // active, the silence between tool hooks is expected, so the stall/freeze decay
465
- // is suppressed and the reactor holds its working face. When work drains, the
466
- // normal cascade resumes from now. docs/progress-is-not-turn-end-spec.md
467
- const setWorkInFlight = (active) => {
468
- const next = !!active;
469
- if (next === workInFlight) return;
470
- workInFlight = next;
471
- if (workInFlight) clearStallTimers(); // cancel any pending 🥱/😨 decay
472
- else armStallTimers(); // work drained — resume the cascade
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 {
@@ -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 = () => { paused = true; };
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",
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": {