polygram 0.10.0-rc.2 → 0.10.0-rc.21

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.
@@ -10,25 +10,52 @@
10
10
  * actual conversation events live in the per-session JSONL claude
11
11
  * writes to disk for /resume to work.
12
12
  *
13
- * Each JSONL line is a JSON object with `type` discriminator:
13
+ * # Two parsers, one event surface
14
14
  *
15
- * { type: 'user', message: {...} }
16
- * { type: 'assistant', message: {... content: [...], stop_reason: 'end_turn'} }
17
- * { type: 'attachment', attachment: {...} }
18
- * { type: 'last-prompt', lastPrompt: '...' }
19
- * { type: 'queue-operation', operation: 'enqueue', content: '...' }
15
+ * - `parseLine(line)` — STATELESS, one JSONL line → events. Kept for
16
+ * single-line use and its test coverage. Cannot coalesce a logical
17
+ * assistant message that spans multiple JSONL lines.
18
+ * - `SessionEventAggregator` — STATEFUL, the primary path used by
19
+ * `pipeToParser`. It coalesces by `message.id` (0.10.0 Phase 1).
20
+ *
21
+ * ## Why the aggregator exists — the empty-turn bug
22
+ *
23
+ * Verified against real claude 2.1.142 JSONL: ONE logical assistant
24
+ * message is written across MULTIPLE JSONL lines that all share the
25
+ * same `message.id` and all repeat the message-level `stop_reason`.
26
+ * A terminal message commonly arrives as a `thinking` line then a
27
+ * `text` line — both `stop_reason: end_turn`, same id.
28
+ *
29
+ * The stateless parser fires a `result` for EVERY line carrying a
30
+ * stop_reason. The `thinking` line has `end_turn` but no text → it
31
+ * resolves the turn with text='' BEFORE the real-text line is read.
32
+ * That is the zero-concurrency empty-turn bug. The aggregator fixes
33
+ * it by buffering lines per `message.id` and firing `result` ONCE,
34
+ * when the message finalizes, with the full coalesced text.
35
+ *
36
+ * # JSONL line shapes (claude 2.1.142, verified)
37
+ *
38
+ * { type: 'user', message: {...}, parentUuid, promptId }
39
+ * { type: 'assistant', message: {id, content:[...], stop_reason} }
40
+ * { type: 'last-prompt', lastPrompt?: '...' }
41
+ * { type: 'queue-operation', operation: 'enqueue'|'dequeue', content? }
42
+ * { type: 'system' | 'attachment' | 'permission-mode' | ... }
20
43
  *
21
44
  * # Mapping to Process events
22
45
  *
23
- * - assistant with `content[].type === 'text'` emit 'assistant-chunk' { text }
24
- * - assistant with `content[].type === 'tool_use'`emit 'tool-use' { name, input }
25
- * - assistant with `message.stop_reason` emit 'result' { subtype, text, ... }
26
- * - last-prompt emit 'last-prompt' (fallback complete signal)
46
+ * - assistant text block → 'assistant-chunk' { text } (eager, per-line)
47
+ * - assistant tool_use block → 'tool-use' { name, input, id } (eager, per-line)
48
+ * - assistant usage block → 'usage' {...} (eager, per-line)
49
+ * - assistant message end → 'result' { subtype, text, stopReason }
50
+ * (ONCE per message.id, on finalize)
51
+ * - last-prompt → 'last-prompt' (fallback complete signal)
52
+ * - user (top-level string) → 'user-message' { text, parentUuid, promptId }
53
+ * - queue-operation → 'queue-operation' { operation, content }
27
54
  *
28
- * Robust against malformed lines: returns null and skips.
55
+ * Robust against malformed lines: skips them.
29
56
  *
30
57
  * @see lib/tmux/log-tail.js — generic file tailer
31
- * @see docs/0.10.0-process-manager-abstraction-plan.md v9
58
+ * @see docs/0.10.0-tmux-concurrency-solution.md §3 (Phase 1)
32
59
  */
33
60
 
34
61
  'use strict';
@@ -72,20 +99,73 @@ function sessionLogPath(cwd, sessionId, homeDir = os.homedir()) {
72
99
  return path.join(homeDir, '.claude', 'projects', encodeCwd(cwd), `${sessionId}.jsonl`);
73
100
  }
