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.
- package/config.example.json +3 -1
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1277 -70
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +224 -78
|
@@ -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
|
/**
|
package/lib/process/process.js
CHANGED
|
@@ -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.
|
package/lib/process-guard.js
CHANGED
|
@@ -193,7 +193,60 @@ function _makeUnhandledRejectionHandler(opts) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
*
|
|
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
|
};
|
package/lib/process-manager.js
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 };
|