polygram 0.12.0-rc.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/config.example.json +3 -1
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -51,6 +51,9 @@ const KNOWN_EVENT_NAMES = new Set([
51
51
  'SubagentStop',
52
52
  'Stop',
53
53
  'Notification',
54
+ // 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
55
+ 'PreCompact',
56
+ 'PostCompact',
54
57
  ]);
55
58
 
56
59
  /**
@@ -87,6 +90,10 @@ function normalizeHookEvent(raw) {
87
90
  prompt: raw?.prompt ?? null,
88
91
  stopHookActive: raw?.stop_hook_active ?? null,
89
92
  lastAssistantMessage: raw?.last_assistant_message ?? null,
93
+ // PreCompact/PostCompact payload: trigger distinguishes auto vs manual
94
+ // compaction; custom_instructions is the `/compact <hint>` text (manual).
95
+ trigger: raw?.trigger ?? null,
96
+ customInstructions: raw?.custom_instructions ?? null,
90
97
  receivedAtMs: raw?.polygram_received_at_ms ?? null,
91
98
  raw,
92
99
  };
@@ -44,6 +44,13 @@ const HOOK_EVENTS = [
44
44
  'SubagentStop',
45
45
  'Stop',
46
46
  'Notification',
47
+ // 0.12.0-rc.13: compaction lifecycle. PreCompact fires when claude is about
48
+ // to (auto-)compact — the moment that detaches the channels MCP bridge.
49
+ // PostCompact fires after, when context has dropped (used to re-arm the
50
+ // per-chat compaction warning). Both confirmed supported by the pinned CLI
51
+ // (2.1.142) and carry a `trigger: auto|manual` field.
52
+ 'PreCompact',
53
+ 'PostCompact',
47
54
  ];
48
55
 
49
56
  /**
@@ -160,6 +160,28 @@ class Process extends EventEmitter {
160
160
  return false;
161
161
  }
162
162
 
163
+ /**
164
+ * Does this session have a DETACHED background job running (a `run_in_background`
165
+ * shell that outlives the dispatch turn)? Used by ProcessManager._evictLRU to PIN
166
+ * the session — skip it for eviction the same way `inFlight` is skipped — so a live
167
+ * job isn't silently killed under budget pressure. Default: no signal → false.
168
+ * Backends that can detect detached shells (cli) override this. Must be cheap + sync.
169
+ *
170
+ * @returns {boolean}
171
+ */
172
+ hasActiveBackgroundWork() {
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * 0.13 D1 (S9): does this process have an open interactive question?
178
+ * Backends without the ask feature return false; CliProcess overrides.
179
+ * ProcessManager._evictLRU treats true like inFlight (eviction pin).
180
+ */
181
+ hasOpenQuestions() {
182
+ return false;
183
+ }
184
+
163
185
  /**
164
186
  * Push priority='now' style steer (rare; legacy of OpenClaw shape).
165
187
  * Hot-path-safe.
@@ -193,7 +193,60 @@ function _makeUnhandledRejectionHandler(opts) {
193
193
  }
194
194
 
195
195
  /**
196
- * Convenience: install both handlers in one call.
196
+ * Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
197
+ * shutdown can't become an uncaughtException.
198
+ *
199
+ * rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
200
+ * write errors themselves still surface: when `launchctl kickstart` destroys the
201
+ * tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
202
+ * Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
203
+ * unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
204
+ * restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
205
+ * every deploy, interrupting the graceful drain.
206
+ *
207
+ * This guards BOTH delivery paths:
208
+ * (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
209
+ * (b) async: attaches an `error` listener for the pipe case (errors arrive as
210
+ * events). Genuine, non-EPIPE/EIO errors still surface unchanged.
211
+ *
212
+ * @returns {{ uninstall: function() }}
213
+ */
214
+ function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
215
+ const guarded = streams.filter(Boolean);
216
+ const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
217
+ const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
218
+ const restores = [];
219
+
220
+ for (const s of guarded) {
221
+ s.on?.('error', onError);
222
+ restores.push(() => s.off?.('error', onError));
223
+
224
+ if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
225
+ const origWrite = s.write;
226
+ s.write = function guardedWrite(...args) {
227
+ try {
228
+ return origWrite.apply(this, args);
229
+ } catch (err) {
230
+ if (!isBrokenPipe(err)) throw err;
231
+ // Pane is gone — nothing to write to. Invoke the write callback (the
232
+ // last arg, if any) so callers awaiting it don't hang, and report
233
+ // backpressure (false) instead of throwing to uncaughtException.
234
+ const cb = args[args.length - 1];
235
+ if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
236
+ return false;
237
+ }
238
+ };
239
+ s.__polygramStdioGuarded = true;
240
+ restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
241
+ }
242
+ }
243
+
244
+ return { uninstall() { for (const r of restores) r(); } };
245
+ }
246
+
247
+ /**
248
+ * Convenience: install both handlers in one call (plus the stdio guard, so the
249
+ * shutdown broken-pipe writes never reach the uncaughtException handler).
197
250
  * @returns {{ uninstall: function() }}
198
251
  */
