open-claude-p 1.1.3 → 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.
@@ -2,3 +2,4 @@
2
2
  export { OPTION_SPEC, getOption } from './spec.js';
3
3
  export { parseArgv } from './parse-argv.js';
4
4
  export { CROSS_RULES, validate } from './validate.js';
5
+ export { forwardedRequestFields, fieldNameFor } from './forward.js';
@@ -26,15 +26,20 @@ import { OPTION_SPEC, getOption } from './spec.js';
26
26
  * @property {string[]} positional
27
27
  * @property {string[]} unknown Tokens that look like flags but were not in the spec.
28
28
  * @property {string[]} errors Human-readable error strings.
29
+ * @property {Set<string>} supplied Canonical names actually present in argv.
30
+ * Defaults are pre-seeded into `options`, so a value alone cannot tell
31
+ * "user passed `--flag=false`" apart from "user passed nothing" — this
32
+ * set is the only way to distinguish them.
29
33
  */
30
34
 
31
35
  /**
32
36
  * @param {string[]} argv
37
+ * @param {Record<string, string|undefined>} [env] defaults to `process.env`
33
38
  * @returns {ParseResult}
34
39
  */
35
- export function parseArgv(argv) {
40
+ export function parseArgv(argv, env = process.env) {
36
41
  /** @type {ParseResult} */
37
- const out = { options: {}, positional: [], unknown: [], errors: [] };
42
+ const out = { options: {}, positional: [], unknown: [], errors: [], supplied: new Set() };
38
43
 
39
44
  // Pre-seed defaults so the validator sees them.
40
45
  for (const spec of OPTION_SPEC) {
@@ -43,6 +48,14 @@ export function parseArgv(argv) {
43
48
  }
44
49
  }
45
50
 
51
+ // Canonical names supplied on the command line. Used only by the
52
+ // env-var pass at the end: argv always wins over the environment, and
53
+ // that has to include an explicit opt-out like `--print-mode=false`,
54
+ // whose value is indistinguishable from the default if you compare
55
+ // values instead of tracking what was actually seen.
56
+ /** @type {Set<string>} */
57
+ const supplied = out.supplied;
58
+
46
59
  let i = 0;
47
60
  while (i < argv.length) {
48
61
  const tok = argv[i];
@@ -63,6 +76,7 @@ export function parseArgv(argv) {
63
76
  i++;
64
77
  continue;
65
78
  }
79
+ supplied.add(spec.name);
66
80
  i = consumeOption(spec, argv, i, inline, out);
67
81
  continue;
68
82
  }
@@ -93,11 +107,13 @@ export function parseArgv(argv) {
93
107
  );
94
108
  break;
95
109
  }
110
+ supplied.add(spec.name);
96
111
  i = consumeOption(spec, argv, i, undefined, out);
97
112
  consumedByValue = true;
98
113
  break;
99
114
  }
100
115
  // Boolean short: just set.
116
+ supplied.add(spec.name);
101
117
  out.options[spec.name] = true;
102
118
  }
103
119
  if (!consumedByValue) i++;
@@ -109,9 +125,64 @@ export function parseArgv(argv) {
109
125
  i++;
110
126
  }
111
127
 
128
+ applyEnvFallbacks(out, supplied, env);
129
+
112
130
  return out;
113
131
  }
114
132
 
