wave-agent-sdk 1.1.4 → 1.2.0

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.
Files changed (89) hide show
  1. package/dist/agent.d.ts +72 -24
  2. package/dist/agent.js +123 -31
  3. package/dist/builtin/plugins.js +17 -2
  4. package/dist/builtin/skills/settings.js +6 -8
  5. package/dist/core/plugin.d.ts +4 -0
  6. package/dist/core/plugin.js +7 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/managers/aiManager.d.ts +0 -24
  10. package/dist/managers/aiManager.js +23 -181
  11. package/dist/managers/bashModeManager.d.ts +33 -0
  12. package/dist/managers/bashModeManager.js +110 -0
  13. package/dist/managers/hookManager.d.ts +5 -0
  14. package/dist/managers/hookManager.js +7 -0
  15. package/dist/managers/liveConfigManager.js +3 -3
  16. package/dist/managers/mcpManager.d.ts +23 -0
  17. package/dist/managers/mcpManager.js +162 -14
  18. package/dist/managers/messageManager.d.ts +12 -13
  19. package/dist/managers/messageManager.js +63 -60
  20. package/dist/managers/permissionManager.d.ts +29 -0
  21. package/dist/managers/permissionManager.js +158 -70
  22. package/dist/managers/planManager.d.ts +9 -0
  23. package/dist/managers/planManager.js +19 -1
  24. package/dist/managers/skillManager.d.ts +31 -0
  25. package/dist/managers/skillManager.js +122 -12
  26. package/dist/managers/slashCommandManager.js +9 -28
  27. package/dist/managers/subagentManager.d.ts +7 -0
  28. package/dist/managers/subagentManager.js +61 -9
  29. package/dist/managers/workflowManager.js +6 -0
  30. package/dist/prompts/index.d.ts +0 -1
  31. package/dist/prompts/index.js +0 -4
  32. package/dist/services/MarketplaceService.js +36 -14
  33. package/dist/services/configurationService.d.ts +34 -2
  34. package/dist/services/configurationService.js +123 -16
  35. package/dist/services/initializationService.js +2 -2
  36. package/dist/services/jsonlHandler.d.ts +14 -0
  37. package/dist/services/jsonlHandler.js +44 -1
  38. package/dist/services/memory.d.ts +14 -0
  39. package/dist/services/memory.js +33 -0
  40. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  41. package/dist/services/officialMarketplaceMirror.js +289 -0
  42. package/dist/services/remoteSettingsService.js +4 -4
  43. package/dist/services/session.js +30 -13
  44. package/dist/services/worktreeHooks.js +6 -1
  45. package/dist/stdio/index.d.ts +10 -0
  46. package/dist/stdio/index.js +10 -0
  47. package/dist/stdio/notificationRouter.d.ts +38 -0
  48. package/dist/stdio/notificationRouter.js +96 -0
  49. package/dist/stdio/rpcClient.d.ts +18 -0
  50. package/dist/stdio/rpcClient.js +10 -0
  51. package/dist/stdio/stdioAgent.d.ts +222 -0
  52. package/dist/stdio/stdioAgent.js +341 -0
  53. package/dist/tools/bashTool.js +2 -0
  54. package/dist/tools/exitPlanMode.js +10 -2
  55. package/dist/types/agent.d.ts +8 -0
  56. package/dist/types/commands.d.ts +7 -0
  57. package/dist/types/configuration.d.ts +6 -1
  58. package/dist/types/hooks.d.ts +1 -0
  59. package/dist/types/hooks.js +19 -0
  60. package/dist/types/mcp.d.ts +3 -0
  61. package/dist/types/messaging.d.ts +1 -8
  62. package/dist/types/skills.d.ts +11 -0
  63. package/dist/utils/bashParser.d.ts +17 -0
  64. package/dist/utils/bashParser.js +72 -0
  65. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  66. package/dist/utils/bashStructure/bashLexer.js +676 -0
  67. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  68. package/dist/utils/bashStructure/bashParser.js +606 -0
  69. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  70. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  71. package/dist/utils/bashStructure/index.d.ts +26 -0
  72. package/dist/utils/bashStructure/index.js +27 -0
  73. package/dist/utils/bashStructure/types.d.ts +62 -0
  74. package/dist/utils/bashStructure/types.js +47 -0
  75. package/dist/utils/container.d.ts +6 -0
  76. package/dist/utils/container.js +9 -0
  77. package/dist/utils/containerSetup.d.ts +11 -1
  78. package/dist/utils/containerSetup.js +26 -11
  79. package/dist/utils/fileUtils.d.ts +11 -0
  80. package/dist/utils/fileUtils.js +37 -0
  81. package/dist/utils/messageOperations.d.ts +0 -18
  82. package/dist/utils/messageOperations.js +0 -62
  83. package/dist/utils/subagentParser.js +9 -2
  84. package/dist/utils/tokenCalculation.js +0 -8
  85. package/dist/utils/worktreeUtils.d.ts +2 -1
  86. package/dist/utils/worktreeUtils.js +64 -34
  87. package/package.json +6 -1
  88. package/dist/managers/bangManager.d.ts +0 -26
  89. package/dist/managers/bangManager.js +0 -78