74
101
 
102
+ // ─── shared content-block extraction ─────────────────────────────────
103
+
104
+ /**
105
+ * Pull the text + tool_use blocks out of an assistant message's
106
+ * `content` array. Shared by `parseLine` and `SessionEventAggregator`
107
+ * so both surface byte-identical text/tool extraction.
108
+ *
109
+ * @returns {{textParts: string[], toolUses: object[]}}
110
+ */
111
+ function extractContentBlocks(content) {
112
+ const textParts = [];
113
+ const toolUses = [];
114
+ if (!Array.isArray(content)) return { textParts, toolUses };
115
+ for (const block of content) {
116
+ if (!block || typeof block !== 'object') continue;
117
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) {
118
+ textParts.push(block.text);
119
+ } else if (block.type === 'tool_use' && block.name) {
120
+ toolUses.push({
121
+ type: 'tool-use',
122
+ name: block.name,
123
+ input: block.input ?? null,
124
+ id: block.id ?? null,
125
+ });
126
+ }
127
+ }
128
+ return { textParts, toolUses };
129
+ }
130
+
131
+ /**
132
+ * Join assistant text blocks the way the SDK backend's
133
+ * `extractAssistantText` does (rc.8 cross-backend parity): blocks
134
+ * joined with '\n\n', trimmed, with a trailing-colon → ellipsis
135
+ * transform ("Listing deps:" → "Listing deps…") so streamed-but-not-
136
+ * final text reads complete during a pause while a tool runs.
137
+ */
138
+ function joinAssistantText(textParts) {
139
+ return textParts.join('\n\n').trim().replace(/([^:]):\s*$/, '$1…');
140
+ }
141
+
142
+ /**
143
+ * Extract the token-usage snapshot from an assistant line, or null.
144
+ * Every assistant message carries the cumulative usage; the latest
145
+ * such event wins downstream.
146
+ */
147
+ function extractUsage(obj) {
148
+ const u = obj.message && obj.message.usage;
149
+ if (!u) return null;
150
+ return {
151
+ type: 'usage',
152
+ inputTokens: u.input_tokens ?? 0,
153
+ outputTokens: u.output_tokens ?? 0,
154
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
155
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
156
+ model: obj.message.model ?? null,
157
+ };
158
+ }
159
+
75
160
  /**
76
- * Parse one JSONL line into a Process-shaped event, OR null when the
77
- * line carries nothing observable. Malformed JSON → null.
161
+ * Parse one JSONL line into Process-shaped events, OR [] when the
162
+ * line carries nothing observable. Malformed JSON → [].
78
163
  *
79
- * Events returned (each with `type` field):
80
- * - 'assistant-chunk' { text }
81
- * - 'tool-use' { name, input, id }
82
- * - 'result' { subtype, text, stopReason }
83
- * - 'last-prompt' { text }
164
+ * STATELESS cannot coalesce a multi-line assistant message. For the
165
+ * live tail path use `SessionEventAggregator` (via `pipeToParser`).
84
166
  *
85
167
  * @param {string} line
86
- * @returns {object[]} array of events (a single line CAN produce
87
- * multiple — e.g. an assistant message with both text and tool_use
88
- * content blocks emits both 'assistant-chunk' and 'tool-use').
168
+ * @returns {object[]}
89
169
  */
