pi-roundtable 0.22.5 → 0.23.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 (129) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Splits a bash command line into the simple commands bash would run, for the shell hold rule.
3
+ *
4
+ * The rule's one promise is: if bash runs a held command, the line is held. So the lexer reads
5
+ * the way bash does, and where it cannot (a line too long or too deeply nested, or a construct it
6
+ * cannot place), it gives up with `unreadable` and the rule holds the whole line. It never guesses
7
+ * that text is inert. What it must therefore know is where each kind of text ends: single, double
8
+ * and `$'…'` quotes, `$(…)`, backticks, `${…}`, `$[…]`, comments, here-document bodies and `case`
9
+ * patterns.
10
+ */
11
+
12
+ import { ArithGroups } from "./shell-arithmetic.ts";
13
+ import {
14
+ COMMAND_PREFIX_WORDS,
15
+ EXPANDED,
16
+ KEYWORDS_BEFORE_CASE,
17
+ type Lexed,
18
+ type Pending,
19
+ type Shared,
20
+ } from "./shell-lexer-types.ts";
21
+ import {
22
+ ansiQuoted,
23
+ backtickBody,
24
+ closesGroup,
25
+ continuedToken,
26
+ heredocBody,
27
+ readDelimiter,
28
+ redirectOperator,
29
+ Unreadable,
30
+ } from "./shell-quoting.ts";
31
+
32
+ export { COMMAND_PREFIX_WORDS, EXPANDED, type Lexed };
33
+
34
+ /** A line longer than this is not read. */
35
+ const MAX_LENGTH = 200_000;
36
+ /** Nested substitutions deeper than this are not read. */
37
+ const MAX_DEPTH = 48;
38
+
39
+ const isSpace = (ch: string) => /\s/.test(ch);
40
+
41
+ /** Splits a command line into simple commands of words; quotes are removed, operators separate. */
42
+ export function lexShell(command: string): Lexed {
43
+ const gone = {
44
+ commands: [],
45
+ unreadable: true,
46
+ arithmetic: false,
47
+ literalSubstitution: false,
48
+ };
49
+ if (command.length > MAX_LENGTH) return gone;
50
+ // A line continuation inside a token is not read the way bash joins it.
51
+ if (continuedToken(command)) return gone;
52
+ const shared = { depth: 0, arithmetic: false, literalSubstitution: false };
53
+ try {
54
+ // Bash drops a NUL byte from the script it reads.
55
+ const lexer = new Lexer(command.replaceAll("\0", ""), shared, false);
56
+ const commands = lexer.list("eof");
57
+ return {
58
+ commands,
59
+ unreadable: false,
60
+ arithmetic: shared.arithmetic,
61
+ literalSubstitution: shared.literalSubstitution,
62
+ };
63
+ } catch (error) {
64
+ // A stack that ran out is a line too deep to read, whatever the cap says.
65
+ if (error instanceof Unreadable || error instanceof RangeError) return gone;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /** Splits a command line into simple commands of words; the commands of a line that cannot be read are none. */
71
+ export function simpleCommands(command: string): string[][] {
72
+ return lexShell(command).commands;
73
+ }
74
+
75
+ class Lexer {
76
+ pos = 0;
77
+ /** Here-documents whose bodies follow the line being read. */
78
+ readonly pending: Pending[] = [];
79
+ #calls = 0;
80
+
81
+ constructor(
82
+ readonly src: string,
83
+ readonly shared: Shared,
84
+ /** Whether `<<` is a shift: the text is arithmetic, where there are no here-documents. */
85
+ readonly arithmetic: boolean,
86
+ ) {}
87
+
88
+ #enter() {
89
+ if (++this.shared.depth > MAX_DEPTH) throw new Unreadable();
90
+ }
91
+
92
+ #leave() {
93
+ this.shared.depth--;
94
+ }
95
+
96
+ /**
97
+ * The commands from here to the end of the text, or, for `paren`, to the `)` that closes the
98
+ * substitution that starts here.
99
+ */
100
+ list(end: "eof" | "paren"): string[][] {
101
+ const id = ++this.#calls;
102
+ const s = this.src;
103
+ const out: string[][] = [];
104
+ let words: string[] = [];
105
+ let word = "";
106
+ let inWord = false;
107
+ // Whether the word was written without a quote, escape or expansion, so it may be a keyword.
108
+ let plain = true;
109
+ let parenDepth = 0;
110
+ let caseOpen = 0;
111
+ /** Whether the next `)` in a `case` ends a pattern: after `in` and after `;;`. */
112
+ let expectPattern = false;
113
+ /** How many words of the current command come before a `case` pattern (`case x in`). */
114
+ let patternBase = 0;
115
+ const arith = new ArithGroups(out, s, this.pos, end === "paren");
116
+ let sawShift = false;
117
+ const endWord = () => {
118
+ if (!inWord) return;
119
+ if (plain && words.every((w) => KEYWORDS_BEFORE_CASE.has(w))) {
120
+ if (word === "case") {
121
+ caseOpen++;
122
+ expectPattern = false;
123
+ } else if (word === "esac" && caseOpen > 0) {
124
+ caseOpen--;
125
+ expectPattern = false;
126
+ }
127
+ } else if (plain && word === "in" && caseOpen > 0) {
128
+ // `case WORD in`: the first pattern follows.
129
+ const at = words.lastIndexOf("case");
130
+ if (at !== -1 && words.length - at === 2) {
131
+ expectPattern = true;
132
+ patternBase = words.length + 1;
133
+ }
134
+ }
135
+ words.push(word);
136
+ word = "";
137
+ inWord = false;
138
+ plain = true;
139
+ };
140
+ const endCommand = () => {
141
+ endWord();
142
+ if (words.length > 0) arith.add(words);
143
+ words = [];
144
+ patternBase = 0;
145
+ };
146
+ const quoted = (text: string) => {
147
+ word += text;
148
+ inWord = true;
149
+ plain = false;
150
+ };
151
+ while (this.pos < s.length) {
152
+ const ch = s[this.pos] as string;
153
+ const next = s[this.pos + 1];
154
+ if (ch === "\\") {
155
+ if (next === undefined) this.pos++;
156
+ else if (next === "\n") this.pos += 2;
157
+ else {
158
+ this.#literal(next, s[this.pos + 2]);
159
+ quoted(next);
160
+ this.pos += 2;
161
+ }
162
+ } else if (ch === "'") {
163
+ const close = s.indexOf("'", this.pos + 1);
164
+ const stop = close === -1 ? s.length : close;
165
+ const literal = s.slice(this.pos + 1, stop);
166
+ this.#literal(literal);
167
+ quoted(literal);
168
+ this.pos = stop + 1;
169
+ } else if (ch === '"') {
170
+ this.pos++;
171
+ quoted(this.#doubleQuoted(out));
172
+ } else if (ch === "$") {
173
+ if (next === "'") quoted(this.#ansiQuoted());
174
+ else if (next === '"') {
175
+ this.pos += 2;
176
+ quoted(this.#doubleQuoted(out));
177
+ } else if (next === "(") {
178
+ // The text is dropped and the word keeps the mark, so a value built from it counts as unknown.
179
+ this.#paren(out);
180
+ quoted(`$(${EXPANDED}`);
181
+ } else if (next === "{") quoted(this.#param(out, false));
182
+ else if (next === "[") quoted(this.#bracket(out));
183
+ else {
184
+ word += "$";
185
+ inWord = true;
186
+ this.pos++;
187
+ }
188
+ } else if (ch === "`") {
189
+ this.#backtick(out);
190
+ quoted(`\`${EXPANDED}`);
191
+ } else if (ch === "\n") {
192
+ endCommand();
193
+ this.pos++;
194
+ if ((this.arithmetic || arith.open) && sawShift) throw new Unreadable();
195
+ this.#drain(id, out);
196
+ } else if (isSpace(ch)) {
197
+ // Bash 5 takes `then` or `do` right after `]]` as a new command.
198
+ const closes = inWord && plain && word === "]]" && words.includes("[[");
199
+ endWord();
200
+ if (closes) endCommand();
201
+ this.pos++;
202
+ } else if (
203
+ ch === "#" &&
204
+ !inWord &&
205
+ (this.pos === 0 || " \t\n;&|()<>".includes(s[this.pos - 1] as string))
206
+ ) {
207
+ // A comment: a `)` or a quote in it means nothing.
208
+ const stop = s.indexOf("\n", this.pos);
209
+ const comment = s.slice(this.pos, stop === -1 ? s.length : stop);
210
+ // Bash 3.2 and 5 differ on a quote in a comment inside a substitution; neither reading is trusted.
211
+ if (this.shared.depth > 0 && /['"`]/.test(comment))
212
+ throw new Unreadable();
213
+ this.pos += comment.length;
214
+ } else if (ch === "(" && caseOpen > 0 && expectPattern && !inWord) {
215
+ // The optional `(` before a `case` pattern; its `)` is the one that ends the pattern.
216
+ this.pos++;
217
+ } else if (ch === "(") {
218
+ endCommand();
219
+ parenDepth++;
220
+ if (next === "(" || s[this.pos - 1] === "(")
221
+ arith.push(parenDepth, this.pos);
222
+ this.pos++;
223
+ } else if (ch === ")") {
224
+ endWord();
225
+ this.pos++;
226
+ if (parenDepth > 0) {
227
+ endCommand();
228
+ if (arith.close(parenDepth, this.pos)) this.shared.arithmetic = true;
229
+ if (!arith.open && !this.arithmetic) sawShift = false;
230
+ parenDepth--;
231
+ } else if (caseOpen > 0) {
232
+ // The `)` that ends a `case` pattern; the pattern runs nothing. Any other `)` there is
233
+ // one that bash versions place differently, so the line is not read.
234
+ // A pattern is one word; anything more is not one bash would accept.
235
+ if (!expectPattern || words.length - patternBase > 1)
236
+ throw new Unreadable();
237
+ expectPattern = false;
238
+ words = [];
239
+ } else {
240
+ endCommand();
241
+ if (end === "paren") {
242
+ if (this.pending.some((p) => p.owner === id))
243
+ throw new Unreadable();
244
+ arith.flush();
245
+ return out;
246
+ }
247
+ }
248
+ } else if (
249
+ ";&|".includes(ch) ||
250
+ (ch === "{" && (next === undefined || isSpace(next))) ||
251
+ (ch === "}" && closesGroup(this.src, this.pos))
252
+ ) {
253
+ endCommand();
254
+ if (ch === ";" && caseOpen > 0 && (next === ";" || next === "&"))
255
+ expectPattern = true;
256
+ this.pos++;
257
+ } else if (
258
+ (ch === "<" || ch === ">") &&
259
+ next === "(" &&
260
+ !this.arithmetic &&
261
+ !arith.open
262
+ ) {
263
+ if (s[this.pos + 2] === "(") throw new Unreadable();
264
+ this.#paren(out);
265
+ quoted(`${ch}(${EXPANDED}`);
266
+ } else if (ch === ">" || ch === "<") {
267
+ // A number or a bash 5 `{name}` right before the operator is its descriptor (`3>&1`, `{fd}>f`), no word of the command.
268
+ if (inWord && plain && /^(?:\d+|\{[A-Za-z_]\w*\})$/.test(word)) {
269
+ word = "";
270
+ inWord = false;
271
+ } else endWord();
272
+ if (ch === "<" && next === "<") {
273
+ if (this.#shiftOrHeredoc(id, words, arith.open)) sawShift = true;
274
+ continue;
275
+ }
276
+ // Redirections become their own words, so their targets can be checked.
277
+ const found = redirectOperator(s, this.pos);
278
+ words.push(found.op);
279
+ if (found.closes) words.push("-");
280
+ this.pos = found.end;
281
+ } else {
282
+ word += ch;
283
+ inWord = true;
284
+ this.pos++;
285
+ }
286
+ }
287
+ endCommand();
288
+ // A group left open is not known to be arithmetic.
289
+ arith.flush();
290
+ return out;
291
+ }
292
+
293
+ /**
294
+ * `<<<`, a shift in arithmetic, or a here-document, at the `<<` that starts here. Returns
295
+ * whether it was a shift, which a newline after it in the same arithmetic makes unreadable.
296
+ */
297
+ #shiftOrHeredoc(id: number, words: string[], inArithmetic: boolean): boolean {
298
+ const s = this.src;
299
+ if (s[this.pos + 2] === "<") {
300
+ words.push("<");
301
+ this.pos += 3;
302
+ return false;
303
+ }
304
+ if (this.arithmetic || inArithmetic) {
305
+ words.push("<");
306
+ this.pos += 2;
307
+ return true;
308
+ }
309
+ this.pos += 2;
310
+ let stripTabs = false;
311
+ if (s[this.pos] === "-") {
312
+ stripTabs = true;
313
+ this.pos++;
314
+ }
315
+ while (s[this.pos] === " " || s[this.pos] === "\t") this.pos++;
316
+ const { text, quoted, end } = readDelimiter(s, this.pos);
317
+ this.pos = end;
318
+ this.pending.push({ owner: id, delimiter: text, quoted, stripTabs });
319
+ // The delimiter stays a word so the redirection's target is skipped like any other.
320
+ words.push("<", text);
321
+ return false;
322
+ }
323
+
324
+ /** Reads the bodies of the here-documents of the line that just ended, and the expansions in them. */
325
+ #drain(id: number, out: string[][]) {
326
+ const s = this.src;
327
+ for (let p = this.pending.shift(); p; p = this.pending.shift()) {
328
+ // A line ended inside another substitution than the one that opened the here-document.
329
+ if (p.owner !== id) throw new Unreadable();
330
+ const start = this.pos;
331
+ const { bodyEnd, next, terminated } = heredocBody(s, start, p);
332
+ // Unterminated inside a substitution: bash 3.2 closes it at the first `)`, and 5 does not.
333
+ if (!terminated && this.shared.depth > 0) throw new Unreadable();
334
+ if (!p.quoted) {
335
+ const body = new Lexer(
336
+ s.slice(start, bodyEnd),
337
+ this.shared,
338
+ this.arithmetic,
339
+ );
340
+ for (const command of body.expansions()) out.push(command);
341
+ }
342
+ this.pos = next;
343
+ }
344
+ }
345
+
346
+ /** The commands run by the expansions in text that is not split into words (a here-document body). */
347
+ expansions(): string[][] {
348
+ const out: string[][] = [];
349
+ const s = this.src;
350
+ while (this.pos < s.length) {
351
+ const ch = s[this.pos] as string;
352
+ const next = s[this.pos + 1];
353
+ if (ch === "\\") this.pos += 2;
354
+ else if (ch === "`") this.#backtick(out);
355
+ else if (ch === "$" && next === "(") this.#paren(out);
356
+ else if (ch === "$" && next === "{") this.#param(out, true);
357
+ else if (ch === "$" && next === "[") this.#bracket(out);
358
+ else this.pos++;
359
+ }
360
+ return out;
361
+ }
362
+
363
+ /** The text of a double-quoted string that starts here, after its opening quote; its substitutions run. */
364
+ #doubleQuoted(out: string[][]): string {
365
+ const s = this.src;
366
+ let text = "";
367
+ while (this.pos < s.length) {
368
+ const ch = s[this.pos] as string;
369
+ const next = s[this.pos + 1];
370
+ if (ch === '"') {
371
+ this.pos++;
372
+ return text;
373
+ }
374
+ if (ch === "\\") {
375
+ // A backslash before a newline joins the lines.
376
+ if (next !== undefined && next !== "\n") {
377
+ this.#literal(next, s[this.pos + 2]);
378
+ text += next;
379
+ }
380
+ this.pos += 2;
381
+ } else if (ch === "`") {
382
+ this.#backtick(out, true);
383
+ text += `\`${EXPANDED}`;
384
+ } else if (ch === "$" && next === "(") {
385
+ this.#paren(out);
386
+ text += `$(${EXPANDED}`;
387
+ } else if (ch === "$" && next === "{") text += this.#param(out, true);
388
+ else if (ch === "$" && next === "[") text += this.#bracket(out);
389
+ else {
390
+ text += ch;
391
+ this.pos++;
392
+ }
393
+ }
394
+ return text;
395
+ }
396
+
397
+ /** Notes text that holds a `$(` or a backtick no shell expands where it is written; `after` follows a lone character. */
398
+ #literal(text: string, after?: string) {
399
+ if (
400
+ text.includes("`") ||
401
+ text.includes("$(") ||
402
+ (text === "$" && after === "(")
403
+ )
404
+ this.shared.literalSubstitution = true;
405
+ }
406
+
407
+ /** The text of a `$'…'` string that starts here, with its escapes decoded. */
408
+ #ansiQuoted(): string {
409
+ const found = ansiQuoted(this.src, this.pos);
410
+ this.#literal(found.text);
411
+ this.pos = found.end;
412
+ return found.text;
413
+ }
414
+
415
+ /** A `$(…)` that starts here: its commands go to `out`; returns its text. An unterminated one runs to the end. */
416
+ #paren(out: string[][]): string {
417
+ const start = this.pos;
418
+ this.#enter();
419
+ this.pos += 2;
420
+ for (const command of this.list("paren")) out.push(command);
421
+ this.#leave();
422
+ return this.src.slice(start, this.pos);
423
+ }
424
+
425
+ /** A backtick substitution that starts here: its commands go to `out`; returns its text. */
426
+ #backtick(out: string[][], inDouble = false): string {
427
+ const s = this.src;
428
+ const start = this.pos;
429
+ const { inner, end } = backtickBody(s, start, inDouble);
430
+ this.pos = end;
431
+ this.#enter();
432
+ const body = new Lexer(inner, this.shared, this.arithmetic);
433
+ for (const command of body.list("eof")) out.push(command);
434
+ this.#leave();
435
+ return s.slice(start, this.pos);
436
+ }
437
+
438
+ /**
439
+ * A `${…}` that starts here: the substitutions in it run, and a `}` in a quote or behind a
440
+ * backslash does not end it. Returns its text.
441
+ */
442
+ #param(out: string[][], inDouble: boolean): string {
443
+ return this.#braced(out, "}", undefined, inDouble);
444
+ }
445
+
446
+ /** A `$[…]` that starts here: arithmetic, where there are no here-documents. Returns its text. */
447
+ #bracket(out: string[][]): string {
448
+ this.shared.arithmetic = true;
449
+ return this.#braced(out, "]", "[", false);
450
+ }
451
+
452
+ #braced(
453
+ out: string[][],
454
+ close: string,
455
+ open: string | undefined,
456
+ inDouble: boolean,
457
+ ): string {
458
+ const s = this.src;
459
+ const start = this.pos;
460
+ this.#enter();
461
+ this.pos += 2;
462
+ let depth = 1;
463
+ while (this.pos < s.length) {
464
+ const ch = s[this.pos] as string;
465
+ const next = s[this.pos + 1];
466
+ if (ch === close && --depth === 0) {
467
+ this.pos++;
468
+ break;
469
+ }
470
+ if (ch === open) {
471
+ depth++;
472
+ this.pos++;
473
+ } else if (ch === "\\") this.pos += 2;
474
+ else if (ch === "'" && !inDouble) {
475
+ const stop = s.indexOf("'", this.pos + 1);
476
+ this.pos = stop === -1 ? s.length : stop + 1;
477
+ } else if (ch === '"') {
478
+ this.pos++;
479
+ this.#doubleQuoted(out);
480
+ } else if (ch === "`") this.#backtick(out, inDouble);
481
+ else if (ch === "$" && next === "(") this.#paren(out);
482
+ else if (ch === "$" && next === "{") this.#param(out, inDouble);
483
+ else if (ch === "$" && next === "[") this.#bracket(out);
484
+ else this.pos++;
485
+ }
486
+ this.#leave();
487
+ this.pos = Math.min(this.pos, s.length);
488
+ const text = s.slice(start, this.pos);
489
+ // `${x@P}` expands the prompt escapes in the value of x, and that runs a `$(` in it.
490
+ if (close === "}" && /@P\}$/.test(text)) throw new Unreadable();
491
+ return text;
492
+ }
493
+ }
@@ -22,6 +22,10 @@ export interface LineState {
22
22
  vars: Map<string, string | undefined>;
23
23
  /** The working directory, or undefined once a `cd` went somewhere unknown. */
24
24
  cwd: string | undefined;
25
+ /** The names the line itself writes anywhere, as a bare word: what it assigns, loops over or reads into. */
26
+ bound: ReadonlySet<string>;
27
+ /** The files the line has written so far with `>`, `>>` and `tee`, as it names them without a leading `./`. */
28
+ written: Set<string>;
25
29
  /** Whether `cd` can be followed: false in a line with subshells or `||`, where the last `cd` may not apply. */
26
30
  followsCd: boolean;
27
31
  }