133
+ /**
134
+ * Fill in options that were not supplied on the command line from their
135
+ * declared `env:` variable.
136
+ *
137
+ * OPTION_SPEC has always documented an `env` field, but nothing read it —
138
+ * the two entries that used it (`--print-mode`, `--no-session-persistence`)
139
+ * were wired up by hand at their call sites instead, so any new entry that
140
+ * declared `env` silently did nothing. This makes the declaration real, and
141
+ * keeps the precedence the hand-rolled versions already used:
142
+ *
143
+ * explicit argv > environment variable > spec default
144
+ *
145
+ * Booleans accept `1` / `true` / `yes` / `on` (case-insensitive); anything
146
+ * else, including the empty string, leaves the option at its default so
147
+ * `VAR=` and `VAR=0` both read as "off". Value-taking options are coerced
148
+ * through the same `coerce()` path as argv, and a malformed value produces
149
+ * the same style of error rather than being silently ignored.
150
+ *
151
+ * @param {ParseResult} out
152
+ * @param {Set<string>} supplied canonical names seen on the command line
153
+ * @param {Record<string, string|undefined>} env
154
+ */
155
+ function applyEnvFallbacks(out, supplied, env) {
156
+ for (const spec of OPTION_SPEC) {
157
+ if (!spec.env) continue;
158
+ const raw = Object.prototype.hasOwnProperty.call(env ?? {}, spec.env)
159
+ ? env[spec.env]
160
+ : undefined;
161
+ // Library callers can pass any object as `env`; only honour real
162
+ // string values so a stray number/object cannot throw in `.trim()`.
163
+ if (typeof raw !== 'string' || raw === '') continue;
164
+ // Supplied on the command line — argv always wins.
165
+ if (supplied.has(spec.name)) continue;
166
+
167
+ if (spec.kind === 'boolean') {
168
+ const v = raw.trim().toLowerCase();
169
+ if (v === '1' || v === 'true' || v === 'yes' || v === 'on') {
170
+ out.options[spec.name] = true;
171
+ }
172
+ continue;
173
+ }
174
+ if (spec.kind === 'array') {
175
+ // Comma-separated, so a single env var can carry a repeatable flag.
176
+ const items = raw.split(',').map((x) => x.trim()).filter(Boolean);
177
+ if (items.length > 0) out.options[spec.name] = items;
178
+ continue;
179
+ }
180
+ const { value, error } = coerce(spec, raw);
181
+ if (error) out.errors.push(`${spec.env}: ${error}`);
182
+ else out.options[spec.name] = value;
183
+ }
184
+ }
185
+
115
186
  /**
116
187
  * Consume the value(s) for `spec` starting at argv index `i` (where argv[i]
117
188
  * is the flag token itself). `inline` is the post-`=` value when present.
@@ -17,6 +17,9 @@
17
17
  // times (occurrences accumulate); array kind also collects
18
18
  // variadic values until the next flag-looking token.
19
19
  // env: Optional environment-variable fallback.
20
+ // printModeOnly: Only forward this flag when running under
21
+ // `--print-mode`. For upstream flags that require
22
+ // `--print` and abort the process without it.
20
23
  // description: One-line help text.
21
24
  // forward: How this option reaches the upstream `claude` process.
22
25
  // { type: 'argv', flag: '--name' } pass to spawn argv
@@ -72,7 +75,11 @@ export const OPTION_SPEC = [
72
75
  {
73
76
  name: 'model',
74
77
  kind: 'string',
75
- description: 'Model alias or full name (e.g. `sonnet`, `claude-sonnet-4-6`).',
78
+ description:
79
+ 'Model alias or full name. Aliases resolve to the latest model in ' +
80
+ 'that family (e.g. `fable`, `opus`, `sonnet`, `haiku`); full names ' +
81
+ 'are passed through verbatim (e.g. `claude-opus-5`, ' +
82
+ '`claude-fable-5-1`, `claude-sonnet-5`).',
76
83
  forward: { type: 'argv', flag: '--model' },
77
84
  },
78
85
  {
@@ -225,6 +232,24 @@ export const OPTION_SPEC = [
225
232
  '(stream-json input + output only).',
226
233
  forward: { type: 'shim' },
227
234
  },
235
+ {
236
+ name: 'forward-subagent-text',
237
+ kind: 'boolean',
238
+ default: false,
239
+ description:
240
+ 'Forward subagent text and thinking blocks as assistant/user messages ' +
241
+ 'with `parent_tool_use_id` set. Requires `--output-format=stream-json`.',
242
+ forward: { type: 'argv', flag: '--forward-subagent-text' },
243
+ },
244
+ {
245
+ name: 'prompt-suggestions',
246
+ kind: 'boolean',
247
+ default: false,
248
+ description:
249
+ 'Emit a `prompt_suggestion` message after each turn carrying a ' +
250
+ 'predicted next user prompt (stream-json output).',
251
+ forward: { type: 'argv', flag: '--prompt-suggestions' },
252
+ },
228
253
  {
229
254
  name: 'include-hook-events',
230
255
  kind: 'boolean',
@@ -236,7 +261,18 @@ export const OPTION_SPEC = [
236
261
  name: 'include-partial-messages',
237
262
  kind: 'boolean',
238
263
  default: false,
239
- description: 'Include partial assistant message chunks in stream-json output.',
264
+ // Upstream refuses this flag unless it is also given `--print`
265
+ // ("Error: --include-partial-messages requires --print and
266
+ // --output-format=stream-json") and exits immediately. The default
267
+ // PTY path drives the interactive TUI, so forwarding it there kills
268
+ // the spawn. ocp already synthesises `assistant-partial` events from
269
+ // the TUI frames on that path, so the flag has nothing to add there
270
+ // anyway — it is forwarded only under `--print-mode`.
271
+ printModeOnly: true,
272
+ description:
273
+ 'Include partial assistant message chunks in stream-json output. ' +
274
+ 'Forwarded upstream only under `--print-mode`; on the default PTY ' +
275
+ 'path ocp emits `assistant-partial` events itself.',
240
276
  forward: { type: 'argv', flag: '--include-partial-messages' },
241
277
  },
242
278
 
@@ -244,8 +280,11 @@ export const OPTION_SPEC = [
244
280
  {
245
281
  name: 'effort',
246
282
  kind: 'enum',
247
- choices: ['low', 'medium', 'high', 'max'],
248
- description: 'Effort level the model should apply.',
283
+ choices: ['low', 'medium', 'high', 'xhigh', 'max'],
284
+ description:
285
+ 'Effort level the model should apply (`low` | `medium` | `high` | ' +
286
+ '`xhigh` | `max`). `xhigh` sits between `high` and `max` and is the ' +
287
+ 'upstream default for coding and agentic work.',
249
288
  forward: { type: 'argv', flag: '--effort' },
250
289
  },
251
290
  {
@@ -281,6 +320,14 @@ export const OPTION_SPEC = [
281
320
  description: 'API-side task budget in tokens. Shim-enforced.',
282
321
  forward: { type: 'shim' },
283
322
  },
323
+ {
324
+ name: 'autocompact',
325
+ kind: 'string',
326
+ description:
327
+ 'Auto-compact window size: `auto`, or an explicit token budget ' +
328
+ 'between 100k and 1M (e.g. `200000`, `500k`).',
329
+ forward: { type: 'argv', flag: '--autocompact' },
330
+ },
284
331
  {
285
332
  name: 'fallback-model',
286
333
  kind: 'string',
@@ -293,8 +340,10 @@ export const OPTION_SPEC = [
293
340
  name: 'permission-mode',
294
341
  kind: 'string',
295
342
  description:
296
- 'Permission mode (`default` | `plan` | `acceptEdits` | ' +
297
- '`bypassPermissions` | `dontAsk` | `auto`).',
343
+ 'Permission mode (`acceptEdits` | `auto` | `bypassPermissions` | ' +
344
+ '`manual` | `dontAsk` | `plan`). `manual` is the current name for ' +
345
+ "the interactive prompt-on-every-tool mode; upstream still accepts " +
346
+ 'the older `default` spelling, so ocp keeps accepting it too.',
298
347
  forward: { type: 'argv', flag: '--permission-mode' },
299
348
  },
300
349
  {
@@ -305,6 +354,35 @@ export const OPTION_SPEC = [
305
354
  'Allow `--dangerously-skip-permissions` as a choice without enabling it.',
306
355
  forward: { type: 'argv', flag: '--allow-dangerously-skip-permissions' },
307
356
  },
357
+ {
358
+ name: 'permission-prompts',
359
+ kind: 'enum',
360
+ choices: ['host', 'none'],
361
+ description:
362
+ 'Who answers permission prompts: `host` (the SDK host, or the tool ' +
363
+ 'named by `--permission-prompt-tool`) or `none` (nobody — anything ' +
364
+ 'that would prompt is denied automatically; the permission mode ' +
365
+ 'still decides everything else). `none` suits PTY automation, which ' +
366
+ 'has no way to answer a prompt, when denying is preferable to ' +
367
+ 'bypassing checks outright.',
368
+ forward: { type: 'argv', flag: '--permission-prompts' },
369
+ },
370
+ {
371
+ name: 'restricted',
372
+ kind: 'boolean',
373
+ default: false,
374
+ description:
375
+ 'Restricted mode: drop the built-in tools that run commands or code ' +
376
+ '(Bash, PowerShell, REPL, …) and WebFetch unless `--tools` names ' +
377
+ 'them, ignore user/project/local settings files (managed settings ' +
378
+ 'and `--settings` still apply), confine the file tools to the ' +
379
+ 'working directories, and refuse `bypassPermissions`. Pair with ' +
380
+ '`--strict-mcp-config` to skip MCP servers too. Note that ocp does ' +
381
+ 'not get its usual clean transcript in this mode — no session ' +
382
+ 'JSONL is written for the run, so the reply is reconstructed from ' +
383
+ 'the TUI frames and may carry leftover box-border chrome.',
384
+ forward: { type: 'argv', flag: '--restricted' },
385
+ },
308
386
  {
309
387
  name: 'tools',
310
388
  kind: 'string',
@@ -353,6 +431,29 @@ export const OPTION_SPEC = [
353
431
  description: 'Append to the system prompt from a file path.',
354
432
  forward: { type: 'argv', flag: '--append-system-prompt-file' },
355
433
  },
434
+ {
435
+ name: 'system-prompt-snapshot',
436
+ kind: 'enum',
437
+ choices: ['on', 'off'],
438
+ description:
439
+ 'Record the system prompt once per conversation and reuse it ' +
440
+ 'verbatim on every later request and resume (`on`, the upstream ' +
441
+ 'default), or render it fresh every request (`off`, for iterating ' +
442
+ 'on prompt text). With `on`, a later run passing different prompt ' +
443
+ 'text is ignored until the conversation is compacted.',
444
+ forward: { type: 'argv', flag: '--system-prompt-snapshot' },
445
+ },
446
+ {
447
+ name: 'exclude-dynamic-system-prompt-sections',
448
+ kind: 'boolean',
449
+ default: false,
450
+ description:
451
+ 'Move per-machine sections (cwd, env info, memory paths, git status) ' +
452
+ 'out of the system prompt and into the first user message, so the ' +
453
+ 'cached prompt prefix is identical across machines and users. Ignored ' +
454
+ 'when `--system-prompt` replaces the default prompt.',
455
+ forward: { type: 'argv', flag: '--exclude-dynamic-system-prompt-sections' },
456
+ },
356
457
  {
357
458
  name: 'add-dir',
358
459
  kind: 'array',
@@ -387,6 +488,13 @@ export const OPTION_SPEC = [
387
488
  description: 'Load plugins from directory (repeatable).',
388
489
  forward: { type: 'argv', flag: '--plugin-dir' },
389
490
  },
491
+ {
492
+ name: 'plugin-url',
493
+ kind: 'array',
494
+ repeatable: true,
495
+ description: 'Fetch a plugin `.zip` from a URL for this session only (repeatable).',
496
+ forward: { type: 'argv', flag: '--plugin-url' },
497
+ },
390
498
  {
391
499
  name: 'disable-slash-commands',
392
500
  kind: 'boolean',
@@ -455,6 +563,38 @@ export const OPTION_SPEC = [
455
563
  description: 'Run Setup hooks with the `maintenance` trigger, then continue.',
456
564
  forward: { type: 'argv', flag: '--maintenance' },
457
565
  },
566
+ {
567
+ name: 'safe-mode',
568
+ kind: 'boolean',
569
+ default: false,
570
+ description:
571
+ 'Start with all customizations disabled (CLAUDE.md, skills, plugins, ' +
572
+ 'hooks, MCP servers, custom commands and agents, output styles, ' +
573
+ 'workflows, themes, keybindings). Admin policy settings still apply. ' +
574
+ 'Sets `CLAUDE_CODE_SAFE_MODE=1`. Useful for reproducing a failure ' +
575
+ 'without local configuration in the way.',
576
+ forward: { type: 'argv', flag: '--safe-mode' },
577
+ },
578
+ {
579
+ name: 'ax-screen-reader',
580
+ kind: 'boolean',
581
+ default: false,
582
+ env: 'OCP_AX_SCREEN_READER',
583
+ description:
584
+ 'Ask the upstream TUI for screen-reader friendly output: flat text, ' +
585
+ 'no decorative borders, no animations. Because ocp reconstructs its ' +
586
+ 'answer by scraping TUI frames, this markedly reduces the amount of ' +
587
+ 'chrome the parsers have to strip. Opt-in — it changes the captured ' +
588
+ 'frame shape, so it is never enabled by default.',
589
+ forward: { type: 'argv', flag: '--ax-screen-reader' },
590
+ },
591
+ {
592
+ name: 'brief',
593
+ kind: 'boolean',
594
+ default: false,
595
+ description: 'Enable the SendUserMessage tool for agent-to-user communication.',
596
+ forward: { type: 'argv', flag: '--brief' },
597
+ },
458
598
  {
459
599
  name: 'debug-file',
460
600
  kind: 'string',
@@ -8,8 +8,14 @@
8
8
  // Rules are appended to `CROSS_RULES`; the runner returns the concatenated
9
9
  // list of human-readable errors.
10
10
 
11
+ // Upstream (`claude --permission-mode`) advertises: acceptEdits, auto,
12
+ // bypassPermissions, manual, dontAsk, plan. `manual` is the current name
13
+ // for the prompt-on-every-tool mode that used to be spelled `default`;
14
+ // upstream still accepts `default`, so we keep it here rather than
15
+ // breaking callers that pass it.
11
16
  const PERMISSION_MODES = new Set([
12
- 'default', 'plan', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'auto',
17
+ 'acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan',
18
+ 'default',
13
19
  ]);
14
20
 
15
21
  /** @type {Array<(options: Record<string, unknown>) => string | undefined>} */