@@ -0,0 +1,676 @@
1
+ /**
2
+ * Tokenizer for the structure-aware bash parser.
3
+ *
4
+ * Unlike `bashParser.ts`'s text-based heuristics, this tokenizer understands
5
+ * bash quoting so that structural tokens (;, &&, ||, |, &, ( ), do/done/fi…)
6
+ * are only recognized OUTSIDE quotes/substitutions, and words keep enough
7
+ * internal structure for the semantic layer to tell quoted `"$f"` from bare
8
+ * `$f`.
9
+ *
10
+ * Words are tokenized into parts:
11
+ * - lit literal text (single-quoted or plain)
12
+ * - var `$name` / `${name}` expansion (name kept raw, unexpanded)
13
+ * - special `$@` `$*` `$?` `$$` `$!` `$#` `$-` `$0`..`$9`
14
+ * - cmdsub `$(...)` or backtick command substitution (text = inner)
15
+ * - arith `$((...))` — unsupported (dynamic), fail closed
16
+ * - brace `{a,b}` brace expansion — unsupported (dynamic)
17
+ * - process `<(cmd)` / `>(cmd)` process substitution — unsupported
18
+ * Each part records whether it appeared inside double quotes (`q: 'double'`)
19
+ * or bare (`q: 'none'`); single-quoted text is fully literal.
20
+ *
21
+ * The tokenizer FAILS (returns null) on any construct it cannot safely delimit
22
+ * (unterminated quotes / $( / backticks / heredocs). Failures are surfaced by
23
+ * the caller as a fail-closed `unsupported` parse result.
24
+ */
25
+ const LENGTH_LIMIT = 10000;
26
+ /** Control operator punctuation (2-char forms first). */
27
+ const OP_2CHAR = ["&&", "||", "|&", ";;", ";&", ";;&"];
28
+ const OP_1CHAR = new Set(["&", "|", ";", "(", ")", "\n"]);
29
+ const NAME_START = /[A-Za-z_]/;
30
+ const NAME_CHAR = /[A-Za-z0-9_]/;
31
+ export function isVariableNameStart(ch) {
32
+ return NAME_START.test(ch);
33
+ }
34
+ /**
35
+ * Classify a redirection operator word emitted by the tokenizer.
36
+ * Returns "plain" when the operator still needs a separate following target
37
+ * word (`>`, `2>`, `>>`, `&>`, `>&` …), "folded" when the target is already
38
+ * part of the operator token (`2>&1`, `>&-`), or null when the word is not a
39
+ * redirection operator.
40
+ *
41
+ * The raw word may carry an optional file-descriptor digit prefix (`2>`) and
42
+ * the dup operators `<&`/`>&` may fold their fd/- target when it directly
43
+ * touches the operator (`2>&1` must stay one token so the reconstructed text
44
+ * remains faithful — legacy `2>& 1` text checks would mis-read the target).
45
+ */
46
+ export function redirectOpKind(raw) {
47
+ const m = raw.match(/^(\d*)(&>>|&>|<<<|<<-|<<|<>|>>|<&|>&|>|<)(\d*|-)?$/);
48
+ if (!m)
49
+ return null;
50
+ const op = m[2];
51
+ const tail = m[3] ?? "";
52
+ if (op === "<&" || op === ">&") {
53
+ // Folded dup has its fd/- target inside the token; a bare `>&`/`<&` (the
54
+ // target was spaced apart) still needs a following word.
55
+ return tail === "" ? "plain" : "folded";
56
+ }
57
+ // digits/- tail is only legal on dup operators
58
+ if (tail !== "")
59
+ return null;
60
+ return "plain";
61
+ }
62
+ /** True when the raw text is a redirection operator (fd prefix allowed). */
63
+ export function isRedirectRaw(raw) {
64
+ return redirectOpKind(raw) !== null;
65
+ }
66
+ /** Whether a word token is a redirection operator (raw literal, no quoting). */
67
+ export function isRedirectWord(w) {
68
+ const p = w.parts[0];
69
+ // Quoted `">"` / `'2>&1'` are ordinary arguments, not operators.
70
+ if (w.parts.length !== 1 || p?.t !== "lit" || p.q !== "none")
71
+ return false;
72
+ return isRedirectRaw(w.raw);
73
+ }
74
+ /**
75
+ * Tokenize a bash command string. Returns tokens or null on a lexical
76
+ * construct we deliberately do not support (fail closed).
77
+ */
78
+ export function tokenize(input) {
79
+ if (input.length > LENGTH_LIMIT)
80
+ return null;
81
+ const tokens = [];
82
+ let i = 0;
83
+ const n = input.length;
84
+ const peek = (off = 0) => input[i + off] ?? "";
85
+ const eof = () => i >= n;
86
+ /** Skip whitespace (not newlines — those are structural separators). */
87
+ const skipWs = () => {
88
+ while (!eof() && /[ \t\r]/.test(peek()))
89
+ i++;
90
+ };
91
+ /** Skip a `#` comment to end of line (only when # starts a word). */
92
+ const skipComment = () => {
93
+ while (!eof() && peek() !== "\n")
94
+ i++;
95
+ };
96
+ /**
97
+ * Read the body of a `$(...)` substitution whose opening '(' is at
98
+ * `openParenAt`. Balances nested $(), parens inside quotes are ignored.
99
+ * Returns inner text (exclusive of outer parens) or null if unterminated.
100
+ */
101
+ const readDollarParen = (openParenAt) => {
102
+ let depth = 1;
103
+ let start = openParenAt + 1;
104
+ let j = openParenAt + 1;
105
+ while (j < n) {
106
+ const c = input[j];
107
+ if (c === "\\") {
108
+ j += 2;
109
+ continue;
110
+ }
111
+ if (c === "'") {
112
+ // single quotes: everything literal until next '
113
+ const close = input.indexOf("'", j + 1);
114
+ if (close === -1)
115
+ return null;
116
+ j = close + 1;
117
+ continue;
118
+ }
119
+ if (c === '"') {
120
+ // double quotes may nest $( ) but a ) inside quotes doesn't close us
121
+ const inner = readQuotedDouble(j);
122
+ if (inner === null)
123
+ return null;
124
+ j = inner + 1;
125
+ continue;
126
+ }
127
+ if (c === "`") {
128
+ const end = readBacktick(j);
129
+ if (end === -1)
130
+ return null;
131
+ j = end + 1;
132
+ continue;
133
+ }
134
+ if (c === "$" && input[j + 1] === "(" && input[j + 2] !== "(") {
135
+ depth++;
136
+ j += 2;
137
+ continue;
138
+ }
139
+ if (c === "(") {
140
+ // subshell parens inside $() — count them too so a lone ) inside a
141
+ // subshell doesn't terminate us: $( (echo a); echo b )
142
+ depth++;
143
+ j++;
144
+ continue;
145
+ }
146
+ if (c === ")") {
147
+ depth--;
148
+ if (depth === 0)
149
+ return input.slice(start, j);
150
+ j++;
151
+ continue;
152
+ }
153
+ j++;
154
+ }
155
+ return null;
156
+ };
157
+ /** Read content of a double-quoted string starting at the opening ".
158
+ * Returns index just past the closing ", or null if unterminated. */
159
+ const readQuotedDouble = (at) => {
160
+ let j = at + 1;
161
+ while (j < n) {
162
+ const c = input[j];
163
+ if (c === "\\") {
164
+ j += 2;
165
+ continue;
166
+ }
167
+ if (c === '"')
168
+ return j;
169
+ if (c === "$" && input[j + 1] === "(" && input[j + 2] !== "(") {
170
+ const inner = readDollarParenFrom(j + 1);
171
+ if (inner === null)
172
+ return null;
173
+ j = inner;
174
+ continue;
175
+ }
176
+ if (c === "`") {
177
+ const end = readBacktick(j);
178
+ if (end === -1)
179
+ return null;
180
+ j = end + 1;
181
+ continue;
182
+ }
183
+ j++;
184
+ }
185
+ return null;
186
+ };
187
+ /** Variant of readDollarParen used when scanning inside a double-quote:
188
+ * returns index just past the matching ')' of the $() starting at `at`. */
189
+ const readDollarParenFrom = (openParenAt) => {
190
+ let depth = 1;
191
+ let j = openParenAt + 1;
192
+ while (j < n) {
193
+ const c = input[j];
194
+ if (c === "\\") {
195
+ j += 2;
196
+ continue;
197
+ }
198
+ if (c === "'") {
199
+ const close = input.indexOf("'", j + 1);
200
+ if (close === -1)
201
+ return null;
202
+ j = close + 1;
203
+ continue;
204
+ }
205
+ if (c === '"') {
206
+ const inner = readQuotedDouble(j);
207
+ if (inner === null)
208
+ return null;
209
+ j = inner + 1;
210
+ continue;
211
+ }
212
+ if (c === "`") {
213
+ const end = readBacktick(j);
214
+ if (end === -1)
215
+ return null;
216
+ j = end + 1;
217
+ continue;
218
+ }
219
+ if (c === "(") {
220
+ depth++;
221
+ j++;
222
+ continue;
223
+ }
224
+ if (c === ")") {
225
+ depth--;
226
+ if (depth === 0)
227
+ return j + 1;
228
+ j++;
229
+ continue;
230
+ }
231
+ j++;
232
+ }
233
+ return null;
234
+ };
235
+ /** Read a backtick substitution body. Returns index of closing backtick or -1. */
236
+ const readBacktick = (at) => {
237
+ let j = at + 1;
238
+ while (j < n) {
239
+ const c = input[j];
240
+ if (c === "\\") {
241
+ j += 2;
242
+ continue;
243
+ }
244
+ if (c === "`")
245
+ return j;
246
+ j++;
247
+ }
248
+ return -1;
249
+ };
250
+ /** Read a $((...)) arithmetic expansion. Returns end index or -1. */
251
+ const readArith = (at) => {
252
+ // at is at the second '('
253
+ let depth = 1;
254
+ let j = at + 1;
255
+ while (j < n) {
256
+ const c = input[j];
257
+ if (c === "\\") {
258
+ j += 2;
259
+ continue;
260
+ }
261
+ if (c === "'") {
262
+ const close = input.indexOf("'", j + 1);
263
+ if (close === -1)
264
+ return -1;
265
+ j = close + 1;
266
+ continue;
267
+ }
268
+ if (c === '"') {
269
+ const inner = readQuotedDouble(j);
270
+ if (inner === null)
271
+ return -1;
272
+ j = inner + 1;
273
+ continue;
274
+ }
275
+ if (c === "$" && input[j + 1] === "(" && input[j + 2] === "(") {
276
+ depth++;
277
+ j += 3;
278
+ continue;
279
+ }
280
+ if (c === "(") {
281
+ depth++;
282
+ j++;
283
+ continue;
284
+ }
285
+ if (c === ")") {
286
+ depth--;
287
+ if (depth === 0) {
288
+ // closing for $(( )) is '))' — the second ) consumed next iteration;
289
+ // simplest: require one more ')'
290
+ return j + 1 <= n ? j + 1 : -1;
291
+ }
292
+ j++;
293
+ continue;
294
+ }
295
+ j++;
296
+ }
297
+ return -1;
298
+ };
299
+ /** Try reading a `${...}` parameter expansion. Returns name | null. */
300
+ const readBraceParam = () => {
301
+ // i at '$', input[i+1] === '{'
302
+ let j = i + 2;
303
+ const start = j;
304
+ while (j < n) {
305
+ const c = input[j];
306
+ if (c === "}") {
307
+ return input.slice(start, j);
308
+ }
309
+ if (c === "{" || c === "(" || c === "$") {
310
+ // nested expansion inside ${...} — too dynamic
311
+ return null;
312
+ }
313
+ j++;
314
+ }
315
+ return null;
316
+ };
317
+ /** Scan a full word starting at i. Appends a word token; returns new i or -1. */
318
+ const scanWord = () => {
319
+ const rawStart = i;
320
+ const parts = [];
321
+ let litStart = -1; // start index of pending literal text
322
+ const flushLit = (end, q) => {
323
+ if (litStart !== -1) {
324
+ const v = input.slice(litStart, end);
325
+ if (v.length > 0)
326
+ parts.push({ t: "lit", v, q });
327
+ litStart = -1;
328
+ }
329
+ };
330
+ let q = "none";
331
+ while (!eof()) {
332
+ const c = peek();
333
+ // unquoted terminators
334
+ if (q === "none" && (c === " " || c === "\t" || c === "\r"))
335
+ break;
336
+ if (q === "none" && c === "\n")
337
+ break;
338
+ if (q === "none" &&
339
+ (c === "&" ||
340
+ c === "|" ||
341
+ c === ";" ||
342
+ c === "(" ||
343
+ c === ")" ||
344
+ c === "<" ||
345
+ c === ">")) {
346
+ // `<` / `>` outside quotes are redirection operators, never part of a
347
+ // word (bash: `foo>bar` redirects bar into foo).
348
+ break;
349
+ }
350
+ // `#` is only a comment when it starts a word (handled in the main loop
351
+ // after whitespace); inside a word it is a literal character.
352
+ if (q === "single") {
353
+ if (c === "'") {
354
+ flushLit(i, "single");
355
+ q = "none";
356
+ i++;
357
+ continue;
358
+ }
359
+ i++;
360
+ continue;
361
+ }
362
+ if (c === "'" && q === "none") {
363
+ flushLit(i, "none");
364
+ q = "single";
365
+ if (litStart === -1)
366
+ litStart = i + 1;
367
+ i++;
368
+ continue;
369
+ }
370
+ if (c === '"') {
371
+ flushLit(i, q === "double" ? "double" : "none");
372
+ if (q === "none") {
373
+ q = "double";
374
+ litStart = i + 1;
375
+ }
376
+ else {
377
+ // closing quote
378
+ q = "none";
379
+ }
380
+ i++;
381
+ continue;
382
+ }
383
+ if (q === "double" && c === "\\") {
384
+ // escaped char inside double quotes: keep both chars literal
385
+ i += 2;
386
+ continue;
387
+ }
388
+ if (q === "none" && c === "\\") {
389
+ // escape next char
390
+ if (i + 1 >= n)
391
+ return null;
392
+ i += 2;
393
+ continue;
394
+ }
395
+ if (c === "$") {
396
+ const n1 = peek(1);
397
+ if (n1 === "(") {
398
+ const n2 = peek(2);
399
+ if (n2 === "(") {
400
+ // $(( arithmetic
401
+ flushLit(i, q);
402
+ const end = readArith(i + 1);
403
+ if (end === -1)
404
+ return null;
405
+ parts.push({ t: "arith", q: q === "double" ? "double" : "none" });
406
+ i = end;
407
+ continue;
408
+ }
409
+ // $( command substitution — readDollarParen expects the '(' index.
410
+ flushLit(i, q);
411
+ const openParen = i + 1;
412
+ const inner = readDollarParen(openParen);
413
+ if (inner === null)
414
+ return null;
415
+ parts.push({
416
+ t: "cmdsub",
417
+ text: inner,
418
+ q: q === "double" ? "double" : "none",
419
+ });
420
+ // Skip '$', '(', inner text and the closing ')'.
421
+ i = openParen + inner.length + 2;
422
+ continue;
423
+ }
424
+ if (n1 === "{") {
425
+ flushLit(i, q);
426
+ const name = readBraceParam();
427
+ if (name === null)
428
+ return null;
429
+ if (name === "")
430
+ return null;
431
+ if (/^[0-9]+$/.test(name) || name === "@" || name === "*") {
432
+ parts.push({
433
+ t: "special",
434
+ name,
435
+ q: q === "double" ? "double" : "none",
436
+ });
437
+ }
438
+ else if (isVariableNameStart(name[0])) {
439
+ parts.push({
440
+ t: "var",
441
+ name,
442
+ q: q === "double" ? "double" : "none",
443
+ });
444
+ }
445
+ else {
446
+ // ${!x} / ${x:-y} / ${x#...} — dynamic, unsupported
447
+ parts.push({ t: "brace", v: `\${${name}}`, q: "none" });
448
+ }
449
+ i = i + 2 + name.length + 1;
450
+ continue;
451
+ }
452
+ // bare $name / $@ / $? etc
453
+ const n1Char = n1;
454
+ if (n1Char === "@" || n1Char === "*") {
455
+ flushLit(i, q);
456
+ parts.push({
457
+ t: "special",
458
+ name: n1Char,
459
+ q: q === "double" ? "double" : "none",
460
+ });
461
+ i += 2;
462
+ continue;
463
+ }
464
+ if (n1Char === "?" ||
465
+ n1Char === "$" ||
466
+ n1Char === "!" ||
467
+ n1Char === "#" ||
468
+ n1Char === "-" ||
469
+ /[0-9]/.test(n1Char)) {
470
+ flushLit(i, q);
471
+ parts.push({
472
+ t: "special",
473
+ name: n1Char,
474
+ q: q === "double" ? "double" : "none",
475
+ });
476
+ i += 2;
477
+ continue;
478
+ }
479
+ if (isVariableNameStart(n1Char)) {
480
+ flushLit(i, q);
481
+ let j = i + 1;
482
+ while (j < n && NAME_CHAR.test(input[j]))
483
+ j++;
484
+ parts.push({
485
+ t: "var",
486
+ name: input.slice(i + 1, j),
487
+ q: q === "double" ? "double" : "none",
488
+ });
489
+ i = j;
490
+ continue;
491
+ }
492
+ // lone '$' — treat as literal
493
+ i++;
494
+ continue;
495
+ }
496
+ if (c === "`") {
497
+ flushLit(i, q);
498
+ const end = readBacktick(i);
499
+ if (end === -1)
500
+ return null;
501
+ parts.push({
502
+ t: "cmdsub",
503
+ text: input.slice(i + 1, end),
504
+ q: q === "double" ? "double" : "none",
505
+ });
506
+ i = end + 1;
507
+ continue;
508
+ }
509
+ // process substitution <( / >( is handled at operator level (see below)
510
+ if (litStart === -1)
511
+ litStart = i;
512
+ i++;
513
+ }
514
+ flushLit(i, q === "double" ? "double" : "none");
515
+ if (i <= rawStart)
516
+ return -1;
517
+ const raw = input.slice(rawStart, i);
518
+ tokens.push({ t: "word", raw, parts });
519
+ return i;
520
+ };
521
+ /**
522
+ * If input[i] begins a redirection operator, return the operator text to
523
+ * emit as a word token (fd prefix + operator, plus a folded dup target like
524
+ * `2>&1`). Digits only form an fd prefix when they directly touch the
525
+ * operator — `2>/dev/null` is one operator, `2 > /dev/null` is the argument
526
+ * `2` followed by a stdout redirect. Returns null when i is not a redirect,
527
+ * or the "<heredoc>" sentinel for `<<`/`<<-`/`<<<` (unmodelled → fail
528
+ * closed).
529
+ */
530
+ const readRedirectOp = () => {
531
+ const start = i;
532
+ let k = i;
533
+ // optional fd-prefix digits touching the operator
534
+ if (/[0-9]/.test(input[k] ?? "")) {
535
+ while (k < n && /[0-9]/.test(input[k]))
536
+ k++;
537
+ if (input[k] !== "<" && input[k] !== ">")
538
+ return null;
539
+ }
540
+ const op = input[k] ?? "";
541
+ if (op === "&") {
542
+ // &> / &>> — redirect both streams to a following target word
543
+ if (input[k + 1] !== ">")
544
+ return null; // plain `&` background operator
545
+ k++;
546
+ if (input[k + 1] === ">")
547
+ k++;
548
+ return input.slice(start, k + 1);
549
+ }
550
+ if (op !== "<" && op !== ">")
551
+ return null;
552
+ const next = input[k + 1] ?? "";
553
+ if (op === "<") {
554
+ if (next === "<")
555
+ return "<heredoc>"; // << <<- <<<
556
+ if (next === "&") {
557
+ // <&fd dup — fold an attached fd / `-` into the token
558
+ k++;
559
+ if (input[k + 1] === "-")
560
+ return input.slice(start, k + 2);
561
+ let j = k + 1;
562
+ while (j < n && /[0-9]/.test(input[j]))
563
+ j++;
564
+ if (j > k + 1)
565
+ return input.slice(start, j);
566
+ return input.slice(start, k + 1);
567
+ }
568
+ if (next === ">")
569
+ return input.slice(start, k + 2); // <>
570
+ return input.slice(start, k + 1); // <
571
+ }
572
+ // '>'
573
+ if (next === ">")
574
+ return input.slice(start, k + 2); // >>
575
+ if (next === "&") {
576
+ // >&fd dup — fold an attached fd / `-` into the token
577
+ k++;
578
+ if (input[k + 1] === "-")
579
+ return input.slice(start, k + 2);
580
+ let j = k + 1;
581
+ while (j < n && /[0-9]/.test(input[j]))
582
+ j++;
583
+ if (j > k + 1)
584
+ return input.slice(start, j);
585
+ return input.slice(start, k + 1);
586
+ }
587
+ return input.slice(start, k + 1); // >
588
+ };
589
+ while (!eof()) {
590
+ skipWs();
591
+ if (eof())
592
+ break;
593
+ const c = peek();
594
+ if (c === "\n") {
595
+ tokens.push({ t: "nl" });
596
+ i++;
597
+ continue;
598
+ }
599
+ if (c === "#") {
600
+ skipComment();
601
+ continue;
602
+ }
603
+ // process substitution <( ... ) / >( ... )
604
+ if ((c === "<" || c === ">") && peek(1) === "(") {
605
+ // Read the inner command up to matching ')' (a full command list).
606
+ const inner = readDollarParenFrom(i + 1);
607
+ if (inner === null)
608
+ return null;
609
+ const raw = input.slice(i, inner);
610
+ tokens.push({
611
+ t: "word",
612
+ raw,
613
+ parts: [{ t: "process", text: raw, q: "none" }],
614
+ });
615
+ i = inner;
616
+ continue;
617
+ }
618
+ // redirection operators (optional fd prefix / folded dup targets). The
619
+ // operator is emitted as a plain word; the (unfolded) target is scanned
620
+ // as the next word. Heredoc bodies are unmodelled → fail closed.
621
+ const rdr = readRedirectOp();
622
+ if (rdr !== null) {
623
+ if (rdr === "<heredoc>")
624
+ return null;
625
+ tokens.push({
626
+ t: "word",
627
+ raw: rdr,
628
+ parts: [{ t: "lit", v: rdr, q: "none" }],
629
+ });
630
+ i += rdr.length;
631
+ continue;
632
+ }
633
+ const two = input.slice(i, i + 2);
634
+ if (OP_2CHAR.includes(two)) {
635
+ tokens.push({ t: "op", op: two });
636
+ i += 2;
637
+ continue;
638
+ }
639
+ if (OP_1CHAR.has(c)) {
640
+ if (c === "\n") {
641
+ tokens.push({ t: "nl" });
642
+ }
643
+ else {
644
+ tokens.push({ t: "op", op: c });
645
+ }
646
+ i++;
647
+ continue;
648
+ }
649
+ // `{` / `}` are reserved words (brace group) only when delimited by
650
+ // whitespace or a control operator; `{a,b}` stays a single word.
651
+ if (c === "{" || c === "}") {
652
+ const after = peek(1);
653
+ const delimited = after === "" || /[ \t\r\n;|&()<>]/.test(after);
654
+ if (delimited) {
655
+ tokens.push({
656
+ t: "word",
657
+ raw: c,
658
+ parts: [{ t: "lit", v: c, q: "none" }],
659
+ });
660
+ i++;
661
+ continue;
662
+ }
663
+ const next = scanWord();
664
+ if (next === null)
665
+ return null;
666
+ i = next;
667
+ continue;
668
+ }
669
+ const next = scanWord();
670
+ if (next === null)
671
+ return null;
672
+ i = next;
673
+ }
674
+ tokens.push({ t: "eof" });
675
+ return tokens;
676
+ }