@@ -38,15 +42,17 @@ export function lineState(
38
42
  roots: scratchDir === undefined ? [workspace] : [workspace, scratchDir],
39
43
  vars,
40
44
  cwd: workspace,
45
+ written: new Set(),
46
+ bound: new Set(bindingCounts(command).keys()),
41
47
  followsCd: !/(^|[^$])\(|\|\|/.test(command),
42
48
  };
43
49
  }
44
50
 
45
- const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
51
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$/s;
46
52
 
47
53
  /**
48
54
  * The word with the line's variables substituted and `~` expanded, or undefined when it holds a
49
- * command substitution, an unknown variable or `~user`.
55
+ * command substitution, an unknown variable, a brace expansion or `~user`.
50
56
  */
51
57
  export function expand(word: string, state: LineState): string | undefined {
52
58
  if (word.includes("`") || word.includes("$(")) return undefined;
@@ -60,6 +66,8 @@ export function expand(word: string, state: LineState): string | undefined {
60
66
  },
61
67
  );
62
68
  if (unknown || substituted.includes("$")) return undefined;
69
+ // A brace expansion (`{a,b}`, `{1..3}`) names several words.
70
+ if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(substituted)) return undefined;
63
71
  if (substituted === "~" || substituted.startsWith("~/"))
64
72
  return `${homedir()}${substituted.slice(1)}`;