@@ -85,6 +91,40 @@ export const CROSS_RULES = [
85
91
  return '`--fork-session` requires `--resume <id>` or `--continue`.';
86
92
  }
87
93
  },
94
+ // R11. forward-subagent-text => stream-json output. Upstream only emits
95
+ // the extra assistant/user messages on the stream-json channel.
96
+ (o) => {
97
+ if (o['forward-subagent-text'] === true && o['output-format'] !== 'stream-json') {
98
+ return '`--forward-subagent-text` requires `--output-format=stream-json`.';
99
+ }
100
+ },
101
+ // R12. prompt-suggestions => stream-json output. The suggestion arrives
102
+ // as its own `prompt_suggestion` event; the text and json adapters
103
+ // have nowhere to put it.
104
+ (o) => {
105
+ if (o['prompt-suggestions'] === true && o['output-format'] !== 'stream-json') {
106
+ return '`--prompt-suggestions` requires `--output-format=stream-json`.';
107
+ }
108
+ },
109
+ // R13. autocompact takes `auto` or a token budget in the 100k–1M range.
110
+ // Accepts a bare integer or a `k`/`m` suffix.
111
+ (o) => {
112
+ const v = o.autocompact;
113
+ if (v === undefined) return undefined;
114
+ if (typeof v !== 'string' || v === '') {
115
+ return '`--autocompact` must be `auto` or a token budget between 100k and 1M.';
116
+ }
117
+ if (v === 'auto') return undefined;
118
+ const m = /^(\d+(?:\.\d+)?)([km])?$/i.exec(v.trim());
119
+ if (!m) {
120
+ return `\`--autocompact\` must be \`auto\` or a token budget between 100k and 1M; got ${JSON.stringify(v)}.`;
121
+ }
122
+ const mult = m[2] ? { k: 1e3, m: 1e6 }[m[2].toLowerCase()] : 1;
123
+ const tokens = Number(m[1]) * mult;
124
+ if (!(tokens >= 100_000 && tokens <= 1_000_000)) {
125
+ return `\`--autocompact\` token budget must be between 100k and 1M; got ${JSON.stringify(v)}.`;
126
+ }
127
+ },
88
128
  ];
89
129
 
90
130
  /**
@@ -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
  },