199
252
  function installSafetyHandlers(opts) {
@@ -201,10 +254,12 @@ function installSafetyHandlers(opts) {
201
254
  const onRejection = _makeUnhandledRejectionHandler(opts);
202
255
  process.on('uncaughtException', onException);
203
256
  process.on('unhandledRejection', onRejection);
257
+ const stdio = guardStdio();
204
258
  return {
205
259
  uninstall() {
206
260
  process.off('uncaughtException', onException);
207
261
  process.off('unhandledRejection', onRejection);
262
+ stdio.uninstall();
208
263
  },
209
264
  };
210
265
  }
@@ -235,6 +290,7 @@ module.exports = {
235
290
  claimPidFile,
236
291
  releasePidFile,
237
292
  installSafetyHandlers,
293
+ guardStdio,
238
294
  _makeUncaughtHandler,
239
295
  _makeUnhandledRejectionHandler,
240
296
  };
@@ -41,6 +41,33 @@ const CALLBACK_TO_EVENT = {
41
41
  onAssistantMessageStart: 'assistant-message-start',
42
42
  onAutonomousAssistantMessage: 'autonomous-assistant-message',
43
43
  onCompactBoundary: 'compact-boundary',
44
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
45
+ // 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
46
+ // context crosses the chat's threshold at turn-end, or (reactive) claude is
47
+ // auto-compacting now. The callback posts a chat message proposing /compact
48
+ // — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
49
+ onCompactionWarn: 'compaction-warn',
50
+ // 0.12.0 background-work visibility (Use 3). CliProcess emits 'bg-work-status'
51
+ // {state:'running'|'cleared', count?} when a detached background shell is first
52
+ // observed running idle past its turn, and again when it clears. The callback
53
+ // posts/edits a "⏳ working in background" status message so a long job reads as
54
+ // working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
55
+ onBgWorkStatus: 'bg-work-status',
56
+ // 0.12 interactive questions: CliProcess emits 'question-asked'
57
+ // {sessionKey, chatId, threadId, turnId, toolCallId, questions} when claude calls
58
+ // the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
59
+ // the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
60
+ onQuestionAsked: 'question-asked',
61
+ // 0.12.0 question-progress-resume: CliProcess emits 'question-resumed' (no payload) when a
62
+ // blocking `ask` resolves with a real answer and the turn resumes working. The callback
63
+ // re-arms the per-turn reactor (it cleared during the wait, no hooks re-lit it). See
64
+ // docs/0.12.0-question-resume-progress-spec.md.
65
+ onQuestionResumed: 'question-resumed',
66
+ // 0.13 D2: CliProcess emits 'input-dropped' {turnId, msgId, chatId, source}
67
+ // when a ledgered input was confirmed dropped (never seen/acked by cycle-end
68
+ // + confirm window, contract observed). polygram redelivers ONCE via the D4
69
+ // tail (lib/handlers/drop-redeliver.js).
70
+ onInputDropped: 'input-dropped',
44
71
  onQueueDrop: 'queue-drop',
45
72
  onThinking: 'thinking',
46
73
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -48,31 +75,15 @@ const CALLBACK_TO_EVENT = {
48
75
  // onApprovalRequired to route tmux prompts through the SAME
49
76
  // approval card UI used by SDK's canUseTool flow.
50
77
  onApprovalRequired: 'approval-required',
51
- // rc.7: tmux backend autosteer "NEW-TURN" extra reply. Fires when
52
- // the TUI's queue dequeued an autosteered paste as a fresh user
53
- // turn (because primary turn ended before fold could happen). The
54
- // payload is { msgId, text, sessionId, backend } and polygram
55
- // routes it to Telegram as a reply to the autosteered msgId. SDK
56
- // backend never emits this its PostToolBatch fold path
57
- // guarantees one combined reply.
58
- onExtraTurnReply: 'extra-turn-reply',
59
- // rc.9: pair-of with onExtraTurnReply. Fires the MOMENT TmuxProcess
60
- // sees the dequeued user-message in JSONL, which is the start of
61
- // turn 2 — gives polygram a hook to re-engage typing indicator and
62
- // re-apply the ✍ reaction on the autosteered msg (otherwise the
63
- // user sees a silent gap from when clearAutosteeredReactions fired
64
- // at primary turn 1 success until the extra-turn reply lands).
65
- // Payload: { msgId, sessionId, backend }.
66
- onExtraTurnStarted: 'extra-turn-started',
67
- // rc.11.1: observability — autosteer resolution + match-miss events.
68
- // - autosteer-resolution: { msgId, via: 'fold'|'new-turn' } —
69
- // confirms which JSONL signal resolved each autosteered msgId.
70
- // - autosteer-match-miss: { phase, *_head, pending_count } —
71
- // fires when JSONL has the queue/dequeue signal but content
72
- // doesn't match any pending. The signature of the rc.11.1 bug
73
- // (oneLine ' / ' vs newline mismatch) would have shown up here.
74
- onAutosteerResolution: 'autosteer-resolution',
75
- onAutosteerMatchMiss: 'autosteer-match-miss',
78
+ // 0.13 P4: the tmux-era rows (onExtraTurnReply/-Started, onAutosteerResolution/
79
+ // -MatchMiss) were removed zero emitters on any backend since the 0.12 tmux
80
+ // deletion; the 'autosteer-resolution' audit trail returns as D2 ledger events.
81
+ // 0.13 D3: 'turn-start' (UserPromptSubmit; payload {hasPending, anchorMsgId})
82
+ // and 'idle' get polygram-side consumers the session feedback controller's
83
+ // start/stop edges for cycles with no pending turn. ('idle' is ALSO wired
84
+ // internally for LRU waiters in _wireCallbacks — both fire.)
85
+ onTurnStart: 'turn-start',
86
+ onIdle: 'idle',
76
87
  // R8: tmux backend autosteer paste failure. TmuxProcess.injectUserMessage
77
88
  // fires `inject-fail` when its fire-and-forget paste rejects. Before
78
89
  // this was wired the event had no consumer — a failed autosteer was
@@ -220,7 +231,23 @@ class ProcessManager {
220
231
  // caller receives a proc whose start() has fully resolved.
221
232
  const pendingStart = this._starting.get(sessionKey);
222
233
  if (pendingStart) await pendingStart;
223
- return existing;
234
+ // Reload-on-drift (cli): a warm cli proc can't hot-swap model/effort
235
+ // (spawn-time flags). If the resolved config has drifted and the proc is
236
+ // idle, kill it (preserves session_id) and fall through to a cold respawn
237
+ // → --resume keeps the conversation, the new --model/--effort takes
238
+ // effect. In-flight cli procs and SDK procs (no wouldReloadFor — they
239
+ // apply model live) are reused unchanged.
240
+ if (typeof existing.wouldReloadFor === 'function' && existing.wouldReloadFor(spawnContext)) {
241
+ this._logEvent('cli-config-reload', {
242
+ sessionKey,
243
+ from_model: existing.model,
244
+ from_effort: existing.effort,
245
+ });
246
+ await this.kill(sessionKey, 'config-reload');
247
+ // fall through to the cold-spawn path below — respawns with --resume
248
+ } else {
249
+ return existing;
250
+ }
224
251
  }
225
252
 
226
253
  // Provisional new-process cost — ask the factory but don't start yet.
@@ -228,16 +255,36 @@ class ProcessManager {
228
255
  const newCost = newProc.cost;
229
256
 
230
257
  while (this.totalCost + newCost > this.budget) {
231
- const evicted = this._evictLRU();
232
- if (!evicted) {
233
- // All entries in-flightpark.
234
- await this._awaitLruSlot();
235
- if (this._shuttingDown) {
236
- try { await newProc.kill('shutdown'); } catch {}
237
- throw new Error('shutdown');
238
- }
239
- // Loop again — budget may have freed up.
258
+ const evicted = this._evictLRU(); // skips inFlight + background-job-pinned
259
+ if (evicted) continue;
260
+ // _evictLRU freed nothing. Policy C split by WHY:
261
+ if (this._hasPinnedSession()) {
262
+ // A DURABLE blocker (live background job) holds a slot. Don't park on it (could be
263
+ // ~an hour) and don't kill it. The budget caps RSS, not correctness — so treat it as
264
+ // SOFT: spawn over budget + warn; the operator reclaims by /reset-ing a chat.
265
+ const pinned = this._pinnedSessionKeys();
266
+ this._logEvent('lru-overflow-pinned', {
267
+ active: this.procs.size,
268
+ totalCost: this.totalCost,
269
+ budget: this.budget,
270
+ newCost,
271
+ pinned,
272
+ });
273
+ this.logger.warn?.(
274
+ `[pm] budget ${this.budget} exceeded (~${this.totalCost + newCost}): all free slots hold ` +
275
+ `live background jobs [${pinned.join(', ')}]. Spawning over limit — /reset one of those ` +
276
+ `chats to reclaim memory.`,
277
+ );
278
+ break; // soft overflow — spawn anyway
240
279
  }
280
+ // No pin — the blockers are all in-flight TURNS (transient, finish in seconds). Keep the
281
+ // existing behavior: park briefly for a slot rather than needlessly overflow.
282
+ await this._awaitLruSlot();
283
+ if (this._shuttingDown) {
284
+ try { await newProc.kill('shutdown'); } catch {}
285
+ throw new Error('shutdown');
286
+ }
287
+ // Loop again — budget may have freed up.
241
288
  }
242
289
 
243
290
  this._wireCallbacks(newProc);
@@ -261,8 +308,16 @@ class ProcessManager {
261
308
  _evictLRU() {
262
309
  let oldest = null;
263
310
  let oldestKey = null;
311
+ let pinnedSkipped = 0;
264
312
  for (const [k, p] of this.procs.entries()) {
265
313
  if (p.inFlight) continue;
314
+ // PIN: a session with a live detached background job is NOT evictable — killing it
315
+ // would silently drop the job (and its report-back wakeup). Skip like inFlight.
316
+ if (p.hasActiveBackgroundWork()) { pinnedSkipped++; continue; }
317
+ // PIN (0.13 D1, S9): a session blocked on an open interactive question is
318
+ // NOT evictable — the keyboard is live and claude is blocked on the ask;
319
+ // killing it silently strands both. Skip like inFlight.
320
+ if (typeof p.hasOpenQuestions === 'function' && p.hasOpenQuestions()) { pinnedSkipped++; continue; }
266
321
  if (!oldest || (p.lastUsedTs || 0) < (oldest.lastUsedTs || 0)) {
267
322
  oldest = p;
268
323
  oldestKey = k;
@@ -273,6 +328,7 @@ class ProcessManager {
273
328
  active: this.procs.size,
274
329
  totalCost: this.totalCost,
275
330
  budget: this.budget,
331
+ pinnedSkipped,
276
332
  });
277
333
  return false;
278
334
  }
@@ -280,12 +336,33 @@ class ProcessManager {
280
336
  session_key: oldestKey,
281
337
  cost: oldest.cost,
282
338
  backend: oldest.backend,
339
+ pinnedSkipped,
283
340
  });
284
341
  oldest.kill('evict').catch(() => {});
285
342
  this.procs.delete(oldestKey);
286
343
  return true;
287
344
  }
288
345
 
346
+ /**
347
+ * A DURABLE eviction blocker: a non-inFlight session holding a slot because it has a live
348
+ * background job (vs an inFlight TURN, which is transient and frees in seconds). Used to
349
+ * split park-vs-overflow when _evictLRU can free nothing.
350
+ */
351
+ _hasPinnedSession() {
352
+ for (const p of this.procs.values()) {
353
+ if (!p.inFlight && p.hasActiveBackgroundWork()) return true;
354
+ }
355
+ return false;
356
+ }
357
+
358
+ _pinnedSessionKeys() {
359
+ const keys = [];
360
+ for (const [k, p] of this.procs.entries()) {
361
+ if (!p.inFlight && p.hasActiveBackgroundWork()) keys.push(k);
362
+ }
363
+ return keys;
364
+ }
365
+
289
366
  async _awaitLruSlot() {
290
367
  return new Promise((resolve, reject) => {
291
368
  const timer = setTimeout(() => {
@@ -446,6 +523,14 @@ class ProcessManager {
446
523
  return p.injectUserMessage(opts);
447
524
  }
448
525
 
526
+ // 0.12 interactive questions: hand an answer back to a blocking `ask` tool call.
527
+ // Returns false if the session is gone (claude is dead → nothing to answer).
528
+ answerQuestion(sessionKey, toolCallId, result) {
529
+ const p = this.procs.get(sessionKey);
530
+ if (!p || p.closed || typeof p.writeQuestionAnswer !== 'function') return false;
531
+ return p.writeQuestionAnswer(toolCallId, result);
532
+ }
533
+
449
534
  steer(sessionKey, text, opts) {
450
535
  const p = this.procs.get(sessionKey);
451
536
  if (!p || p.closed) return false;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Interactive-question state machine + Telegram rendering (0.12 ask feature).
3
+ *
4
+ * Pure, I/O-free: given an `ask` tool call's questions and the accumulated state,
5
+ * produce the Telegram message body + inline keyboard for the CURRENT question,
6
+ * apply a button tap or a free-text reply, and assemble the final answer payload.
7
+ * All Telegram sends / DB writes / bridge writes live in the callers
8
+ * (lib/handlers/questions.js, callbacks.js, cli-process.js) — this module is the
9
+ * testable core.
10
+ *
11
+ * Design: docs/0.12.0-interactive-questions-design.md. Sequential, one question
12
+ * at a time (P1–P2: single-select, multiSelect, free-text "Other"). Body is
13
+ * rendered PLAIN-TEXT (no parse_mode) because option labels/descriptions are
14
+ * agent-authored and must not reach the Markdown→HTML pipeline (security finding).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const MAX_LABEL = 40; // Telegram button labels truncate ~64; keep well under
20
+ const MAX_OTHER = 1000; // cap on the user's free-text answer entering claude's context
21
+ const MAX_PREVIEW = 500;
22
+
23
+ function truncLabel(label) {
24
+ const s = String(label ?? '');
25
+ return s.length > MAX_LABEL ? s.slice(0, MAX_LABEL - 1) + '…' : s;
26
+ }
27
+
28
+ /** Initial state for an ask call's questions array. */
29
+ function initState(questions) {
30
+ return {
31
+ questions: Array.isArray(questions) ? questions : [],
32
+ qIndex: 0,
33
+ answers: [], // accumulated: [{ header, selected:[label...], other? }]
34
+ toggles: {}, // current multiSelect question: { '<optIndex>': true }
35
+ awaitingOther: false, // current question is in free-text capture mode
36
+ };
37
+ }
38
+
39
+ function currentQuestion(state) {
40
+ return state.questions[state.qIndex] || null;
41
+ }
42
+
43
+ function isDone(state) {
44
+ return state.qIndex >= state.questions.length;
45
+ }
46
+
47
+ /**
48
+ * Render the CURRENT question as { text, reply_markup }. callbackBase is the
49
+ * `q:<qid>:<token>` prefix; actions append `:opt:<i>` / `:submit` / `:other`.
50
+ * Returns null when the set is already done.
51
+ */
52
+ function renderCurrent(state, callbackBase) {
53
+ const q = currentQuestion(state);
54
+ if (!q) return null;
55
+ const multi = q.multiSelect === true;
56
+ const total = state.questions.length;
57
+ const opts = Array.isArray(q.options) ? q.options : [];
58
+
59
+ const lines = [];
60
+ if (total > 1) lines.push(`Question ${state.qIndex + 1} of ${total}`);
61
+ if (q.header) lines.push(String(q.header));
62
+ lines.push(String(q.question ?? ''));
63
+ lines.push('');
64
+ opts.forEach((o, i) => {
65
+ // Multi-select renders as a checklist (☐ unchecked / ☑️ checked) so it's legible as a
66
+ // tap-to-toggle-then-Submit control, NOT a single-select button. Single-select keeps `•`.
67
+ const mark = multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : '• ';
68
+ lines.push(`${mark}${truncLabel(o.label)}${o.description ? ` — ${o.description}` : ''}`);
69
+ if (o.preview) lines.push(String(o.preview).slice(0, MAX_PREVIEW));
70
+ });
71
+ if (multi) lines.push('\nTap to check/uncheck — pick one or more, then Submit.');
72
+
73
+ const rows = opts.map((o, i) => ([{
74
+ // The checkbox glyph on the BUTTON is the load-bearing affordance: an unchecked option
75
+ // must show ☐ (not a bare label) or it reads like a single-select tap-to-submit button.
76
+ text: `${multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : ''}${truncLabel(o.label)}`,
77
+ callback_data: `${callbackBase}:opt:${i}`,
78
+ }]));
79
+ if (multi) {
80
+ const any = Object.values(state.toggles).some(Boolean);
81
+ rows.push([{
82
+ text: any ? '✅ Submit' : '✅ Submit (pick at least one)',
83
+ callback_data: `${callbackBase}:submit`,
84
+ }]);
85
+ }
86
+ if (q.allowOther !== false) {
87
+ rows.push([{ text: '✏️ Type my own', callback_data: `${callbackBase}:other` }]);
88
+ }
89
+
90
+ return { text: lines.join('\n'), reply_markup: { inline_keyboard: rows } };
91
+ }
92
+
93
+ /** Parse the action suffix of a callback (everything after `q:<qid>:<token>:`). */
94
+ function parseAction(suffix) {
95
+ const s = String(suffix ?? '');
96
+ if (s === 'submit') return { type: 'submit' };
97
+ if (s === 'other') return { type: 'other' };
98
+ const m = s.match(/^opt:(\d+)$/);
99
+ if (m) return { type: 'opt', i: Number(m[1]) };
100
+ return { type: 'unknown' };
101
+ }
102
+
103
+ function recordAndAdvance(state, answer) {
104
+ const next = {
105
+ ...state,
106
+ answers: [...state.answers, answer],
107
+ qIndex: state.qIndex + 1,
108
+ toggles: {},
109
+ awaitingOther: false,
110
+ };
111
+ return next;
112
+ }
113
+
114
+ /**
115
+ * Apply a button tap. Returns:
116
+ * { state, kind:'toggled' } — multiSelect toggle, re-render
117
+ * { state, kind:'awaiting-other' } — user wants to type their own
118
+ * { state, kind:'reject', message } — invalid (e.g. submit with none)
119
+ * { state, kind:'advanced', receipt, done } — answer recorded; done if set complete
120
+ */
121
+ function applyTap(state, action) {
122
+ const q = currentQuestion(state);
123
+ if (!q) return { state, kind: 'reject', message: 'No active question.' };
124
+ const opts = Array.isArray(q.options) ? q.options : [];
125
+ const multi = q.multiSelect === true;
126
+
127
+ if (action.type === 'other') {
128
+ if (q.allowOther === false) return { state, kind: 'reject', message: 'Free text not allowed here.' };
129
+ return { state: { ...state, awaitingOther: true }, kind: 'awaiting-other' };
130
+ }
131
+
132
+ if (action.type === 'opt') {
133
+ if (action.i < 0 || action.i >= opts.length) {
134
+ return { state, kind: 'reject', message: 'Unknown option.' };
135
+ }
136
+ if (multi) {
137
+ const toggles = { ...state.toggles };
138
+ if (toggles[action.i]) delete toggles[action.i]; else toggles[action.i] = true;
139
+ return { state: { ...state, toggles }, kind: 'toggled' };
140
+ }
141
+ // single-select: record + advance
142
+ const label = opts[action.i].label;
143
+ const next = recordAndAdvance(state, { header: q.header, selected: [label] });
144
+ return { state: next, kind: 'advanced', receipt: label, done: isDone(next) };
145
+ }
146
+
147
+ if (action.type === 'submit') {
148
+ if (!multi) return { state, kind: 'reject', message: 'Nothing to submit.' };
149
+ const picked = Object.keys(state.toggles).filter(k => state.toggles[k]).map(Number).sort((a, b) => a - b);
150
+ if (picked.length === 0) return { state, kind: 'reject', message: 'Pick at least one option.' };
151
+ const labels = picked.map(i => opts[i].label);
152
+ const next = recordAndAdvance(state, { header: q.header, selected: labels });
153
+ return { state: next, kind: 'advanced', receipt: labels.join(', '), done: isDone(next) };
154
+ }
155
+
156
+ return { state, kind: 'reject', message: 'Unknown action.' };
157
+ }
158
+
159
+ /**
160
+ * Apply a free-text reply (the user's "Other" answer). Only valid when the
161
+ * current question is in awaitingOther mode. Returns the same advanced shape.
162
+ */
163
+ function applyFreeText(state, text) {
164
+ const q = currentQuestion(state);
165
+ if (!q || !state.awaitingOther) return { state, kind: 'reject', message: 'Not awaiting a typed answer.' };
166
+ const other = String(text ?? '').slice(0, MAX_OTHER);
167
+ const next = recordAndAdvance(state, { header: q.header, selected: [], other });
168
+ return { state: next, kind: 'advanced', receipt: other, done: isDone(next) };
169
+ }
170
+
171
+ /** Final tool result once the set is done. */
172
+ function assemble(state) {
173
+ return { answers: state.answers };
174
+ }
175
+
176
+ module.exports = {
177
+ initState,
178
+ currentQuestion,
179
+ isDone,
180
+ renderCurrent,
181
+ parseAction,
182
+ applyTap,
183
+ applyFreeText,
184
+ assemble,
185
+ MAX_LABEL,
186
+ MAX_OTHER,
187
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * pending_questions store — persistence for the 0.12 interactive-question flow.
3
+ *
4
+ * Mirrors lib/approvals/store.js: per-row 128-bit callback token, status
5
+ * lifecycle, audit-kept rows (never deleted; 'pending' at boot → 'expired').
6
+ * One OPEN question per session at a time. The answer routes back to claude on
7
+ * tool_call_id (a `question_answer` bridge message).
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { newToken, tokensEqual } = require('../approvals/store');
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;
20
+
21
+ function createQuestionStore(rawDb, now = () => Date.now()) {
22
+ const insertStmt = rawDb.prepare(`
23
+ INSERT INTO pending_questions (
24
+ bot_name, session_key, chat_id, thread_id, turn_id, tool_call_id,
25
+ callback_token, questions_json, state_json, created_ts, timeout_ts
26
+ ) VALUES (
27
+ @bot_name, @session_key, @chat_id, @thread_id, @turn_id, @tool_call_id,
28
+ @callback_token, @questions_json, @state_json, @created_ts, @timeout_ts
29
+ )
30
+ `);
31
+ const getByIdStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE id = ?`);
32
+ const getOpenForSessStmt = rawDb.prepare(`
33
+ SELECT * FROM pending_questions WHERE session_key = ? AND status = 'pending'
34
+ ORDER BY created_ts DESC LIMIT 1`);
35
+ const getByToolCallStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE tool_call_id = ? LIMIT 1`);
36
+ const setMsgIdsStmt = rawDb.prepare(`UPDATE pending_questions SET message_ids_json = ? WHERE id = ?`);
37
+ const updateStateStmt = rawDb.prepare(`
38
+ UPDATE pending_questions SET state_json = @state_json, awaiting_other = @awaiting_other
39
+ WHERE id = @id AND status = 'pending'`);
40
+ const claimStmt = rawDb.prepare(`
41
+ UPDATE pending_questions SET from_id = @from_id
42
+ WHERE id = @id AND from_id IS NULL AND status = 'pending'`);
43
+ const resolveStmt = rawDb.prepare(`
44
+ UPDATE pending_questions SET status = @status, answered_ts = @answered_ts
45
+ WHERE id = @id AND status = 'pending'`);
46
+ const listTimedOutStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE status = 'pending' AND timeout_ts < ?`);
47
+ const listOpenStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE bot_name = ? AND status = 'pending'`);
48
+
49
+ return {
50
+ issue({ bot_name, session_key, chat_id, thread_id = null, turn_id = null, tool_call_id, questions, state, timeoutMs = DEFAULT_TIMEOUT_MS }) {
51
+ if (!bot_name || !session_key || !chat_id || !tool_call_id) {
52
+ throw new Error('issue: bot_name, session_key, chat_id, tool_call_id required');
53
+ }
54
+ const created_ts = now();
55
+ const res = insertStmt.run({
56
+ bot_name,
57
+ session_key,
58
+ chat_id: String(chat_id),
59
+ thread_id: thread_id != null ? String(thread_id) : null,
60
+ turn_id,
61
+ tool_call_id,
62
+ callback_token: newToken(),
63
+ questions_json: JSON.stringify(questions ?? []),
64
+ state_json: JSON.stringify(state ?? {}),
65
+ created_ts,
66
+ timeout_ts: created_ts + timeoutMs,
67
+ });
68
+ return getByIdStmt.get(res.lastInsertRowid);
69
+ },
70
+
71
+ getById(id) { return getByIdStmt.get(id); },
72
+ getOpenForSession(session_key) { return getOpenForSessStmt.get(session_key); },
73
+ getByToolCallId(tool_call_id) { return getByToolCallStmt.get(tool_call_id); },
74
+ setMessageIds(id, ids) { return setMsgIdsStmt.run(JSON.stringify(ids ?? []), id).changes; },
75
+
76
+ updateState(id, state, awaitingOther = false) {
77
+ return updateStateStmt.run({ id, state_json: JSON.stringify(state ?? {}), awaiting_other: awaitingOther ? 1 : 0 }).changes;
78
+ },
79
+
80
+ /**
81
+ * Authorize a responder. Claim-on-first-tap: if no from_id is recorded yet,
82
+ * the first interacting user claims the question; thereafter only that user
83
+ * may answer. Returns { ok, claimed }.
84
+ */
85
+ claimOrCheck(id, from_id) {
86
+ if (from_id == null) return { ok: false, claimed: false };
87
+ const claimed = claimStmt.run({ id, from_id }).changes > 0;
88
+ if (claimed) return { ok: true, claimed: true };
89
+ const row = getByIdStmt.get(id);
90
+ return { ok: row && Number(row.from_id) === Number(from_id), claimed: false };
91
+ },
92
+
93
+ resolve(id, status) {
94
+ if (!['answered', 'cancelled', 'timeout', 'expired'].includes(status)) {
95
+ throw new Error(`bad status: ${status}`);
96
+ }
97
+ return resolveStmt.run({ id, status, answered_ts: now() }).changes;
98
+ },
99
+
100
+ sweepTimedOut() { return listTimedOutStmt.all(now()); },
101
+ listOpen(bot_name) { return listOpenStmt.all(bot_name); },
102
+ };
103
+ }
104
+
105
+ module.exports = { createQuestionStore, tokensEqual, DEFAULT_TIMEOUT_MS };