90
170
  function parseLine(line) {
91
171
  if (!line || typeof line !== 'string') return [];
@@ -98,42 +178,18 @@ function parseLine(line) {
98
178
 
99
179
  if (obj.type === 'assistant' && obj.message) {
100
180
  const content = obj.message.content;
101
- if (Array.isArray(content)) {
102
- for (const block of content) {
103
- if (!block || typeof block !== 'object') continue;
104
- if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) {
105
- out.push({ type: 'assistant-chunk', text: block.text });
106
- } else if (block.type === 'tool_use' && block.name) {
107
- out.push({
108
- type: 'tool-use',
109
- name: block.name,
110
- input: block.input ?? null,
111
- id: block.id ?? null,
112
- });
113
- }
114
- }
181
+ const { textParts, toolUses } = extractContentBlocks(content);
182
+ // Emit text FIRST then tool-uses — text-then-tool is the dominant
183
+ // real-world shape.
184
+ if (textParts.length > 0) {
185
+ const joined = joinAssistantText(textParts);
186
+ if (joined.length > 0) out.push({ type: 'assistant-chunk', text: joined });
115
187
  }
116
- // Token-usage telemetry. Every assistant message carries the
117
- // cumulative usage snapshot — input_tokens + cache_creation +
118
- // cache_read = current context size. TmuxProcess uses the latest
119
- // such event to implement getContextUsage().
120
- if (obj.message.usage) {
121
- const u = obj.message.usage;
122
- out.push({
123
- type: 'usage',
124
- inputTokens: u.input_tokens ?? 0,
125
- outputTokens: u.output_tokens ?? 0,
126
- cacheReadTokens: u.cache_read_input_tokens ?? 0,
127
- cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
128
- model: obj.message.model ?? null,
129
- });
130
- }
131
- // stop_reason marks end of an assistant turn segment. 'end_turn'
132
- // is the canonical complete; 'tool_use' / 'max_tokens' / etc. are
133
- // partial-with-continuation. We forward all stop_reasons so the
134
- // caller can decide.
188
+ for (const t of toolUses) out.push(t);
189
+ const usage = extractUsage(obj);
190
+ if (usage) out.push(usage);
191
+ // stop_reason marks end of an assistant turn segment.
135
192
  if (obj.message.stop_reason) {
136
- // Collect all text from the message for the result.text field.
137
193
  const text = Array.isArray(content)
138
194
  ? content.filter((b) => b?.type === 'text').map((b) => b.text || '').join('')
139
195
  : '';
@@ -147,21 +203,205 @@ function parseLine(line) {
147
203
  }
148
204
  } else if (obj.type === 'last-prompt') {
149
205
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
206
+ } else if (obj.type === 'user' && obj.message) {
207
+ // Top-level user message — only emit when content is a non-empty
208
+ // string. Array content carries tool_result blocks (API-shaped
209
+ // tool feedback), NOT a user prompt — skip those.
210
+ const content = obj.message.content;
211
+ if (typeof content === 'string' && content.length > 0) {
212
+ out.push({ type: 'user-message', text: content });
213
+ }
214
+ } else if (obj.type === 'attachment' && obj.attachment) {
215
+ const a = obj.attachment;
216
+ if (a.type === 'queued_command' && typeof a.prompt === 'string' && a.prompt.length > 0) {
217
+ out.push({ type: 'queue-folded', prompt: a.prompt });
218
+ }
150
219
  }
151
220
 
152
221
  return out;
153
222
  }
154
223
 
224
+ // ─── stateful aggregator (0.10.0 Phase 1) ────────────────────────────
225
+
155
226
  /**
156
- * Wrap a LogTail (or any EventEmitter that emits 'line') and
157
- * forward parsed events via 'event'. Returns the emitter so callers
158
- * can chain `.on('event', ...)`.
227
+ * SessionEventAggregator stateful JSONL event translator that
228
+ * coalesces a logical assistant message spanning multiple JSONL lines
229
+ * (all sharing one `message.id`).
230
+ *
231
+ * Contract:
232
+ * - `assistant-chunk` / `tool-use` / `usage` are emitted EAGERLY,
233
+ * per JSONL line — live streaming granularity is preserved.
234
+ * - `result` is emitted ONCE per `message.id`, when that message
235
+ * FINALIZES. A message finalizes when (a) a line with a different
236
+ * message.id arrives, (b) any non-assistant line arrives, or
237
+ * (c) `flush()` is called (turn-complete / tail-close safety net).
238
+ * - An assistant line WITHOUT a `message.id` is treated as its own
239
+ * standalone message and parsed per-line via `parseLine` — real
240
+ * claude always writes `message.id`; absent id only occurs in
241
+ * synthetic test fixtures, which keep their legacy behaviour.
242
+ *
243
+ * This is what fixes the zero-concurrency empty-turn bug: a `thinking`
244
+ * line no longer resolves the turn before its sibling `text` line.
245
+ */
246
+ class SessionEventAggregator {
247
+ constructor() {
248
+ // Buffered in-flight assistant message, or null.
249
+ // { id, sessionId, parentUuid, textParts: string[], stopReason }
250
+ this._asm = null;
251
+ }
252
+
253
+ /**
254
+ * Feed one raw JSONL line. Returns the events it produced (which may
255
+ * include the finalize of a PREVIOUSLY buffered message).
256
+ * @param {string} line
257
+ * @returns {object[]}
258
+ */
259
+ push(line) {
260
+ if (!line || typeof line !== 'string') return [];
261
+ let obj;
262
+ try { obj = JSON.parse(line); }
263
+ catch { return []; }
264
+ if (!obj || typeof obj !== 'object') return [];
265
+
266
+ const out = [];
267
+
268
+ if (obj.type === 'assistant' && obj.message) {
269
+ const msg = obj.message;
270
+ const id = (typeof msg.id === 'string' && msg.id.length > 0) ? msg.id : null;
271
+
272
+ // No message.id — synthetic/legacy line. Finalize any open
273
+ // buffer, then emit this line's events per-line exactly as the
274
+ // stateless parser would (no coalescing without an id to key on).
275
+ if (id === null) {
276
+ if (this._asm) out.push(...this._finalize());
277
+ out.push(...parseLine(line));
278
+ return out;
279
+ }
280
+
281
+ // A different message id means the previously buffered message
282
+ // is now complete.
283
+ if (this._asm && this._asm.id !== id) out.push(...this._finalize());
284
+ if (!this._asm) {
285
+ this._asm = {
286
+ id,
287
+ sessionId: obj.sessionId ?? null,
288
+ parentUuid: obj.parentUuid ?? null,
289
+ textParts: [],
290
+ stopReason: null,
291
+ };
292
+ }
293
+
294
+ // Eager per-line events. `result` is the ONLY deferred event.
295
+ const { textParts, toolUses } = extractContentBlocks(msg.content);
296
+ if (textParts.length > 0) {
297
+ const joined = joinAssistantText(textParts);
298
+ if (joined.length > 0) {
299
+ this._asm.textParts.push(joined);
300
+ out.push({ type: 'assistant-chunk', text: joined });
301
+ }
302
+ }
303
+ for (const t of toolUses) out.push(t);
304
+ const usage = extractUsage(obj);
305
+ if (usage) out.push(usage);
306
+ // Every line of a message repeats the message-level stop_reason;
307
+ // the last non-null one wins.
308
+ if (msg.stop_reason) this._asm.stopReason = msg.stop_reason;
309
+ return out;
310
+ }
311
+
312
+ // Any non-assistant line ends the current assistant message.
313
+ if (this._asm) out.push(...this._finalize());
314
+
315
+ if (obj.type === 'user' && obj.message) {
316
+ const content = obj.message.content;
317
+ if (typeof content === 'string' && content.length > 0) {
318
+ out.push({
319
+ type: 'user-message',
320
+ text: content,
321
+ parentUuid: obj.parentUuid ?? null,
322
+ promptId: obj.promptId ?? null,
323
+ });
324
+ }
325
+ } else if (obj.type === 'last-prompt') {
326
+ out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
327
+ } else if (obj.type === 'queue-operation') {
328
+ // The live queue-activity signal (0.10.0 §3). `enqueue` carries
329
+ // the pasted `content`; `dequeue` is bare. Phase 2's turn ledger
330
+ // consumes this to correlate folds vs new-turns by token.
331
+ out.push({
332
+ type: 'queue-operation',
333
+ operation: typeof obj.operation === 'string' ? obj.operation : null,
334
+ content: typeof obj.content === 'string' ? obj.content : null,
335
+ });
336
+ } else if (obj.type === 'attachment' && obj.attachment
337
+ && obj.attachment.type === 'queued_command'
338
+ && typeof obj.attachment.prompt === 'string'
339
+ && obj.attachment.prompt.length > 0) {
340
+ // Retained for parity with `parseLine`. Confirmed ABSENT from
341
+ // real claude 2.1.142 JSONL — the live fold signal is
342
+ // `queue-operation`. Harmless dead branch; Phase 2 retires it.
343
+ out.push({ type: 'queue-folded', prompt: obj.attachment.prompt });
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /**
349
+ * Finalize any buffered assistant message. Called on turn-complete
350
+ * and on tail close so the genuinely-last message of a session
351
+ * (which has no trailing line to trigger finalize) still surfaces
352
+ * its `result`. Idempotent.
353
+ * @returns {object[]}
354
+ */
355
+ flush() {
356
+ return this._asm ? this._finalize() : [];
357
+ }
358
+
359
+ /** @returns {object[]} the buffered message's `result`, or []. */
360
+ _finalize() {
361
+ const asm = this._asm;
362
+ this._asm = null;
363
+ // A message that never carried a stop_reason produced no turn end
364
+ // — its text already streamed via eager `assistant-chunk`. No
365
+ // `result`. (The `last-prompt` fallback in TmuxProcess covers a
366
+ // genuinely missing end_turn.)
367
+ if (!asm || !asm.stopReason) return [];
368
+ return [{
369
+ type: 'result',
370
+ subtype: asm.stopReason === 'end_turn' ? 'success' : asm.stopReason,
371
+ text: asm.textParts.join('\n\n'),
372
+ stopReason: asm.stopReason,
373
+ sessionId: asm.sessionId,
374
+ parentUuid: asm.parentUuid,
375
+ }];
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Wrap a LogTail (or any EventEmitter that emits 'line') with a
381
+ * `SessionEventAggregator` and forward parsed events via 'event'.
382
+ *
383
+ * The aggregator is flushed on the tail's 'close' so a session whose
384
+ * last assistant message has no trailing line still emits its
385
+ * `result`. The tail is also given a `flushParser()` method so the
386
+ * consumer (TmuxProcess) can force a flush at turn-complete.
387
+ *
388
+ * @returns the same emitter (chainable).
159
389
  */
160
390
  function pipeToParser(tail) {
391
+ const aggregator = new SessionEventAggregator();
161
392
  tail.on('line', (line) => {
162
- const events = parseLine(line);
163
- for (const ev of events) tail.emit('event', ev);
393
+ for (const ev of aggregator.push(line)) tail.emit('event', ev);
394
+ });
395
+ tail.on('close', () => {
396
+ for (const ev of aggregator.flush()) tail.emit('event', ev);
164
397
  });
398
+ // Exposed so TmuxProcess can finalize the buffered message the
399
+ // instant a turn is judged complete (e.g. capture-pane quiescence
400
+ // won the race) instead of waiting for the next JSONL line.
401
+ tail.flushParser = () => {
402
+ for (const ev of aggregator.flush()) tail.emit('event', ev);
403
+ };
404
+ tail._aggregator = aggregator;
165
405
  return tail;
166
406
  }
167
407
 
@@ -169,5 +409,6 @@ module.exports = {
169
409
  encodeCwd,
170
410
  sessionLogPath,
171
411
  parseLine,
412
+ SessionEventAggregator,
172
413
  pipeToParser,
173
414
  };
@@ -32,6 +32,7 @@ const childProcess = require('child_process');
32
32
  const crypto = require('crypto');
33
33
  const fs = require('fs');
34
34
  const path = require('path');
35
+ const { createAsyncLock } = require('../async-lock');
35
36
 
36
37
  // ─── Constants ───────────────────────────────────────────────────────
37
38
 
@@ -173,13 +174,16 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
173
174
  stderr: err.stderr,
174
175
  });
175
176
  }
176
- // Set a wide pane to reduce capture-pane wrap artifacts.
177
- // Best-effort if the set-option fails, capture-pane -J fallback
178
- // in captureWide() handles the wrap case.
177
+ // Try to widen the detached pane so claude TUI has room to render
178
+ // long lines. `resize-window` is the supported way; older
179
+ // attempts used a non-existent `pane-width` option that always
180
+ // errored (tmux 3.x: pane-width is a format variable, not a
181
+ // settable option). capture-pane -J in captureWide() handles
182
+ // any remaining wrap artifacts.
179
183
  try {
180
- await runFn('tmux', ['set-option', '-t', name, '-w', 'pane-width', String(paneWidth)]);
184
+ await runFn('tmux', ['resize-window', '-t', name, '-x', String(paneWidth)]);
181
185
  } catch (err) {
182
- logger.warn?.(`[tmux-runner] set-option pane-width failed for ${name}: ${err.message}`);
186
+ logger.debug?.(`[tmux-runner] resize-window failed for ${name}: ${err.message} (capture-pane -J handles wrap)`);
183
187
  }
184
188
  return name;
185
189
  }
@@ -193,6 +197,41 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
193
197
  await runFn('tmux', ['send-keys', '-t', name, key]);
194
198
  }
195
199
 
200
+ // rc.13.1: paste+Enter must be ATOMIC per session. Pre-rc.13.1 two
201
+ // concurrent pasteText+sendControl pairs could interleave in the
202
+ // TUI's bracketed-paste buffer — Ivan caught this on shumorobot
203
+ // 2026-05-15 (the 2233-char user JSONL entry contained one
204
+ // truncated polygram channel + a full nested polygram prompt for
205
+ // a different msg_id). Symptom: msg 696's paste was at byte
206
+ // `chat_id="-1003` when msg 698's autosteer paste cut in,
207
+ // concatenating two pastes into one TUI user message → the agent
208
+ // saw a malformed input → the reply attribution went sideways
209
+ // (msg 697 got msg 698's answer, msg 696 got served last).
210
+ //
211
+ // The async-lock is keyed by tmux session name, so different
212
+ // sessions don't block each other. Within one session, pasteText
213
+ // + sendControl(Enter) hold the lock atomically.
214
+ const inputLock = createAsyncLock();
215
+ async function pasteAndEnter(name, text) {
216
+ const release = await inputLock.acquire(name);
217
+ try {
218
+ const res = await pasteText(name, text);
219
+ await sendControl(name, 'Enter');
220
+ // L3 fix: small post-Enter drain so back-to-back
221
+ // pasteAndEnter calls don't race in the claude TUI's input
222
+ // handler. Pre-fix, when two injectUserMessage calls fired in
223
+ // quick succession (spike multi-2-rapid, ~2/5 failure rate),
224
+ // the TUI sometimes only enqueued ONE of the two pastes —
225
+ // the second's bracketed-paste-start collided with the first
226
+ // Enter's processing. 50ms is enough on the TUI we tested
227
+ // against (claude v2.1.142); see AGENTS.md pinned version.
228
+ await new Promise((r) => setTimeout(r, 50));
229
+ return res;
230
+ } finally {
231
+ release();
232
+ }
233
+ }
234
+
196
235
  /**
197
236
  * Push a multi-line text prompt into the pane.
198
237
  *
@@ -200,10 +239,19 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
200
239
  * 2. \n → MULTILINE_SEPARATOR (F-spike-3)
201
240
  * 3. set-buffer + paste-buffer (atomic; bracketed-paste-aware
202
241
  * in modern claude TUI versions)
242
+ * 4. brief drain delay so a subsequent send-keys (e.g. Enter) is
243
+ * processed as a key event by the TUI, NOT consumed as part of
244
+ * the paste's bracketed-paste content.
245
+ *
246
+ * INCIDENT (0.10.0-rc.2): without the drain delay, send-keys Enter
247
+ * fired immediately after paste-buffer was being swallowed by
248
+ * claude TUI's bracketed-paste handler — the paste sat in the input
249
+ * area unsubmitted. Manual `tmux send-keys ... Enter` unstuck it.
250
+ * 80ms is enough on macOS tmux 3.6a for the close-bracket ESC[201~
251
+ * to land before any subsequent key arrives.
203
252
  *
204
- * NO Enter is sent. Caller follows up with `sendControl(name, 'Enter')`
205
- * when they want to submit. (Splitting paste + Enter lets callers
206
- * verify the text landed via capture-pane before submitting.)
253
+ * NO Enter is sent here. Caller follows up with
254
+ * `sendControl(name, 'Enter')` when they want to submit.
207
255
  */
208
256
  async function pasteText(name, text) {
209
257
  const sanitized = sanitize(text);
@@ -218,6 +266,8 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
218
266
  await runFn('tmux', ['delete-buffer', '-b', bufName]).catch(() => {});
219
267
  throw err;
220
268
  }
269
+ // Drain delay — see incident note above.
270
+ await new Promise((r) => setTimeout(r, 80));
221
271
  return { sanitized, oneLine, stripped: text.length - sanitized.length };
222
272
  }
223
273
 
@@ -279,6 +329,7 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
279
329
  spawn,
280
330
  sendControl,
281
331
  pasteText,
332
+ pasteAndEnter,
282
333
  capturePane,
283
334
  captureWide,
284
335
  sessionExists,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.2",
3
+ "version": "0.10.0-rc.21",
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": {