polygram 0.12.0-rc.36 → 0.12.0-rc.38

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/lib/claude-bin.js CHANGED
@@ -7,14 +7,20 @@ const fs = require('fs');
7
7
  // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
8
8
  // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
9
9
  // + spike scripts + polygram boot all import from here now.
10
- // 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed). The dev-channels
11
- // inbound-message delivery has an intermittent channel-bind race (the bridge
12
- // pushes user_msg before claude's channel subscription is active message
13
- // silently dropped stuck turn; see docs/0.12.0-known-issues.md). Trying a
14
- // newer claude to see if the research-preview channels reliability improved,
15
- // before building polygram-side recovery. Re-validate the channel flow on each
16
- // bump via tests/e2e-channels-real-claude.test.js.
17
- const CLAUDE_CLI_PINNED_VERSION = '2.1.158';
10
+ // 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed) chasing the
11
+ // dev-channels reliability issues (see docs/0.12.0-known-issues.md).
12
+ // 0.12.0-rc.38: bumped 2.1.158 2.1.173. Two reasons: (1) the ~32s startup
13
+ // deaths root-caused 2026-06-11 to a stale MCP connect-timeout racing the
14
+ // --resume session-id swap — a newer claude may fix the timer (2.1.173 also
15
+ // adds "Channel notifications re-registered after reconnect"); (2) keep the
16
+ // research-preview channels current. Per-bump re-validation done 2026-06-11:
17
+ // resume-dialog env vars survive (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES /
18
+ // _TOKEN_THRESHOLD), trust + dev-channels dialogs unchanged, "esc to
19
+ // interrupt" hint unchanged (template-rendered), but the channels READY
20
+ // banner text CHANGED → readySignal in cli-process.js matches both forms.
21
+ // Re-validate the channel flow on each bump via
22
+ // tests/e2e-channels-real-claude.test.js (run with E2E_REAL_CLAUDE=1).
23
+ const CLAUDE_CLI_PINNED_VERSION = '2.1.173';
18
24
 
