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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/autosteered-refs.js +20 -2
- package/lib/claude-bin.js +78 -0
- package/lib/db/sessions.js +97 -1
- package/lib/handlers/autosteer.js +6 -0
- package/lib/process/tmux-process.js +967 -216
- package/lib/process-manager.js +56 -2
- package/lib/sdk/callbacks.js +219 -0
- package/lib/tmux/session-log-parser.js +302 -61
- package/lib/tmux/tmux-runner.js +59 -8
- package/package.json +1 -1
- package/polygram.js +150 -29
|
@@ -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
|
-
*
|
|
13
|
+
* # Two parsers, one event surface
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
24
|
-
* - assistant
|
|
25
|
-
* - assistant
|
|
26
|
-
* -
|
|
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:
|
|
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-
|
|
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
|
|
77
|
-
* line carries nothing observable. Malformed JSON →
|
|
161
|
+
* Parse one JSONL line into Process-shaped events, OR [] when the
|
|
162
|
+
* line carries nothing observable. Malformed JSON → [].
|
|
78
163
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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[]}
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
|
163
|
-
|
|
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
|
};
|
package/lib/tmux/tmux-runner.js
CHANGED
|
@@ -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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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', ['
|
|
184
|
+
await runFn('tmux', ['resize-window', '-t', name, '-x', String(paneWidth)]);
|
|
181
185
|
} catch (err) {
|
|
182
|
-
logger.
|
|
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
|
|
205
|
-
* when they want to submit.
|
|
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.
|
|
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": {
|