65
73
  return substituted.startsWith("~") ? undefined : substituted;
@@ -88,8 +96,12 @@ export function follow(argv: readonly string[], state: LineState): void {
88
96
  : [];
89
97
  for (const word of assignments) {
90
98
  const match = ASSIGNMENT.exec(word);
99
+ // An append (`x+=y`) makes a value the line does not follow.
91
100
  if (match)
92
- state.vars.set(match[1] as string, expand(match[2] as string, state));
101
+ state.vars.set(
102
+ match[1] as string,
103
+ match[2] === "" ? expand(match[3] as string, state) : undefined,
104
+ );
93
105
  }
94
106
  if (head === "cd" || head === "pushd" || head === "popd") {
95
107
  const target = args.find((a) => !a.startsWith("-"));
@@ -100,6 +112,49 @@ export function follow(argv: readonly string[], state: LineState): void {
100
112
  }
101
113
  }
102
114
 
115
+ /**
116
+ * `expand` for a word that names a command or a script: a variable the line itself binds is
117
+ * unknown there, because the line is followed assignment by assignment and bash may skip the one
118
+ * that matters (`false && x=ls; $x sudo`, `(V=1); s${V}udo`), so the variable is empty. What the
119
+ * line does not bind, such as `$HOME`, keeps its value.
120
+ */
121
+ export function expandName(word: string, state: LineState): string | undefined {
122
+ for (const match of word.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/g))
123
+ if (state.bound.has(match[1] as string)) return undefined;
124
+ return expand(word, state);
125
+ }
126
+
127
+ /** How many times a line writes each name where it could bind it: as a bare word, or in `${name:=…}`. */
128
+ export function bindingCounts(command: string): Map<string, number> {
129
+ const counts = new Map<string, number>();
130
+ for (const match of command.matchAll(
131
+ /(?<![$\w{])([A-Za-z_]\w*)(?!\w)|\$\{([A-Za-z_]\w*):?=/g,
132
+ )) {
133
+ const name = (match[1] ?? match[2]) as string;
134
+ counts.set(name, (counts.get(name) ?? 0) + 1);
135
+ }
136
+ return counts;
137
+ }
138
+
139
+ /**
140
+ * Forgets the value of each variable a command assigns when the line names it more than once. The
141
+ * line is followed one assignment after another, but bash may bind a name by a loop, `read`,
142
+ * `declare`, `eval`, a function or a branch the rule does not follow, so a name written twice may
143
+ * hold either value (`x=ls; for x in sudo; do $x; done`); a name written once holds what it was
144
+ * given or is unset.
145
+ */
146
+ export function forgetRebound(
147
+ words: readonly string[],
148
+ state: LineState,
149
+ counts: ReadonlyMap<string, number>,
150
+ ): void {
151
+ for (const word of words) {
152
+ const name = /^([A-Za-z_][A-Za-z0-9_]*)\+?=/.exec(word)?.[1];
153
+ if (name !== undefined && (counts.get(name) ?? 0) > 1)
154
+ state.vars.set(name, undefined);
155
+ }
156
+ }
157
+
103
158
  /** Whether `path` is `root` or under it, comparing the paths as written. */
104
159
  export function under(path: string, root: string): boolean {
105
160
  const rel = relative(resolve(root), resolve(path));
@@ -119,7 +174,7 @@ function realPath(path: string): string {
119
174
  }
120
175
 
121
176
  /** Whether a write to `path` stays inside a root, as written. */
122
- export function insideRoots(path: string, state: LineState): boolean {
177
+ function insideRoots(path: string, state: LineState): boolean {
123
178
  return state.roots.some((root) => under(path, root));
124
179
  }
125
180
 
@@ -158,3 +213,29 @@ export function rmHeld(
158
213
  }
159
214
  return undefined;
160
215
  }
216
+
217
+ /**
218
+ * Whether a write to the word stays inside a scratch root. A word the line cannot resolve is read
219
+ * as written from the workspace, as before variables were followed.
220
+ */
221
+ export function writable(word: string, state: LineState): boolean {
222
+ if (word === "/dev/null") return true;
223
+ return insideRoots(targetPath(word, state), state);
224
+ }
225
+
226
+ /** Where a write to the word lands: the word resolved from the line, else from the workspace, else as written. */
227
+ function targetPath(word: string, state: LineState): string {
228
+ return (
229
+ resolvePath(word, state) ??
230
+ resolvePath(word, { ...state, vars: new Map(), cwd: state.workspace }) ??
231
+ word
232
+ );
233
+ }
234
+
235
+ /** Whether a write to the word reaches a device node, such as a disk, rather than `/dev/null`. */
236
+ export function deviceTarget(word: string, state: LineState): boolean {
237
+ // A target the line cannot expand may be any device, so it is read as one.
238
+ if (/[$`]/.test(word) && resolvePath(word, state) === undefined) return true;
239
+ const path = targetPath(word, state);
240
+ return path !== "/dev/null" && under(path, "/dev");
241
+ }