19
25
  /**
20
26
  * Resolve + verify the pinned claude CLI binary.
@@ -207,12 +207,14 @@ const CODES = {
207
207
  isTransient: false,
208
208
  autoRecover: null,
209
209
  },
210
- // TURN_TIMEOUT: 10-min wall-clock cap on a single channels turn. Mirror
211
- // of the tmux wall-clock ceiling — typically a runaway, not a wedge.
212
- // Not transient (auto-retry would just runaway again).
210
+ // TURN_TIMEOUT: per-turn time cap (idle default 10 min, configurable per
211
+ // chat/topic — UMI runs 60 min). Mirror of the tmux wall-clock ceiling —
212
+ // typically a runaway, not a wedge. Not transient (auto-retry would just
213
+ // runaway again). Copy must not name a number: the 2026-06-11 UMI false-⏱
214
+ // rendered "10-minute" under a 60-minute cap.
213
215
  TURN_TIMEOUT: {
214
216
  kind: 'turnTimeout',
215
- userMessage: '⏱ The turn ran past the 10-minute cap. Resend if the answer still matters.',
217
+ userMessage: '⏱ This one ran past its time cap with no reply. Resend if the answer still matters.',
216
218
  isTransient: false,
217
219
  autoRecover: null,
218
220
  },
@@ -23,7 +23,7 @@ const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
23
23
  // polygram passes the alias (opus / sonnet / haiku) and lets claude
24
24
  // resolve. Bump on Claude release.
25
25
  const MODEL_VERSIONS_DESC = {
26
- opus: 'claude-opus-4-7',
26
+ opus: 'claude-opus-4-8',
27
27
  sonnet: 'claude-sonnet-4-6',
28
28
  haiku: 'claude-haiku-4-5',
29
29
  };
@@ -28,6 +28,10 @@ const MODEL_COSTS = {
28
28
  'claude-haiku-4-5': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreation: 1 },
29
29
  // Claude Opus 4.7 (1M context)
30
30
  'claude-opus-4-7': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
31
+ // Claude Opus 4.8 — what `--model opus` resolves to now (verified in the
32
+ // Music-topic transcript 2026-06-10). Same Opus pricing; without this entry
33
+ // opus turns fall back to the Sonnet `default` and undercount cost ~5×.
34
+ 'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
31
35
  // Default fallback — Sonnet rates (safest mid-tier estimate).
32
36
  default: { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
33
37
  };
@@ -122,7 +122,10 @@ const DEFAULT_QUEUE_CAP = 50; // Parity P2: match SDK/tmux pendin
122
122
  // catalog when new dialogs are observed in production.
123
123
  const SESSION_AGE_PROMPT_RE = /Resuming the full session[\s\S]*Resume from summary/i;
124
124
  const MID_TURN_PROMPTS = [
125
- { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, action: 'enter' },
125
+ // Review F2 (resume-dialog fix): bare Enter selects the pre-selected
126
+ // "Resume from summary" — which literally runs /compact. Navigate to
127
+ // "Resume full session as-is" instead, same as the startup-gate trigger.
128
+ { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, action: 'keys', keys: ['Down', 'Enter'] },
126
129
  ];
127
130
 
128
131
  // 0.12 Phase 3.2 (Finding 0.1.A): rc.45 esc-to-interrupt liveness heartbeat.
@@ -864,6 +867,20 @@ class CliProcess extends Process {
864
867
  cwd: resolvedCwd || opts.cwd || process.cwd(),
865
868
  command: this.claudeBin,
866
869
  args: claudeArgs,
870
+ envExtras: {
871
+ // Resume-dialog suppression (docs/0.13-resume-dialog-fix-spec.md B1):
872
+ // claude's session-age "resume-return" dialog fires when sessionAge ≥
873
+ // this many minutes AND est. tokens ≥ CLAUDE_CODE_RESUME_TOKEN_THRESHOLD
874
+ // (defaults 70 / 1e5, binary-verified on 2.1.158). Its pre-selected
875
+ // option literally runs /compact — silently compacting every aged
876
+ // --resume (and breaking the /model "conversation kept" guarantee).
877
+ // A huge threshold (1 year) means the dialog never triggers and resume
878
+ // is always full-session-as-is. Per-process env — the operator's own
879
+ // interactive claude is untouched. Belt-and-braces: the session-age
880
+ // gate trigger below still navigates to "full" if a future binary bump
881
+ // renames this var.
882
+ CLAUDE_CODE_RESUME_THRESHOLD_MINUTES: '525600',
883
+ },
867
884
  });
868
885
 
869
886
  // Dialog handling (Phase 0 finding) — poll capture-pane and Enter through:
@@ -880,7 +897,7 @@ class CliProcess extends Process {
880
897
  * lives in the shared helper.
881
898
  */
