polygram 0.10.0-rc.32 → 0.10.0-rc.34

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.10.0-rc.32",
4
+ "version": "0.10.0-rc.34",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -726,13 +726,19 @@ class TmuxProcess extends Process {
726
726
  // ~1-2KB → the claude TUI collapses it into a `[Pasted text #N]`
727
727
  // placeholder whose single post-paste Enter can be absorbed
728
728
  // mid-ingest, leaving the prompt UNSUBMITTED — the turn never
729
- // starts. `_confirmSubmitViaJsonl` confirms the submit landed by
729
+ // starts. `_scheduleSubmitRetries` confirms the submit landed by
730
730
  // waiting for this paste's correlation token to surface in a
731
731
  // JSONL `user-message` (the ONLY reliable signal — capture-pane
732
732
  // false-positives on the collapsed placeholder); it re-sends
733
733
  // Enter on a miss and, after bounded retries, REJECTS with
734
734
  // TMUX_SUBMIT_FAILED.
735
735
  //
736
+ // 0.10.0 Commit 2: `_scheduleSubmitRetries` is `paste-parked`-
737
+ // aware. If the predicate observed our paste queued by a busy
738
+ // TUI (the C1 trace), it waits for the eventual user-message
739
+ // instead of re-sending Enter / failing loud. See the method
740
+ // doc.
741
+ //
736
742
  // The confirm drives TWO derived promises below:
737
743
  // submitConfirmP — rejects TMUX_SUBMIT_FAILED → a racer that
738
744
  // fails the turn fast and loud; on success it never settles.
@@ -743,8 +749,10 @@ class TmuxProcess extends Process {
743
749
  // prompt sitting unsubmitted reads as an idle (=="complete")
744
750
  // pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
745
751
  // and mask the real TMUX_SUBMIT_FAILED cause.
752
+ // (submitConfirmP / submitOkP plumbing is retired in Commit 3's
753
+ // _runTurn race rewrite — kept here so Commit 2 stays surgical.)
746
754
  const confirmP = turn.token
747
- ? this._confirmSubmitViaJsonl(turn.token, turn)
755
+ ? this._scheduleSubmitRetries(turn.token, turn)
748
756
  : Promise.resolve(); // no token — nothing to confirm
749
757
  const submitConfirmP = confirmP.then(() => new Promise(() => {}));
750
758
  const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
@@ -1021,6 +1029,13 @@ class TmuxProcess extends Process {
1021
1029
  // without a terminal `result` (e.g. turnTimeoutMs) cannot leak
1022
1030
  // its buffered message into turn N+1.
1023
1031
  this._sessionLogTail?.flushParser?.();
1032
+ // Commit 2: clear any lingering submit-confirm waiter for this
1033
+ // turn's token. The parked branch of `_scheduleSubmitRetries`
1034
+ // races the turn's own settle promises so it normally self-cleans,
1035
+ // but a turn that ends via the hard W1 deadline (turnDeadlineP
1036
+ // rejects in `_runTurn`, never resolving `resultPromise`) would
1037
+ // otherwise leave a dangling Map entry. Defensive + cheap.
1038
+ if (turn?.token) this._submitConfirms.delete(turn.token);
1024
1039
  const qi = this.pendingQueue.indexOf(turn);
1025
1040
  if (qi >= 0) this.pendingQueue.splice(qi, 1);
1026
1041
  this._dropFromActiveGroup(turn);
@@ -1674,7 +1689,7 @@ class TmuxProcess extends Process {
1674
1689
  this._confirmPaste(tokens);
1675
1690
  // B7: a user-message is the proof that a primary paste actually
1676
1691
  // STARTED a turn (claude registered the prompt). Release any
1677
- // _confirmSubmitViaJsonl waiter for these tokens.
1692
+ // _scheduleSubmitRetries waiter for these tokens.
1678
1693
  this._confirmSubmit(tokens);
1679
1694
  let matched = [];
1680
1695
  for (const tok of tokens) {
@@ -1829,7 +1844,7 @@ class TmuxProcess extends Process {
1829
1844
  * TUI into a `[Pasted text #N]` placeholder whose single post-paste
1830
1845
  * Enter can be absorbed mid-ingest, leaving the prompt unsubmitted —
1831
1846
  * but that submit-confirmation runs as a concurrent racer in
1832
- * `_runTurn` (`_confirmSubmitViaJsonl`), NOT here. Blocking
1847
+ * `_runTurn` (`_scheduleSubmitRetries`), NOT here. Blocking
1833
1848
  * `_pasteAndEnter` on the confirm would hold `_pasteLock` across the
1834
1849
  * whole confirm window and stall every following paste — an autosteer
1835
1850
  * that should fold into the primary turn could never paste.
@@ -1843,7 +1858,7 @@ class TmuxProcess extends Process {
1843
1858
  // (it false-positived on `[Pasted text #N]`). The runner just
1844
1859
  // pastes + Enter. Submit confirmation for a PRIMARY turn is
1845
1860
  // JSONL-token-based and runs as a CONCURRENT racer in `_runTurn`
1846
- // (`_confirmSubmitViaJsonl`) — NOT here. Blocking `_pasteAndEnter`
1861
+ // (`_scheduleSubmitRetries`) — NOT here. Blocking `_pasteAndEnter`
1847
1862
  // on the confirm would hold `_pasteLock` across the whole confirm
1848
1863
  // window and stall every following paste (an autosteer that
1849
1864
  // SHOULD fold into the primary turn could never paste). The
@@ -1869,17 +1884,33 @@ class TmuxProcess extends Process {
1869
1884
  }
1870
1885
 
1871
1886
  /**
1872
- * B7: confirm a primary paste actually submitted by waiting for its
1887
+ * Confirm a primary paste actually submitted by waiting for its
1873
1888
  * correlation `token` to surface in a JSONL `user-message`. On each
1874
1889
  * miss, re-send Enter (the prior Enter was absorbed by the TUI's
1875
1890
  * bracketed-paste ingest of a `[Pasted text #N]` block). After
1876
1891
  * `submitConfirmRetries` exhausted misses, throw `TMUX_SUBMIT_FAILED`.
1877
1892
  *
1878
- * The signal is the `user-message` ONLY not `queue-operation`. A
1879
- * `queue-operation enqueue` means the paste was parked in the TUI
1880
- * queue, which for a primary paste into an idle TUI would itself be
1881
- * wrong; the genuine "the prompt started a turn" proof is the
1882
- * `user-message` claude writes when it registers the prompt.
1893
+ * 0.10.0 Commit 2 `paste-parked`-aware (the C1 fix). The B7
1894
+ * predecessor (`_confirmSubmitViaJsonl`) re-sent Enter on every miss
1895
+ * and failed loud after 5, with NO way to tell "the Enter was
1896
+ * absorbed, the prompt is stuck" (genuine submit failure) apart from
1897
+ * "the TUI was busy and legitimately PARKED the paste in its queue"
1898
+ * (a paste that WILL submit when the prior turn finishes). The
1899
+ * 2026-05-20 C1 trace was the latter failing loud: a paste the TUI
1900
+ * queued got 5 spurious Enter re-sends then `TMUX_SUBMIT_FAILED`.
1901
+ *
1902
+ * The turn-phase predicate now distinguishes them: a
1903
+ * `queue-operation enqueue` carrying THIS turn's `corr-id` (or the
1904
+ * `Press up to edit queued messages` capture-pane fallback) sets
1905
+ * `turn.parked = true`. Once parked:
1906
+ * - STOP re-sending Enter — the paste is in the TUI queue; another
1907
+ * Enter could submit a DIFFERENT queued item or double-submit.
1908
+ * - Do NOT fail loud — the turn is legitimately in flight.
1909
+ * - Wait (unbounded here) for the eventual `user-message`. The
1910
+ * `_runTurn` turn deadline (W1) is the only floor; a paste that
1911
+ * is truly never released fails as `TMUX_TURN_TIMEOUT` (correct
1912
+ * attribution — the wedged thing is the prior turn, not our
1913
+ * submission), not `TMUX_SUBMIT_FAILED`.
1883
1914
  *
1884
1915
  * Runs as a concurrent racer in `_runTurn` (NOT a blocking gate in
1885
1916
  * `_pasteAndEnter` — that would hold `_pasteLock` across the confirm
@@ -1888,17 +1919,37 @@ class TmuxProcess extends Process {
1888
1919
  * racer already won, or the turn was killed) the retry loop bails so
1889
1920
  * a stray retry Enter cannot land in an unrelated turn.
1890
1921
  */
1891
- async _confirmSubmitViaJsonl(token, turn = null) {
1922
+ async _scheduleSubmitRetries(token, turn = null) {
1892
1923
  for (let attempt = 0; attempt <= this.submitConfirmRetries; attempt += 1) {
1924
+ // C1: parked → the paste is safely queued in the TUI. Wait for
1925
+ // the eventual user-message; never re-send Enter, never fail
1926
+ // loud. Checked at the TOP so a paste parked before the first
1927
+ // confirm-wait skips the wait entirely.
1928
+ if (turn && turn.parked) {
1929
+ this.emit('submit-parked', {
1930
+ token,
1931
+ turnId: turn.turnId,
1932
+ attempt,
1933
+ sessionId: this.claudeSessionId,
1934
+ backend: 'tmux',
1935
+ });
1936
+ await this._awaitSubmitOrTerminal(token, turn);
1937
+ return;
1938
+ }
1893
1939
  const confirmed = await this._awaitSubmitConfirm(token);
1894
1940
  if (confirmed) return; // submitted ✓
1895
1941
  // The turn already settled some other way (result/capture/kill)
1896
1942
  // — the submit clearly is no longer the open question. Stop:
1897
1943
  // re-sending Enter or throwing now would be wrong.
1898
1944
  if (turn && (turn.state === 'done' || turn.state === 'failed')) return;
1945
+ // The enqueue may have landed DURING the submitConfirmMs wait —
1946
+ // re-check before deciding to re-send Enter. The loop top then
1947
+ // handles the parked branch.
1948
+ if (turn && turn.parked) continue;
1899
1949
  if (attempt === this.submitConfirmRetries) break; // out of retries
1900
- // The tokened user-message never arrived the prompt is still
1901
- // sitting in the input box as `[Pasted text #N]`. Re-send Enter.
1950
+ // The tokened user-message never arrived AND the paste was not
1951
+ // parked — the prompt is still sitting in the input box as
1952
+ // `[Pasted text #N]`. Re-send Enter.
1902
1953
  this.logger.debug?.(
1903
1954
  `[${this.label}] paste not submitted (no user-message for ${token}), `
1904
1955
  + `re-sending Enter (attempt ${attempt + 1})`,
@@ -1920,6 +1971,34 @@ class TmuxProcess extends Process {
1920
1971
  );
1921
1972
  }
1922
1973
 
1974
+ /**
1975
+ * Parked-branch wait (Commit 2): resolve when `token` surfaces in a
1976
+ * JSONL `user-message` (submit landed), or when the owning turn goes
1977
+ * terminal another way (result flushed / interrupted / killed). NO
1978
+ * timeout — the caller's `_runTurn` turn deadline (W1) is the floor.
1979
+ *
1980
+ * Racing the turn's own settle promises prevents a leaked
1981
+ * `_submitConfirms` entry on a turn that ends without ever
1982
+ * producing our user-message (e.g. the prior turn wedges and W1
1983
+ * fires).
1984
+ */
1985
+ _awaitSubmitOrTerminal(token, turn) {
1986
+ return new Promise((resolve) => {
1987
+ let done = false;
1988
+ const finish = () => {
1989
+ if (done) return;
1990
+ done = true;
1991
+ this._submitConfirms.delete(token);
1992
+ resolve();
1993
+ };
1994
+ this._submitConfirms.set(token, finish); // user-message → finish
1995
+ // Bail if the turn settles via result / interrupt before the
1996
+ // user-message lands.
1997
+ turn?.resultPromise?.then(finish, finish);
1998
+ turn?.interruptP?.then(finish, finish);
1999
+ });
2000
+ }
2001
+
1923
2002
  /**
1924
2003
  * Resolve `true` once `token` surfaces in a JSONL `user-message`
1925
2004
  * (via `_confirmSubmit`), or `false` after `submitConfirmMs`.
@@ -2663,12 +2742,12 @@ class TmuxProcess extends Process {
2663
2742
  try { finish(); } catch { /* swallow */ }
2664
2743
  }
2665
2744
  // B7: release any pending submit-confirm waiters too — a
2666
- // `_confirmSubmitViaJsonl` blocked on a tokened user-message from a
2745
+ // `_scheduleSubmitRetries` blocked on a tokened user-message from a
2667
2746
  // now-dead session would otherwise burn its whole retry budget.
2668
2747
  // Each waiter's stored fn resolves it as confirmed, so the confirm
2669
2748
  // loop returns at once instead of retrying; the in-flight turn is
2670
2749
  // already rejected by `drainQueue` above, so the turn settles loud
2671
- // regardless. (`_confirmSubmitViaJsonl` also bails on its own when
2750
+ // regardless. (`_scheduleSubmitRetries` also bails on its own when
2672
2751
  // the owning turn reaches a terminal state — this is belt-and-
2673
2752
  // braces for a confirm whose turn ref it never received.)
2674
2753
  for (const finish of [...this._submitConfirms.values()]) {
@@ -20,6 +20,9 @@
20
20
 
21
21
  'use strict';
22
22
 
23
+ const { getTopicConfig } = require('../session-key');
24
+ const { pickBackend } = require('../process/factory');
25
+
23
26
  function createSdkCallbacks({
24
27
  db,
25
28
  dbWrite,
@@ -97,15 +100,35 @@ function createSdkCallbacks({
97
100
 
98
101
  return {
99
102
  onInit: (sessionKey, event, entry) => {
103
+ // Resolve the spawn-time identity the SAME way the backends do
104
+ // (topic override merged over chat-level + factory's
105
+ // pickBackend) — must match what `buildSpawnContext` in
106
+ // polygram.js compares against, otherwise every spawn re-poisons
107
+ // the row with chat-level values and S2 drift fires forever.
108
+ //
109
+ // The shumorobot 2026-05-21 Music topic bug was this: chat-
110
+ // level agent='shumabit' + cwd=$HOME got written into the row
111
+ // every turn, but the topic-level resolved to
112
+ // music-curation:music-curator + .../Music/rekordbox. Next turn
113
+ // → drift → drop row → fresh sid → context lost. Forever.
114
+ //
115
+ // pm_backend MUST also be persisted explicitly; otherwise
116
+ // db.upsertSession defaults it to 'sdk' for every spawn,
117
+ // making historical telemetry meaningless.
118
+ const chatConfig = config.chats[entry.chatId] || {};
119
+ const topicConfig = getTopicConfig(chatConfig, entry.threadId || null);
100
120
  dbWrite(() => db.upsertSession({
101
121
  session_key: sessionKey,
102
122
  chat_id: entry.chatId,
103
123
  thread_id: entry.threadId,
104
124
  claude_session_id: event.session_id,
105
- agent: config.chats[entry.chatId]?.agent || null,
106
- cwd: config.chats[entry.chatId]?.cwd || null,
107
- model: config.chats[entry.chatId]?.model || null,
108
- effort: config.chats[entry.chatId]?.effort || null,
125
+ agent: topicConfig.agent || chatConfig.agent || null,
126
+ cwd: topicConfig.cwd || chatConfig.cwd || null,
127
+ model: topicConfig.model || chatConfig.model || null,
128
+ effort: topicConfig.effort || chatConfig.effort || null,
129
+ pm_backend: pickBackend({
130
+ config, chatId: entry.chatId, threadId: entry.threadId || null,
131
+ }),
109
132
  }), `upsert session ${sessionKey}`);
110
133
  },
111
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.32",
3
+ "version": "0.10.0-rc.34",
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": {