open-claude-p 1.2.1 → 1.2.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.2.2] — 2026-09-13
11
+
12
+ Fixes the two defects listed as known issues in 1.2.1. Both were
13
+ pre-existing — they predate the 1.2.x work — and both were tracked to
14
+ root cause before anything was changed.
15
+
16
+ ### Fixed
17
+
18
+ - **Replies lost every space between words.** The upstream TUI does not
19
+ emit literal spaces between words on a redraw; it positions each word
20
+ with an absolute column move and lets the terminal leave the gap blank:
21
+
22
+ ```
23
+ A\x1b[5Gpseudoterminal\x1b[20Gis\x1b[23Ga\x1b[25Gsoftware
24
+ ```
25
+
26
+ `ansi-strip` translated only CUF (`ESC [ Pn C`) into spaces. CHA
27
+ (`ESC [ Pn G`) — which the current CLI uses for nearly every word — was
28
+ deleted by the general CSI strip, so the reply came back as
29
+ `Apseudoterminalisasoftware…`. The parser now tracks the cursor column
30
+ and expands any forward horizontal move, CUF and CHA alike, into the
31
+ run of spaces it would have left on screen. CUP (`H`/`f`) adopts the
32
+ column without padding, since it is a jump to another region rather
33
+ than a gap.
34
+
35
+ - **The CLI printed the TUI's spinner cell and usage meters around the
36
+ answer.** `cleanResponse` exists as the last line of defence for
37
+ PTY-extracted text — its own docstring says so — but only the chat
38
+ client ever called it. The CLI printed raw PTY text whenever the
39
+ upstream session file was unavailable, which is exactly when the scrub
40
+ is needed. Both CLI paths now scrub, and the pattern set gained the
41
+ rate-limit meters (`Usage ██░░ 16% (resets in 3h)`), the bare context
42
+ meter, and the spinner status cell with its token counters (`63✢ 88`).
43
+ Guards keep real prose intact: meters must carry bar glyphs, counter
44
+ cells must carry a digit beside the glyph and the multi-space padding
45
+ the TUI uses, and a reply that is legitimately just a number ("what is
46
+ 6*7" → "42") is never swallowed.
47
+
48
+ - **Sessions started from inside a Claude Code session were never
49
+ persisted.** When `ocp` itself runs under Claude Code — an agent
50
+ driving it, or a developer testing from one — the spawned `claude`
51
+ inherited the caller's session identity, most decisively
52
+ `CLAUDE_CODE_CHILD_SESSION`, and concluded it was a continuation of the
53
+ parent rather than a new session. It then wrote no session JSONL at
54
+ all: `sessionId` came back `null`, the clean transcript ocp prefers was
55
+ unavailable so every answer fell back to PTY-scraped text, and
56
+ `--continue` / `--resume` had nothing to resume. The driver now removes
57
+ the caller's session-identity variables when spawning. User-facing
58
+ configuration (`CLAUDE_CODE_SIMPLE`, `CLAUDE_CODE_SAFE_MODE`, provider
59
+ toggles, …) is untouched, an explicit value in `opts.env` still wins,
60
+ and `OCP_KEEP_PARENT_SESSION_ENV=1` restores the old behaviour.
61
+
62
+ ### Tests
63
+
64
+ - 237 → 243. New coverage: `test/ansi-cha.test.js` (absolute column moves,
65
+ including the verbatim byte sequence captured off the wire) and
66
+ `test/parent-session-env.test.js`. `test/clean-response.test.js` gains
67
+ the meter, spinner-cell and numeric-answer cases.
68
+
69
+ ### Verification
70
+
71
+ Each fix was reproduced first, then confirmed against the real CLI:
72
+ replaying the captured session through the parser now yields the sentence
73
+ with its spacing intact; a warm-daemon second turn prints the answer with
74
+ no chrome; and `--continue` recalls a word from the previous turn, which
75
+ it could not do before.
76
+
77
+ ---
78
+
10
79
  ## [1.2.1] — 2026-09-13
11
80
 
12
81
  Re-sync with Claude Code 2.1.270 and the current model lineup, and fix a
package/bin/cli.js CHANGED
@@ -65,6 +65,27 @@ const EXIT = {
65
65
  NOT_IMPLEMENTED: 8,
66
66
  };
67
67
 
68
+ /**
69
+ * Scrub TUI chrome out of PTY-extracted text.
70
+ *
71
+ * Only for text reconstructed from terminal frames. Text read from the
72
+ * upstream session JSONL is already clean and must be passed through
73
+ * untouched. Failures are swallowed: a scrub that throws must never cost
74
+ * the caller their answer.
75
+ *
76
+ * @param {string} text
77
+ * @returns {Promise<string>}
78
+ */
79
+ async function scrubPtyText(text) {
80
+ if (typeof text !== 'string' || text === '') return text;
81
+ try {
82
+ const { cleanResponse } = await import('../src/chat/index.js');
83
+ return cleanResponse(text);
84
+ } catch {
85
+ return text;
86
+ }
87
+ }
88
+
68
89
  async function main() {
69
90
  const { options, positional, unknown, errors, supplied } = parseArgv(process.argv.slice(2));
70
91
 
@@ -309,8 +330,10 @@ async function main() {
309
330
  process.stdout,
310
331
  );
311
332
  for (const ev of (result.events ?? [])) adapter.onEvent(ev);
333
+ // Daemon results are always PTY-extracted — the daemon does not read
334
+ // the upstream session file — so they need the same scrub.
312
335
  adapter.end({
313
- text: result.text,
336
+ text: await scrubPtyText(result.text),
314
337
  isError: result.isError,
315
338
  sessionId: result.sessionId,
316
339
  cost: result.cost,
@@ -466,17 +489,24 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
466
489
  let finalText = result.text;
467
490
  let usage = null;
468
491
  let toolsFromSession = [];
492
+ let fromSessionFile = false;
469
493
  if (!result.isError) {
470
494
  try {
471
495
  const { readSessionText } = await import('../src/chat/index.js');
472
496
  const sessionRead = await readSessionText(
473
497
  result.sessionId, startTime, options.cwd, { expectPrompt: prompt },
474
498
  );
475
- if (sessionRead?.text) finalText = sessionRead.text;
499
+ if (sessionRead?.text) { finalText = sessionRead.text; fromSessionFile = true; }
476
500
  if (sessionRead?.usage) usage = sessionRead.usage;
477
501
  if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
478
502
  } catch { /* keep PTY-extracted fallback */ }
479
503
  }
504
+ // The JSONL is always clean; PTY-extracted text is not. `cleanResponse`
505
+ // exists as the last line of defence for exactly this fallback, but the
506
+ // CLI never called it — only the chat client did — so a run that could
507
+ // not read the session file printed the TUI's spinner cell and usage
508
+ // meters around the answer.
509
+ if (!fromSessionFile) finalText = await scrubPtyText(finalText);
480
510
 
481
511
  adapter.end({
482
512
  text: finalText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-claude-p",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "A PTY-backed compatibility shim for `claude -p` (Claude Code headless/print mode). Drives the interactive `claude` CLI via node-pty and exposes the same option surface and stream-json contract. Ships as both a library and an `ocp` CLI binary.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/chat/index.js CHANGED
@@ -1328,7 +1328,28 @@ export const TUI_CHROME_PATTERNS = [
1328
1328
  // 2 MCPs | 4 hooks - Zero-config…"). These must NOT drop the whole
1329
1329
  // line — only the matched fragment. Order matters: longer / more
1330
1330
  // specific variants first so the shorter ones don't partial-match.
1331
+ // Spinner glyphs the TUI cycles through. Kept in sync with
1332
+ // `PATTERNS.spinnerLeading` in `src/parsers/tui-frame.js`.
1333
+ const SPINNER_GLYPHS = '✻✶✺✹✸✷✵●✢✳✽·✾✿❀❁❂❃❄❅❆❇❈❉❊❋';
1334
+
1331
1335
  export const TUI_CHROME_INLINE_PATTERNS = [
1336
+ // Rate-limit meters painted to the right of a streamed reply:
1337
+ // `Usage ██░░░░░░░░ 16% (resets in 3h 18m)`, `| Weekly ░░░ 3% (resets in 6d 1h)`.
1338
+ // Anchored on the bar glyphs so ordinary prose with a percentage is safe.
1339
+ new RegExp(
1340
+ String.raw`\s*\|?\s*(?:Usage|Weekly)\s*[█░▒▓]+\s*\d+%\s*\(resets in [^)]*\)`,
1341
+ 'gi',
1342
+ ),
1343
+ // Bare context meter: `██████░░░░ 22% │`.
1344
+ /\s*[█░▒▓]{2,}\s*\d+%\s*│?/g,
1345
+ // The animated status cell the TUI paints to the LEFT of a reply,
1346
+ // carrying token counters: `63✢ 88 `. Two guards keep real prose
1347
+ // safe: a digit must sit beside the glyph, and the cell must be
1348
+ // separated from the content by the multi-space padding the TUI uses.
1349
+ // That padding is what distinguishes the chrome `✳ 128 Here is …`
1350
+ // from an ordinary bullet like `· 5 minutes later`.
1351
+ new RegExp(String.raw`^\s*\d+\s*[${SPINNER_GLYPHS}]\s*\d*\s{2,}`, 'g'),
1352
+ new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]\s*\d+\s{2,}`, 'g'),
1332
1353
  /\s*\d+\s+CLAUDE\.md\s*\|\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
1333
1354
  /\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
1334
1355
  /\s*\d+\s+MCP servers?\s+need(?:s)?\s+auth\s*·\s*\/mcp/gi,
@@ -1344,7 +1365,7 @@ export const TUI_CHROME_INLINE_PATTERNS = [
1344
1365
  * @param {string} text
1345
1366
  */
1346
1367
  export function cleanResponse(text) {
1347
- return String(text ?? '')
1368
+ const cleaned = String(text ?? '')
1348
1369
  .replace(/\r/g, '\n')
1349
1370
  .split('\n')
1350
1371
  .map((l) => l.trimEnd())
@@ -1368,4 +1389,19 @@ export function cleanResponse(text) {
1368
1389
  .replace(/\n{3,}/g, '\n\n')
1369
1390
  .replace(SENTINEL_REGEX, '')
1370
1391
  .trim();
1392
+
1393
+ // The TUI paints bare token counters (`63`, `128`) around a streamed
1394
+ // reply, and once the surrounding chrome is gone they are left stranded
1395
+ // on their own lines. Drop them — but ONLY when other content survives,
1396
+ // so a reply that is legitimately just a number ("what is 6*7" -> "42")
1397
+ // is never swallowed.
1398
+ const lines = cleaned.split('\n');
1399
+ // A counter cell is a bare integer, optionally still carrying the
1400
+ // spinner glyph it was painted next to.
1401
+ const isBareCounter = (l) =>
1402
+ new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]?\s*\d{1,6}\s*$`).test(l);
1403
+ if (lines.some((l) => l.trim() !== '' && !isBareCounter(l))) {
1404
+ return lines.filter((l) => !isBareCounter(l)).join('\n').trim();
1405
+ }
1406
+ return cleaned;
1371
1407
  }
package/src/index.js CHANGED
@@ -143,6 +143,51 @@ export function sanitizePassThroughArgv(argv) {
143
143
  * `runOneShot()` spawns a fresh PTY session; pass `poolSize: N` to opt
144
144
  * into warm-session reuse.
145
145
  */
146
+ // Environment variables that identify the *calling* Claude Code session —
147
+ // its id, its IPC bridge, its resolved binary — rather than configuring
148
+ // Claude generally. When `ocp` itself runs inside a Claude Code session
149
+ // (an agent driving it, or a developer testing from one) the child
150
+ // `claude` inherits all of them and concludes it is a continuation of the
151
+ // parent rather than a new session. The visible consequence is that it
152
+ // writes no session JSONL at all: `sessionId` comes back null, the clean
153
+ // transcript ocp prefers is unavailable so answers fall back to
154
+ // PTY-scraped text, and `--continue` / `--resume` have nothing to resume.
155
+ //
156
+ // The child is a new, independent session, so these must not carry over.
157
+ // User-facing configuration (`CLAUDE_CODE_SIMPLE`, `CLAUDE_CODE_SAFE_MODE`,
158
+ // provider toggles, …) is deliberately NOT in this list and passes through
159
+ // untouched. Set OCP_KEEP_PARENT_SESSION_ENV=1 to disable the scrub.
160
+ const PARENT_SESSION_ENV = [
161
+ 'CLAUDE_CODE_SESSION_ID',
162
+ 'CLAUDE_CODE_BRIDGE_SESSION_ID',
163
+ 'CLAUDE_CODE_CHILD_SESSION',
164
+ 'CLAUDE_CODE_ENTRYPOINT',
165
+ 'CLAUDE_CODE_MESSAGING_SOCKET',
166
+ 'CLAUDE_CODE_MESSAGING_TOKEN',
167
+ 'CLAUDE_CODE_EXECPATH',
168
+ ];
169
+
170
+ /**
171
+ * Remove the calling Claude Code session's identity from a spawn env.
172
+ *
173
+ * Returns a new object; the input is not mutated. An explicit value in
174
+ * `opts.env` wins — if a caller deliberately sets one of these, we honour
175
+ * it rather than second-guessing them.
176
+ *
177
+ * @param {Record<string, string|undefined>} env
178
+ * @param {Record<string, string|undefined>} [explicit] caller-supplied overrides
179
+ * @returns {Record<string, string|undefined>}
180
+ */
181
+ export function scrubParentSessionEnv(env, explicit = {}) {
182
+ if (process.env.OCP_KEEP_PARENT_SESSION_ENV === '1') return { ...env };
183
+ const out = { ...env };
184
+ for (const key of PARENT_SESSION_ENV) {
185
+ if (Object.prototype.hasOwnProperty.call(explicit, key)) continue;
186
+ delete out[key];
187
+ }
188
+ return out;
189
+ }
190
+
146
191
  export function createDriver(driverOpts = {}) {
147
192
  return new Driver(driverOpts);
148
193
  }
@@ -252,7 +297,7 @@ class Driver {
252
297
  process.env.OCP_PRINT_MODE === 'true';
253
298
  if (printMode) {
254
299
  const cwd = req.cwd ?? this.opts.cwd;
255
- const env = { ...process.env, ...this.opts.env };
300
+ const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
256
301
  const r = await runPrintMode({
257
302
  bin: this.opts.claudeBin,
258
303
  req,
@@ -331,7 +376,7 @@ class Driver {
331
376
  // Pool eligibility: explicit resume/continue bind to a specific past
332
377
  // session and are not poolable (they bypass the pool entirely).
333
378
  const cwd = req.cwd ?? this.opts.cwd;
334
- const env = { ...process.env, ...this.opts.env };
379
+ const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
335
380
  const pool = this._getPool();
336
381
  const isResumeLike =
337
382
  (typeof req.resume === 'string' && req.resume !== '') ||
@@ -1,8 +1,24 @@
1
1
  // Baseline parser: strip ANSI escape sequences.
2
2
  //
3
- // We do not attempt to reconstruct a terminal screen here. Cursor moves,
4
- // clears, and color codes are simply removed. The remaining text is what
5
- // downstream parsers consume.
3
+ // We do not attempt to reconstruct a terminal screen here. Colors, clears
4
+ // and vertical moves are simply removed. The one thing we DO model is the
5
+ // cursor's horizontal position, because the upstream TUI uses horizontal
6
+ // moves in place of literal spaces:
7
+ //
8
+ // A\x1b[5Gpseudoterminal\x1b[20Gis\x1b[23Ga\x1b[25Gsoftware
9
+ //
10
+ // Every inter-word gap there is an absolute column move, not a space. Strip
11
+ // the sequences blindly and the words run together — "Apseudoterminalisa…".
12
+ // So we track the column and translate a forward move into the run of
13
+ // spaces it would have left blank on screen.
14
+ //
15
+ // Two sequences produce those gaps:
16
+ // CUF — `ESC [ Pn C` move right Pn columns (relative, default 1)
17
+ // CHA — `ESC [ Pn G` move to column Pn (absolute, 1-based, default 1)
18
+ //
19
+ // CUF alone used to be enough; the current CLI emits CHA for nearly every
20
+ // word, which is what made this worth modelling properly rather than
21
+ // special-casing one sequence.
6
22
  //
7
23
  // Buffering rule for partial chunks: if the chunk ends with an ESC that has
8
24
  // not yet been terminated (e.g. mid-CSI), hold it until the next chunk.
@@ -10,37 +26,9 @@
10
26
  const ESC = '\x1b';
11
27
  const BEL = '\x07';
12
28
 
13
- // Cursor Forward (CUF): `ESC [ Pn C` advances the cursor `Pn` columns
14
- // (default 1). In headless captures the upstream `claude` CLI uses this
15
- // in place of literal space characters for inter-word gaps (a TUI
16
- // rendering optimization). Stripping the sequence collapses words
17
- // together — `each<CUF1>other` becomes `eachother`. We translate CUF
18
- // into the equivalent run of spaces BEFORE the general CSI strip so the
19
- // visible text is preserved.
20
- const RE_CSI_CURSOR_FORWARD = /\x1b\[(\d*)C/g;
21
- function expandCursorForward(text) {
22
- return text.replace(RE_CSI_CURSOR_FORWARD, (_match, n) => {
23
- const count = parseInt(n || '1', 10);
24
- return ' '.repeat(Math.min(count, 200)); // cap defensively
25
- });
26
- }
27
-
28
- // Complete-sequence regexes — used only on input slices we have proven to be
29
- // complete (i.e. without a dangling partial at the end).
30
- const RE_CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g; // ESC [ params intermediates final
31
- // OSC-style sequences: ESC introducer then arbitrary content then BEL or ST.
32
- // Covers OSC (`]`), DCS (`P`), SOS (`X`), PM (`^`), APC (`_`).
33
- const RE_STRING_TERMINATED =
34
- /\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)/g;
35
- // Two-byte ESC forms — covers C1 7-bit aliases (`ESC @`..`ESC _` except the
36
- // CSI/OSC/etc. introducers, which are handled above) AND the DEC private
37
- // single-letter sequences in the 0x30..0x3F range (`ESC 7` = DECSC save
38
- // cursor, `ESC 8` = DECRC restore, `ESC =` / `ESC >` keypad modes, etc.).
39
- // Order is: CSI -> string-terminated -> this, so by the time we reach
40
- // RE_C1 the only `\x1b[`/`\x1b]`/`\x1b_`/... left in the input is either
41
- // complete-and-already-stripped or partial-and-held-back by the pending
42
- // buffer.
43
- const RE_C1 = /\x1b[0-?@-_]/g;
29
+ // Defensive ceiling on generated padding. A corrupt or hostile column
30
+ // target must not let one escape sequence expand into megabytes.
31
+ const MAX_PAD = 200;
44
32
 
45
33
  /** Introducer characters for OSC-style sequences (BEL/ST terminated). */
46
34
  const STRING_INTRODUCERS = new Set([']', 'P', 'X', '^', '_']);
@@ -56,11 +44,136 @@ function isCompleteEscapeTail(tail) {
56
44
  return tail.length >= 2;
57
45
  }
58
46
 
47
+ /** First numeric parameter of a CSI parameter string, or `fallback`. */
48
+ function firstParam(params, fallback) {
49
+ const n = parseInt(params, 10);
50
+ return Number.isNaN(n) ? fallback : n;
51
+ }
52
+
53
+ /** Nth (0-based) numeric parameter of a CSI parameter string. */
54
+ function nthParam(params, index, fallback) {
55
+ const raw = params.split(';')[index];
56
+ if (raw === undefined || raw === '') return fallback;
57
+ const n = parseInt(raw, 10);
58
+ return Number.isNaN(n) ? fallback : n;
59
+ }
60
+
61
+ /**
62
+ * Strip escapes from `input` while tracking the cursor column, expanding
63
+ * forward horizontal moves into spaces.
64
+ *
65
+ * `state.col` is the 0-based column and persists across chunks, since a
66
+ * rendered line routinely spans several PTY reads.
67
+ *
68
+ * @param {string} input
69
+ * @param {{col: number}} state
70
+ * @returns {string}
71
+ */
72
+ function stripTrackingColumn(input, state) {
73
+ let out = '';
74
+ let i = 0;
75
+ const len = input.length;
76
+
77
+ while (i < len) {
78
+ const ch = input[i];
79
+
80
+ if (ch === '\r' || ch === '\n') {
81
+ out += ch;
82
+ state.col = 0;
83
+ i += 1;
84
+ continue;
85
+ }
86
+
87
+ if (ch !== ESC) {
88
+ out += ch;
89
+ state.col += 1;
90
+ i += 1;
91
+ continue;
92
+ }
93
+
94
+ const c1 = input[i + 1];
95
+
96
+ // ── CSI: ESC [ params intermediates final ────────────────────────────
97
+ if (c1 === '[') {
98
+ let j = i + 2;
99
+ while (j < len) {
100
+ const c = input.charCodeAt(j);
101
+ if (c >= 0x30 && c <= 0x3f) j += 1; // parameter bytes
102
+ else break;
103
+ }
104
+ const paramsEnd = j;
105
+ while (j < len) {
106
+ const c = input.charCodeAt(j);
107
+ if (c >= 0x20 && c <= 0x2f) j += 1; // intermediate bytes
108
+ else break;
109
+ }
110
+ if (j >= len) {
111
+ // Incomplete despite the pending-buffer guard; drop the remainder.
112
+ break;
113
+ }
114
+ const final = input[j];
115
+ const params = input.slice(i + 2, paramsEnd);
116
+ i = j + 1;
117
+
118
+ // Private-parameter sequences (`ESC [ ? 25 h`) never move the cursor
119
+ // horizontally in a way we can model; strip them.
120
+ if (params.charCodeAt(0) >= 0x3c && params.charCodeAt(0) <= 0x3f) continue;
121
+
122
+ if (final === 'C') {
123
+ // CUF — relative forward move.
124
+ const n = Math.max(0, firstParam(params, 1));
125
+ const pad = Math.min(n, MAX_PAD);
126
+ out += ' '.repeat(pad);
127
+ state.col += pad;
128
+ } else if (final === 'G' || final === '`') {
129
+ // CHA / HPA — absolute column, 1-based.
130
+ const target = Math.max(1, firstParam(params, 1)) - 1;
131
+ if (target > state.col) {
132
+ const pad = Math.min(target - state.col, MAX_PAD);
133
+ out += ' '.repeat(pad);
134
+ state.col += pad;
135
+ }
136
+ // A backwards move would overwrite already-emitted text. We cannot
137
+ // represent that without a screen model, so we neither pad nor
138
+ // rewind — the text simply continues.
139
+ } else if (final === 'H' || final === 'f') {
140
+ // CUP — absolute row;col. This is a jump to another part of the
141
+ // screen (status line, input box), so it never means "gap here";
142
+ // adopt the column but emit no padding.
143
+ state.col = Math.max(1, nthParam(params, 1, 1)) - 1;
144
+ }
145
+ // Everything else (SGR, erase, vertical moves, scroll regions) is
146
+ // zero-width for our purposes: stripped, column untouched.
147
+ continue;
148
+ }
149
+
150
+ // ── OSC and friends: ESC ] … BEL | ST ────────────────────────────────
151
+ if (STRING_INTRODUCERS.has(c1)) {
152
+ let j = i + 2;
153
+ while (j < len) {
154
+ if (input[j] === BEL) { j += 1; break; }
155
+ if (input[j] === ESC && input[j + 1] === '\\') { j += 2; break; }
156
+ j += 1;
157
+ }
158
+ i = j;
159
+ continue;
160
+ }
161
+
162
+ // ── Two-byte ESC forms (ESC 7, ESC 8, ESC =, C1 aliases) ─────────────
163
+ if (c1 === undefined) break; // dangling ESC; pending guard should catch it
164
+ i += 2;
165
+ }
166
+
167
+ return out;
168
+ }
169
+
59
170
  export const ansiStripParser = {
60
171
  name: 'ansi-strip',
61
172
  priority: 10,
62
173
  create() {
63
174
  let pending = '';
175
+ // Cursor column persists across feeds — a line spans many PTY reads.
176
+ const state = { col: 0 };
64
177
  return {
65
178
  feed(chunk) {
66
179
  let input = pending + chunk;
@@ -77,17 +190,11 @@ export const ansiStripParser = {
77
190
  }
78
191
  }
79
192
 
80
- // Translate visible-gap CSI sequences (cursor forward) into
81
- // literal spaces BEFORE the general CSI strip eats them.
82
- const expanded = expandCursorForward(input);
83
- const out = expanded
84
- .replace(RE_CSI, '')
85
- .replace(RE_STRING_TERMINATED, '')
86
- .replace(RE_C1, '');
87
- return { text: out, events: [] };
193
+ return { text: stripTrackingColumn(input, state), events: [] };
88
194
  },
89
195
  reset() {
90
196
  pending = '';
197
+ state.col = 0;
91
198
  },
92
199
  };
93
200
  },