polygram 0.17.4 → 0.17.7
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.
|
@@ -69,6 +69,22 @@ function createSessionFeedback({
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Stop the cycle's TYPING the moment it delivers its answer, decoupled from
|
|
73
|
+
// endCycle. endCycle fires on the Process 'idle' edge (= session idle), which a
|
|
74
|
+
// later turn delays — so without this the "typing…" indicator spun minutes past the
|
|
75
|
+
// delivered answer (field: Ivan DM 2026-06-26). The entry is LEFT in place so
|
|
76
|
+
// endCycle still clears the anchor 🤔 and tears the entry down. docs/typing-tracks-activity-spec.md
|
|
77
|
+
function stopCycleTyping(sessionKey) {
|
|
78
|
+
const entry = active.get(sessionKey);
|
|
79
|
+
if (!entry || entry.typingStopped) return;
|
|
80
|
+
entry.typingStopped = true;
|
|
81
|
+
try { entry.stop(); } catch { /* best-effort */ }
|
|
82
|
+
logEvent('autonomous-cycle-visuals', {
|
|
83
|
+
chat_id: entry.anchor?.chatId ?? getChatIdFromKey(sessionKey),
|
|
84
|
+
session_key: sessionKey, state: 'typing-stopped',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
function endCycle(sessionKey) {
|
|
73
89
|
const entry = active.get(sessionKey);
|
|
74
90
|
if (!entry) return;
|
|
@@ -85,7 +101,7 @@ function createSessionFeedback({
|
|
|
85
101
|
});
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
return { startAutonomousCycle, endCycle };
|
|
104
|
+
return { startAutonomousCycle, stopCycleTyping, endCycle };
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
module.exports = { createSessionFeedback };
|
|
@@ -133,8 +133,24 @@ function createDispatcher({
|
|
|
133
133
|
throw new Error('auto-resume turn produced no text');
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// 4.
|
|
137
|
-
//
|
|
136
|
+
// 4. Deliver the continuation reply — UNLESS the resumed turn already
|
|
137
|
+
// delivered it itself. On the channels/cli backend Claude responds via the
|
|
138
|
+
// reply tool DURING the turn, so result.alreadyDelivered is set and the main
|
|
139
|
+
// dispatch path short-circuits its own deliver (cli-process.js ~2116). The
|
|
140
|
+
// resume path must honor it too, or the reply-tool send + this re-send
|
|
141
|
+
// double-post the SAME answer (field: shumabit@umi WhatsApp topic 2026-06-27,
|
|
142
|
+
// a bridge-disconnect resume sent "Fixed. ✅…" twice). SDK / genuine no-reply
|
|
143
|
+
// turns leave it falsy → deliver as before.
|
|
144
|
+
if (result.alreadyDelivered) {
|
|
145
|
+
logEvent('auto-resume-already-delivered', {
|
|
146
|
+
chat_id: chatId, session_key: sessionKey, msg_id: originalMsg.message_id,
|
|
147
|
+
text_len: result.text.length,
|
|
148
|
+
});
|
|
149
|
+
return result.text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Send the continuation reply as regular Telegram messages, threaded under
|
|
153
|
+
// the original user message.
|
|
138
154
|
const chunks = chunkMarkdownText(result.text, chunkBudget);
|
|
139
155
|
await deliverReplies({
|
|
140
156
|
bot,
|
|
@@ -119,11 +119,19 @@ function resolveToolAck(toolCallId, ok, error, messageId) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
|
|
122
|
-
// Separate from tool_ack: a question
|
|
123
|
-
//
|
|
124
|
-
//
|
|
122
|
+
// Separate from tool_ack: a question waits for the user, possibly for hours. The
|
|
123
|
+
// DAEMON owns the lifecycle — it resolves the ask with the user's answer, or sweeps
|
|
124
|
+
// it {timedout} at its configured question timeout (POLYGRAM_QUESTION_TIMEOUT_MS,
|
|
125
|
+
// default 24h). This local timer is ONLY a last-resort backstop for the narrow case
|
|
126
|
+
// where the daemon stays connected but never calls back; it sits a margin ABOVE the
|
|
127
|
+
// daemon timeout so the daemon always resolves first (with the proper user-facing
|
|
128
|
+
// message). It must track the daemon value — a hardcoded 32min here once fired long
|
|
129
|
+
// before the 24h wait, resolving {timedout} on a question the user answered an hour
|
|
130
|
+
// later (0.17.5).
|
|
125
131
|
const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
|
|
126
|
-
const
|
|
132
|
+
const QUESTION_BACKSTOP_MARGIN_MS = 5 * 60 * 1000
|
|
133
|
+
const DAEMON_QUESTION_TIMEOUT_MS = Number(process.env.POLYGRAM_QUESTION_TIMEOUT_MS) || (24 * 60 * 60 * 1000)
|
|
134
|
+
const QUESTION_ANSWER_TIMEOUT_MS = DAEMON_QUESTION_TIMEOUT_MS + QUESTION_BACKSTOP_MARGIN_MS
|
|
127
135
|
|
|
128
136
|
function awaitQuestionAnswer(toolCallId) {
|
|
129
137
|
return new Promise((resolve) => {
|
|
@@ -48,6 +48,10 @@ const { Process, UnsupportedOperationError } = require('./process');
|
|
|
48
48
|
const { ChannelsBridgeServer } = require('./channels-bridge-server');
|
|
49
49
|
const { writeHookFiles, removeHookFiles } = require('./hook-settings');
|
|
50
50
|
const { createHookTail } = require('./hook-event-tail');
|
|
51
|
+
// Single source of truth for the question wait: the daemon owns the question
|
|
52
|
+
// lifecycle (answer or {timedout} sweep), and we pass this to the bridge so its
|
|
53
|
+
// last-resort `ask` backstop sits ABOVE it instead of undercutting it.
|
|
54
|
+
const { DEFAULT_TIMEOUT_MS: QUESTION_TIMEOUT_MS } = require('../questions/store');
|
|
51
55
|
// File-send staging: reuse the dispatcher's allowlist root so the dir we
|
|
52
56
|
// create exactly matches the realpath the validator accepts (no /tmp vs
|
|
53
57
|
// /private/tmp drift — one of the original Music-topic failures).
|
|
@@ -343,6 +347,9 @@ class CliProcess extends Process {
|
|
|
343
347
|
// is the broader surface (hooks + pane heartbeat + bridge tool calls).
|
|
344
348
|
this._lastHookEventAt = 0;
|
|
345
349
|
this._lastActivityAt = 0;
|
|
350
|
+
// Monotonic count of work hooks (all but the terminal Stop) — the rung-2
|
|
351
|
+
// no-reply backstop snapshots it at Stop capture to detect a later resume.
|
|
352
|
+
this._workHookSeq = 0;
|
|
346
353
|
// 0.13 D2: the InputLedger — every user-shaped input written to the bridge
|
|
347
354
|
// gets an observable lifecycle: written → seen → resolved | dropped |
|
|
348
355
|
// superseded | fold-suspected. Pre-P3, injectUserMessage minted a turn_id
|
|
@@ -553,13 +560,25 @@ class CliProcess extends Process {
|
|
|
553
560
|
await this.bridgeServer.listen();
|
|
554
561
|
}
|
|
555
562
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Env for the spawned channels-bridge MCP subprocess. POLYGRAM_QUESTION_TIMEOUT_MS
|
|
565
|
+
* tells the bridge our question wait so its last-resort `ask` backstop sits ABOVE
|
|
566
|
+
* it — without it the bridge fell back to a hardcoded 32min that fired long before
|
|
567
|
+
* the daemon's 24h wait, so a question the user answered an hour later was already
|
|
568
|
+
* resolved {timedout}. Extracted (pure) so the alignment is unit-testable.
|
|
569
|
+
*/
|
|
570
|
+
_bridgeEnv() {
|
|
571
|
+
return {
|
|
572
|
+
POLYGRAM_SESSION_KEY: this.sessionKey,
|
|
573
|
+
POLYGRAM_SOCK: this.sockPath,
|
|
574
|
+
POLYGRAM_SOCK_SECRET: this.sockSecret,
|
|
575
|
+
POLYGRAM_CLAUDE_SESSION_ID: this.claudeSessionId,
|
|
576
|
+
POLYGRAM_QUESTION_TIMEOUT_MS: String(QUESTION_TIMEOUT_MS),
|
|
562
577
|
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async _spawnTmuxClaude({ tmuxName, opts }) {
|
|
581
|
+
const bridgeEnv = this._bridgeEnv();
|
|
563
582
|
const mcpConfig = {
|
|
564
583
|
mcpServers: {
|
|
565
584
|
'polygram-bridge': {
|
|
@@ -1764,20 +1783,41 @@ class CliProcess extends Process {
|
|
|
1764
1783
|
}
|
|
1765
1784
|
}
|
|
1766
1785
|
|
|
1786
|
+
/**
|
|
1787
|
+
* Is this turn eligible for the rung-2 activity-quiet finalize? Eligible when the
|
|
1788
|
+
* answer is already captured where a finalize can deliver it:
|
|
1789
|
+
* - a delivered FINAL reply (it went out incrementally), OR
|
|
1790
|
+
* - seen + consumed-acked (the answer rode a sibling turn_id — fold-id echo;
|
|
1791
|
+
* see _ledgerAckConsumed), OR
|
|
1792
|
+
* - an attributed Stop captured the answer AND no work hook has fired since
|
|
1793
|
+
* (_workHookSeq unchanged from the capture) — i.e. claude is genuinely done,
|
|
1794
|
+
* not resumed into more work. A reply-less turn's only finalizer is its Stop grace;
|
|
1795
|
+
* when a pane-thinking heartbeat cancels that grace (the turn's own residual
|
|
1796
|
+
* "esc to interrupt"), this is the backstop that still delivers the captured
|
|
1797
|
+
* last_assistant_message instead of orphaning to the idle ceiling. The
|
|
1798
|
+
* hook-recency check withdraws eligibility the moment claude resumes (a resume
|
|
1799
|
+
* emits PreToolUse/etc. that increments _workHookSeq past the capture), so a
|
|
1800
|
+
* stale early Stop can't finalize over a still-working turn — that also covers
|
|
1801
|
+
* an in-flight sub-agent, which emits work hooks after any boundary Stop.
|
|
1802
|
+
* An interim-only turn with no captured answer stays ineligible (it must keep working).
|
|
1803
|
+
*/
|
|
1804
|
+
_activityQuietEligible(pending) {
|
|
1805
|
+
if (this._turnHasFinalReply(pending)) return true;
|
|
1806
|
+
if (pending.seen === true && pending._consumedAcked === true) return true;
|
|
1807
|
+
if (pending._stopHookData
|
|
1808
|
+
&& (this._workHookSeq || 0) === (pending._stopHookDataSeq || 0)) return true;
|
|
1809
|
+
return false;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1767
1812
|
/**
|
|
1768
1813
|
* D1 rung 2: arm/refresh the activity-quiet finalize for one pending.
|
|
1769
|
-
* Preconditions: hooks live,
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1814
|
+
* Preconditions: hooks live, the answer is captured (see _activityQuietEligible),
|
|
1815
|
+
* no open question (waiting-on-user suspends the clock — claude is legitimately
|
|
1816
|
+
* silent), and no rung-1 grace in flight.
|
|
1772
1817
|
*/
|
|
1773
1818
|
_armActivityQuiet(turnId, pending) {
|
|
1774
1819
|
if (!this._sawHookStream) return;
|
|
1775
|
-
|
|
1776
|
-
// fold-id echo; see _ledgerAckConsumed). Same eligibility as the fire site. An
|
|
1777
|
-
// interim-only turn (status promise, no final reply) is NOT eligible — it must
|
|
1778
|
-
// keep working, not quiet-finalize as done. docs/progress-is-not-turn-end-spec.md
|
|
1779
|
-
if (!this._turnHasFinalReply(pending)
|
|
1780
|
-
&& !(pending.seen === true && pending._consumedAcked === true)) return;
|
|
1820
|
+
if (!this._activityQuietEligible(pending)) return;
|
|
1781
1821
|
if (this._openQuestions.size > 0) return;
|
|
1782
1822
|
if (pending._stopGracePending) return;
|
|
1783
1823
|
if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
|
|
@@ -1797,20 +1837,19 @@ class CliProcess extends Process {
|
|
|
1797
1837
|
|
|
1798
1838
|
/**
|
|
1799
1839
|
* D1 rung 2 fire: the whole activity surface (hooks + pane heartbeat + bridge
|
|
1800
|
-
* tool calls) has been quiet for activityQuietMs
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1840
|
+
* tool calls) has been quiet for activityQuietMs and the answer is captured (a
|
|
1841
|
+
* delivered reply, a consumed-ack, or an attributed Stop — see
|
|
1842
|
+
* _activityQuietEligible). The tail is over (Stop was lost, foreign, the hook
|
|
1843
|
+
* stream died mid-session, or — the no-reply case — the Stop grace was cancelled
|
|
1844
|
+
* by a pane-thinking heartbeat racing the Stop's own residual streaming hint).
|
|
1804
1845
|
*/
|
|
1805
1846
|
_activityQuietFinalize(turnId) {
|
|
1806
1847
|
const pending = this.pendingTurns.get(turnId);
|
|
1807
1848
|
if (!pending) return;
|
|
1808
1849
|
if (pending._stopGracePending) return;
|
|
1809
1850
|
if (this._openQuestions.size > 0) return; // re-check at fire time
|
|
1810
|
-
|
|
1811
|
-
// out under a sibling turn_id — fold-id echo; see _ledgerAckConsumed).
|
|
1851
|
+
if (!this._activityQuietEligible(pending)) return;
|
|
1812
1852
|
const consumedAcked = pending.seen === true && pending._consumedAcked === true;
|
|
1813
|
-
if (!this._turnHasFinalReply(pending) && !consumedAcked) return;
|
|
1814
1853
|
const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
|
|
1815
1854
|
this._logEvent('cli-activity-quiet-finalize', {
|
|
1816
1855
|
turn_id: turnId,
|
|
@@ -1819,6 +1858,16 @@ class CliProcess extends Process {
|
|
|
1819
1858
|
last_hook_age_ms: lastHookAgeMs,
|
|
1820
1859
|
had_stop: !!pending._stopHookData,
|
|
1821
1860
|
});
|
|
1861
|
+
// The no-reply rescue: a reply-less, not-consumed-acked turn finalizing here
|
|
1862
|
+
// qualified ONLY via its captured Stop — i.e. it would have orphaned to the idle
|
|
1863
|
+
// ceiling before this backstop existed. Distinct event so the soak can count it.
|
|
1864
|
+
if (!this._turnHasFinalReply(pending) && !consumedAcked) {
|
|
1865
|
+
this._logEvent('cli-noreply-stop-rescued', {
|
|
1866
|
+
turn_id: turnId,
|
|
1867
|
+
last_hook_age_ms: lastHookAgeMs,
|
|
1868
|
+
text_len: (pending._stopHookData?.lastAssistantMessage || '').length,
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1822
1871
|
if (lastHookAgeMs != null && lastHookAgeMs >= this.activityQuietMs) {
|
|
1823
1872
|
// A previously-live hook stream went quiet enough that rung 2 (not an
|
|
1824
1873
|
// attributed Stop) ended the turn — the soak's mid-session-death signal.
|
|
@@ -1827,13 +1876,25 @@ class CliProcess extends Process {
|
|
|
1827
1876
|
this._finalizeTurn(turnId);
|
|
1828
1877
|
}
|
|
1829
1878
|
|
|
1879
|
+
/**
|
|
1880
|
+
* Capture a Stop hook's data on a pending, recording the work-hook count AT capture.
|
|
1881
|
+
* The rung-2 no-reply backstop (_activityQuietEligible) compares the live _workHookSeq
|
|
1882
|
+
* against this snapshot to tell "claude is done" (no work hook since the Stop) from
|
|
1883
|
+
* "claude resumed" (a later work hook bumped the count). A monotonic counter — not a
|
|
1884
|
+
* timestamp — so a Stop and a resume hook landing in the same millisecond still differ.
|
|
1885
|
+
*/
|
|
1886
|
+
_captureStopHookData(pending, info) {
|
|
1887
|
+
pending._stopHookData = info;
|
|
1888
|
+
pending._stopHookDataSeq = this._workHookSeq || 0;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1830
1891
|
/**
|
|
1831
1892
|
* D1 rung 1: an attributed Stop (the pending was `seen` at pickup, or has
|
|
1832
1893
|
* ≥1 turn_id-bound reply) finalizes through a short grace that any
|
|
1833
1894
|
* subsequent same-session activity cancels (see _noteActivity #2).
|
|
1834
1895
|
*/
|
|
1835
1896
|
_beginAttributedStopGrace(turnId, pending, info) {
|
|
1836
|
-
pending
|
|
1897
|
+
this._captureStopHookData(pending, info);
|
|
1837
1898
|
pending._stopGracePending = true;
|
|
1838
1899
|
if (pending._activityQuietTimer) {
|
|
1839
1900
|
clearTimeout(pending._activityQuietTimer);
|
|
@@ -1932,7 +1993,7 @@ class CliProcess extends Process {
|
|
|
1932
1993
|
let graceCount = 0;
|
|
1933
1994
|
for (const p of this.pendingTurns.values()) if (p._stopGracePending) graceCount++;
|
|
1934
1995
|
if (graceCount !== 1) return;
|
|
1935
|
-
pending
|
|
1996
|
+
this._captureStopHookData(pending, info);
|
|
1936
1997
|
clearTimeout(pending._stopGraceTimer);
|
|
1937
1998
|
pending._stopGraceTimer = null;
|
|
1938
1999
|
finalize();
|
|
@@ -2876,6 +2937,10 @@ class CliProcess extends Process {
|
|
|
2876
2937
|
this._lastHookEventAt = Date.now();
|
|
2877
2938
|
} else if (ev.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
|
|
2878
2939
|
this._lastHookEventAt = Date.now();
|
|
2940
|
+
// Monotonic count of WORK hooks (everything but the terminal Stop). The rung-2
|
|
2941
|
+
// no-reply backstop snapshots this at Stop capture; a later increment means
|
|
2942
|
+
// claude resumed work, withdrawing the stale Stop's finalize eligibility.
|
|
2943
|
+
this._workHookSeq = (this._workHookSeq || 0) + 1;
|
|
2879
2944
|
this._noteActivity(`hook:${ev.type}`);
|
|
2880
2945
|
}
|
|
2881
2946
|
|
|
@@ -3058,7 +3123,7 @@ class CliProcess extends Process {
|
|
|
3058
3123
|
// sub-agent: refresh the captured last_assistant_message so the
|
|
3059
3124
|
// eventual finalize delivers the LATEST produced answer (claude's real
|
|
3060
3125
|
// end-of-work text), not the boundary Stop's stale/partial text.
|
|
3061
|
-
p
|
|
3126
|
+
this._captureStopHookData(p, info);
|
|
3062
3127
|
}
|
|
3063
3128
|
} else if (this.pendingTurns.size > 1) {
|
|
3064
3129
|
// Can't attribute Stop to one of several concurrent turns — surface
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -216,6 +216,10 @@ function createSdkCallbacks({
|
|
|
216
216
|
const text = (msg && typeof msg.text === 'string' && msg.text)
|
|
217
217
|
|| extractAssistantText(msg);
|
|
218
218
|
if (!text) return;
|
|
219
|
+
// 0.13 D3 fix: the cycle delivered its answer → stop its "typing…" NOW. endCycle
|
|
220
|
+
// (Process idle = SESSION idle) is delayed by any later turn, so typing otherwise
|
|
221
|
+
// spins minutes past the delivered answer. docs/typing-tracks-activity-spec.md
|
|
222
|
+
sessionFeedback?.stopCycleTyping?.(sessionKey);
|
|
219
223
|
const chatId = getChatIdFromKey(sessionKey);
|
|
220
224
|
const threadIdRaw = getThreadIdFromKey(sessionKey);
|
|
221
225
|
const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
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": {
|