882
899
  async _handleStartupDialogs(tmuxName) {
883
- await runStartupGate({
900
+ const gateResult = await runStartupGate({
884
901
  runner: this.runner,
885
902
  tmuxName,
886
903
  triggers: [
@@ -893,15 +910,23 @@ class CliProcess extends Process {
893
910
  // pre-selected "trust" option). The older "trust the files in this folder"
894
911
  // wording is kept for back-compat; both anchor on "trust … this folder".
895
912
  { name: 'trust', regex: /trust (?:the files in )?this folder/i, key: 'Enter' },
896
- // Review F#12: session-age "Resume from summary?" prompt — fires on
897
- // aged sessions (claude treats older session JSONLs differently).
898
- // Tmux backend dismisses with Enter at tmux-process.js:2637 onward;
899
- // mirror that here so an aged channels session doesn't hang the
900
- // handshake until CHANNELS_HANDSHAKE_TIMEOUT (15s) dead chat
901
- // requiring manual /reset.
902
- { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, key: 'Enter' },
913
+ // Review F#12 + 2026-06-11 resume-dialog fix: session-age
914
+ // "resume-return" prompt on aged sessions. Bare Enter selects the
915
+ // pre-selected "Resume from summary" which literally runs /compact
916
+ // on the resumed session (silent context degradation; the original
917
+ // F#12 dismissal compacted every aged resume). Navigate to option 2
918
+ // "Resume full session as-is" instead. This is the FALLBACK path:
919
+ // spawn env (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES above) suppresses
920
+ // the dialog entirely; this trigger firing at all means suppression
921
+ // failed (upstream renamed the env var?) — surfaced via the
922
+ // session-age-dialog-fallback event below.
923
+ { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, keys: ['Down', 'Enter'] },
903
924
  ],
904
- readySignal: /Listening for channel messages from: server:polygram-bridge/i,
925
+ // 2.1.173 reworked the channels UI banner (live-captured 2026-06-11):
926
+ // "Channels (experimental) messages from server:polygram-bridge inject
927
+ // directly in this session · …". Keep the 2.1.158 text too so a
928
+ // POLYGRAM_CLAUDE_BIN override to an older binary still gates correctly.
929
+ readySignal: /(?:Listening for channel messages from:|Channels \(experimental\) messages from) server:polygram-bridge/i,
905
930
  timeoutCode: 'CHANNELS_DIALOG_TIMEOUT',
906
931
  // Progress-aware gate (shumorobot General incident 2026-05-30): a
907
932
  // cold spawn that's mid-download (runtime fetch, "24%" progress bar)
@@ -917,9 +942,25 @@ class CliProcess extends Process {
917
942
  // only bounds a TUI that rendered and then truly hung.
918
943
  stallMs: this.startupGateStallMs ?? 60_000,
919
944
  deadlineMs: this.startupGateDeadlineMs ?? 180_000,
945
+ // Review F4: fire-time, NOT gate-resolution — the 2026-06-10 incident
946
+ // matched session-age and THEN died (TMUX_SESSION_GONE), which a
947
+ // success-path check would miss. The dialog appearing AT ALL means the
948
+ // env suppression (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES in
949
+ // _spawnTmuxClaude) stopped working — almost certainly an upstream
950
+ // rename on a binary bump. The gate handles it (full resume picked);
951
+ // this makes the regression visible.
952
+ onTrigger: (name) => {
953
+ if (name !== 'session-age') return;
954
+ this.logger.warn?.(
955
+ `[${this.label}] channels: session-age resume dialog appeared despite env suppression — ` +
956
+ 'check CLAUDE_CODE_RESUME_THRESHOLD_MINUTES against the pinned claude binary',
957
+ );
958
+ this._logEvent('session-age-dialog-fallback', { tmux_name: tmuxName, phase: 'startup-gate' });
959
+ },
920
960
  logger: this.logger,
921
961
  label: `${this.label}:startup-gate`,
922
962
  });
963
+ return gateResult;
923
964
  }
924
965
 
925
966
  // 0.12 Phase 1.6: TWO-handshake gate. The original implementation only
@@ -1201,6 +1242,34 @@ class CliProcess extends Process {
1201
1242
  return;
1202
1243
  }
1203
1244
 
1245
+ // Dropped-"4" fix A2 (docs/0.13-resume-dialog-fix-spec.md): resolve the
1246
+ // reply's originating TG message so the dispatcher has a target for solo
1247
+ // reactions (and reply-quoting). Resolution order strictly mirrors
1248
+ // _recordReplyForPendingTurn so quote/reaction attribution can never
1249
+ // disagree with reply attribution: echoed turn_id → InputLedger entry's
1250
+ // msgId (registered at send/inject time); no echo → the single pending
1251
+ // turn's ledger entry. Anything else stays null — an unattributable
1252
+ // reply must never react to / quote an unrelated message.
1253
+ //
1254
+ // Review F1: quote only the FIRST delivered reply per turn. On SDK,
1255
+ // deliverReplies fires once per turn → one quote; the channels dispatcher
1256
+ // fires per reply tool call, and an N-reply turn must not produce N
1257
+ // bubbles all quoting the same user message.
1258
+ let sourceMsgId = null;
1259
+ let sourceEntry = null;
1260
+ if (args.turn_id && this.inputLedger.has(args.turn_id)) {
1261
+ sourceEntry = this.inputLedger.get(args.turn_id);
1262
+ } else if (this.pendingTurns.size === 1) {
1263
+ const [[onlyTurnId]] = this.pendingTurns;
1264
+ sourceEntry = this.inputLedger.get(onlyTurnId) || null;
1265
+ }
1266
+ if (sourceEntry && !sourceEntry._quoteUsed) {
1267
+ // Review F6: ledger stores msgId stringified; every other delivery call
1268
+ // site passes numeric message_id — coerce rather than lean on TG leniency.
1269
+ const n = Number(sourceEntry.msgId);
1270
+ sourceMsgId = Number.isFinite(n) && n > 0 ? n : null;
1271
+ }
1272
+
1204
1273
  let result;
1205
1274
  try {
1206
1275
  result = await this.toolDispatcher({
@@ -1211,6 +1280,7 @@ class CliProcess extends Process {
1211
1280
  text: args.text,
1212
1281
  files: args.files,
1213
1282
  messageId: args.message_id, // 0.13: edit_message target bubble
1283
+ sourceMsgId, // reaction/quote target (A2)
1214
1284
  sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
1215
1285
  maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
1216
1286
  });
@@ -1219,6 +1289,12 @@ class CliProcess extends Process {
1219
1289
  return;
1220
1290
  }
1221
1291
 
1292
+ // Review F1: the quote target is spent once a reply actually delivered
1293
+ // with it. A FAILED delivery doesn't consume it — the retry still quotes.
1294
+ if (msg.name === 'reply' && result?.ok && sourceMsgId != null && sourceEntry) {
1295
+ sourceEntry._quoteUsed = true;
1296
+ }
1297
+
1222
1298
  // 0.13: carry the delivered message_id back so the bridge hands it to claude
1223
1299
  // (reply → edit_message progressive status).
1224
1300
  this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error, message_id: result?.message_id });
@@ -1449,6 +1525,21 @@ class CliProcess extends Process {
1449
1525
  this._ledgerTransition(id, 'resolved');
1450
1526
  this._logEvent('cli-input-acked', { turn_id: id, source: e.source });
1451
1527
  }
1528
+ // UMI 2026-06-11 19:49 false ⏱ timeout: when claude answers a
1529
+ // primary+fold in ONE reply but echoes the FOLD's turn_id, the reply
1530
+ // routes via late-reply correlation and the PRIMARY pending absorbs
1531
+ // nothing — yet this ack names the primary. Mark it consumed so the
1532
+ // finalizer rungs treat it as replied (resolve already-delivered)
1533
+ // instead of rejecting it at a ceiling AFTER the user got the answer.
1534
+ const pending = this.pendingTurns.get(id);
1535
+ if (pending) {
1536
+ pending._consumedAcked = true;
1537
+ // The ack itself flips rung-2 eligibility on — arm now. (The turn's
1538
+ // last _noteActivity ran BEFORE this flag was set, so without this
1539
+ // a quiet tail would never re-arm and the turn would sit until a
1540
+ // ceiling.)
1541
+ this._armActivityQuiet(id, pending);
1542
+ }
1452
1543
  }
1453
1544
  }
1454
1545
 
@@ -1607,7 +1698,10 @@ class CliProcess extends Process {
1607
1698
  */
1608
1699
  _armActivityQuiet(turnId, pending) {
1609
1700
  if (!this._sawHookStream) return;
1610
- if (!pending.replies || pending.replies.length === 0) return;
1701
+ // ≥1 reply, OR seen + consumed-acked (the answer rode a sibling turn_id —
1702
+ // fold-id echo; see _ledgerAckConsumed). Same eligibility as the fire site.
1703
+ if ((!pending.replies || pending.replies.length === 0)
1704
+ && !(pending.seen === true && pending._consumedAcked === true)) return;
1611
1705
  if (this._openQuestions.size > 0) return;
1612
1706
  if (pending._stopGracePending) return;
1613
1707
  if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
@@ -1637,11 +1731,15 @@ class CliProcess extends Process {
1637
1731
  if (!pending) return;
1638
1732
  if (pending._stopGracePending) return;
1639
1733
  if (this._openQuestions.size > 0) return; // re-check at fire time
1640
- if (!pending.replies || pending.replies.length === 0) return;
1734
+ // Eligibility: ≥1 bound reply, OR seen + consumed-acked (the answer went
1735
+ // out under a sibling turn_id — fold-id echo; see _ledgerAckConsumed).
1736
+ const consumedAcked = pending.seen === true && pending._consumedAcked === true;
1737
+ if ((!pending.replies || pending.replies.length === 0) && !consumedAcked) return;
1641
1738
  const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
1642
1739
  this._logEvent('cli-activity-quiet-finalize', {
1643
1740
  turn_id: turnId,
1644
1741
  reply_count: pending.replies.length,
1742
+ consumed_acked: consumedAcked,
1645
1743
  last_hook_age_ms: lastHookAgeMs,
1646
1744
  had_stop: !!pending._stopHookData,
1647
1745
  });
@@ -1792,8 +1890,11 @@ class CliProcess extends Process {
1792
1890
  // BUT a turn finalized via the Stop fallback (no reply tool calls — the
1793
1891
  // stuck-turn case) has delivered NOTHING; marking it alreadyDelivered
1794
1892
  // would resolve the turn silently and the user still sees nothing. So
1795
- // only claim already-delivered when reply tool calls actually fired.
1796
- alreadyDelivered: hadReplyToolCalls,
1893
+ // only claim already-delivered when reply tool calls actually fired
1894
+ // or when claude ACKED consuming this turn in a sibling reply
1895
+ // (consumed_turn_ids; the fold-id-echo case): re-sending the Stop
1896
+ // fallback there would duplicate the delivered answer.
1897
+ alreadyDelivered: hadReplyToolCalls || pending._consumedAcked === true,
1797
1898
  sessionId: this.claudeSessionId,
1798
1899
  cost: null, // Channels protocol doesn't expose per-turn cost
1799
1900
  duration,
@@ -1931,10 +2032,14 @@ class CliProcess extends Process {
1931
2032
  // would send a scary timeout error AFTER a successful reply (round-2
1932
2033
  // panel finding: the v2 soak gate contradicted the design's own
1933
2034
  // ask-timeout-then-ceiling path). TURN_TIMEOUT rejection is reserved
1934
- // for turns with ZERO delivered replies.
1935
- if ((pending.replies?.length || 0) > 0) {
2035
+ // for turns with ZERO delivered replies. Consumed-acked counts as
2036
+ // replied: the answer rode a sibling turn_id (fold-id echo the UMI
2037
+ // 2026-06-11 19:49 false ⏱; see _ledgerAckConsumed).
2038
+ if ((pending.replies?.length || 0) > 0
2039
+ || (pending.seen === true && pending._consumedAcked === true)) {
1936
2040
  this._logEvent('cli-turn-ceiling-resolved', {
1937
- reason, turnTimeoutMs, reply_count: pending.replies.length,
2041
+ reason, turnTimeoutMs, reply_count: pending.replies?.length || 0,
2042
+ consumed_acked: pending._consumedAcked === true,
1938
2043
  });
1939
2044
  this.emit('idle');
1940
2045
  resolve({
@@ -3389,16 +3494,28 @@ class CliProcess extends Process {
3389
3494
  pending_count: this.pendingTurns.size,
3390
3495
  });
3391
3496
 
3392
- if (prompt.action === 'enter') {
3393
- try {
3394
- await this.runner.sendControl(this.tmuxSession, 'Enter');
3395
- } catch (err) {
3396
- this.logger.warn?.(
3397
- `[${this.label}] cli: mid-turn dismiss-Enter failed for ${prompt.name}: ${err.message}`,
3398
- );
3497
+ if (prompt.action === 'enter' || prompt.action === 'keys') {
3498
+ // 'keys' sends a navigation sequence (e.g. Down,Enter to pick a
3499
+ // non-default dialog option); 'enter' stays the single-key dismissal.
3500
+ const keySeq = prompt.action === 'keys' ? prompt.keys : ['Enter'];
3501
+ for (let ki = 0; ki < keySeq.length; ki++) {
3502
+ if (ki > 0) await new Promise(r => setTimeout(r, 120)); // Ink can swallow same-batch keys
3503
+ try {
3504
+ await this.runner.sendControl(this.tmuxSession, keySeq[ki]);
3505
+ } catch (err) {
3506
+ this.logger.warn?.(
3507
+ `[${this.label}] cli: mid-turn ${keySeq[ki]} failed for ${prompt.name}: ${err.message}`,
3508
+ );
3509
+ }
3399
3510
  }
3400
3511
  }
3401
3512
  // 'emit-only': telemetry-only; operator decides next step.
3513
+ // Resume-dialog fix: the session-age dialog escaping to MID-TURN means
3514
+ // env suppression failed AND the startup gate didn't see it — same
3515
+ // soak-queryable event kind as the startup-gate fallback.
3516
+ if (prompt.name === 'session-age') {
3517
+ this._logEvent('session-age-dialog-fallback', { tmux_name: this.tmuxSession, phase: 'mid-turn' });
3518
+ }
3402
3519
  }
3403
3520
 
3404
3521
  // 0.12 Phase 3.3 (Q1 resolution): unknown-prompt heuristic. If the pane
@@ -94,8 +94,15 @@ function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
94
94
  }
95
95
 
96
96
  // Solo-emoji shortcuts (single emoji → sticker if mapped, else reaction).
97
- const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed)
98
- || /^\p{Emoji}️?$/u.test(trimmed);
97
+ // Keycap-base guard (2026-06-10 "2+2 → 4" dropped reply): Unicode \p{Emoji}
98
+ // includes 0-9/#/* (keycap bases), so a bare single-digit answer parsed as
99
+ // a reaction with text:'' and the channels dispatcher dropped it. A solo
100
+ // digit/hash/asterisk is always TEXT; real keycap emoji (4️⃣) are
101
+ // multi-codepoint and never hit this branch anyway. The optional ️
102
+ // also catches a stray variation selector on a digit ("4️") — same class.
103
+ const emojiOnly = !/^[0-9#*]️?$/.test(trimmed)
104
+ && (/^\p{Emoji_Presentation}$/u.test(trimmed)
105
+ || /^\p{Emoji}️?$/u.test(trimmed));
99
106
 
100
107
  if (emojiOnly && trimmed) {
101
108
  if (emojiToSticker[trimmed]) {
@@ -60,6 +60,10 @@ const DEFAULT_SETTLE_MS = 500;
60
60
  * @param {number} [opts.pollMs=300]
61
61
  * @param {number} [opts.settleMs=500]
62
62
  * @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
63
+ * @param {Function} [opts.onTrigger] — (name) => void, called AT FIRE
64
+ * TIME (not gate resolution). Telemetry hung off the success-path return
65
+ * misses the matched-then-died sequence (2026-06-10 prod: gate matched
66
+ * session-age, then TMUX_SESSION_GONE). Errors are swallowed.
63
67
  * @param {object} [opts.logger=console]
64
68
  * @param {string} [opts.label='startup-gate']
65
69
  * @returns {Promise<{matchedTriggers: string[], elapsedMs: number}>}
@@ -74,6 +78,7 @@ async function runStartupGate({
74
78
  pollMs = DEFAULT_POLL_MS,
75
79
  settleMs = DEFAULT_SETTLE_MS,
76
80
  timeoutCode = 'TUI_STARTUP_TIMEOUT',
81
+ onTrigger = null,
77
82
  logger = console,
78
83
  label = 'startup-gate',
79
84
  } = {}) {
@@ -174,13 +179,22 @@ async function runStartupGate({
174
179
  for (const trigger of triggers) {
175
180
  if (seen.has(trigger.name)) continue;
176
181
  if (!trigger.regex.test(pane)) continue;
177
- try {
178
- await runner.sendControl(tmuxName, trigger.key);
179
- } catch (err) {
180
- logger.warn?.(`[${label}] sendControl(${trigger.key}) failed for trigger=${trigger.name}: ${err.message}`);
182
+ // `keys: [...]` sends a sequence (dialog navigation — e.g. Down,Enter
183
+ // to pick a non-default option); `key:` remains the single-key form.
184
+ // Sequence keys go as separate send-keys calls with a short delay —
185
+ // Ink dialogs can swallow the second key of a same-batch sequence.
186
+ const keySeq = Array.isArray(trigger.keys) ? trigger.keys : [trigger.key];
187
+ for (let ki = 0; ki < keySeq.length; ki++) {
188
+ if (ki > 0) await new Promise(r => setTimeout(r, Math.min(settleMs, 120)));
189
+ try {
190
+ await runner.sendControl(tmuxName, keySeq[ki]);
191
+ } catch (err) {
192
+ logger.warn?.(`[${label}] sendControl(${keySeq[ki]}) failed for trigger=${trigger.name}: ${err.message}`);
193
+ }
181
194
  }
182
195
  seen.add(trigger.name);
183
196
  matchedTriggers.push(trigger.name);
197
+ try { onTrigger?.(trigger.name); } catch { /* telemetry must not break the gate */ }
184
198
  matched = true;
185
199
  // Sending a key is activity — navigating the TUI counts as progress
186
200
  // even if the pre-transition pane text was static (e.g. a dialog we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.36",
3
+ "version": "0.12.0-rc.38",
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": {