@alphacoder-v0/pi-loops 0.22.7 → 0.24.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 (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/src/danger.ts CHANGED
@@ -8,26 +8,246 @@
8
8
  */
9
9
  import * as os from "node:os";
10
10
 
11
+ const SHELL_WORD = /\b(bash|sh|zsh|fish|dash|ksh)\b/;
12
+
13
+ /**
14
+ * `/\b(curl|wget)\b[\s\S]*\|\s*\S*\b(bash|sh|zsh|fish|dash|ksh)\b/`, in one pass: that regex
15
+ * backtracks over every `|` for every curl, and 200 KB of `curl |` took ten seconds. After the first
16
+ * curl or wget, a `|` reaches a shell named later in its own run of non-space characters, or in the
17
+ * next run when the `|` ends its run.
18
+ */
19
+ function curlIntoShell(command: string): boolean {
20
+ const at = command.search(/\b(curl|wget)\b/);
21
+ if (at === -1) return false;
22
+ let pipeEnded = false;
23
+ for (const [run] of command.slice(at).matchAll(/\S+/g)) {
24
+ const from = pipeEnded ? 0 : run.indexOf("|") + 1;
25
+ if ((pipeEnded || from > 0) && SHELL_WORD.test(run.slice(from))) return true;
26
+ pipeEnded = run.endsWith("|");
27
+ }
28
+ return false;
29
+ }
30
+
31
+ /** Whether `re` (global) matches in `text` at or after `from`, reading the characters before `from` for `\b`. */
32
+ function matchesFrom(re: RegExp, text: string, from: number): RegExpExecArray | null {
33
+ re.lastIndex = from;
34
+ return re.exec(text);
35
+ }
36
+
37
+ /**
38
+ * `/\bdd\b[^\n]*\bof=\/dev\/(disk|sd[a-z]|nvme|hd[a-z])/`, in one pass: per line, the first `dd`
39
+ * and then the target after it, where the regex tried every `dd` against the rest of the line.
40
+ */
41
+ function ddToBlockDevice(command: string): boolean {
42
+ return command.split("\n").some((line) => {
43
+ const dd = /\bdd\b/g.exec(line);
44
+ return !!dd && !!matchesFrom(/\bof=\/dev\/(disk|sd[a-z]|nvme|hd[a-z])/g, line, dd.index + 2);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * `/\bchmod\b(\s+-\S+)*\s+0?777\s+\//`, in one pass over the words: a word ending in `chmod`, any
50
+ * words that are flags, then `777` or `0777`, then a word that starts with `/`.
51
+ */
52
+ function chmod777Absolute(command: string): boolean {
53
+ const words = command.split(/\s+/);
54
+ let afterChmod = false; // a chmod word, then only flags since
55
+ for (let i = 0; i < words.length; i++) {
56
+ const w = words[i];
57
+ if (afterChmod && /^0?777$/.test(w) && i + 1 < words.length && words[i + 1].startsWith("/")) return true;
58
+ afterChmod = (afterChmod && /^-\S/.test(w)) || /\bchmod$/.test(w);
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * `/\bgit\s+push\b[^\n]*(--force|--force-with-lease|\s-f\b|\s\+)[^\n]*\b(main|master)\b/`, in one
65
+ * pass: for the first `git push` that ends on each line (its `\s+` may cross lines), the first force
66
+ * after it on that line (`\s-f` may start at the newline), then a branch on the line the force ends.
67
+ * Each search also stops at a newline, so no line is read twice.
68
+ */
69
+ function forcePushMain(command: string): boolean {
70
+ const push = /\bgit\s+push\b/g;
71
+ let tried = -1; // where the line of the last push tried ends
72
+ for (let p = push.exec(command); p; p = push.exec(command)) {
73
+ const from = p.index + p[0].length;
74
+ if (from <= tried) continue; // a push before it on this line reached at least as far
75
+ tried = command.indexOf("\n", from) === -1 ? command.length : command.indexOf("\n", from);
76
+ const force = matchesFrom(/--force|--force-with-lease|\s-f\b|\s\+|\n/g, command, from);
77
+ if (!force || force[0] === "\n") continue;
78
+ const branch = matchesFrom(/\b(main|master)\b|\n/g, command, force.index + force[0].length);
79
+ if (branch && branch[0] !== "\n") return true;
80
+ }
81
+ return false;
82
+ }
83
+
11
84
  /**
12
85
  * The patterns, widened where the obvious ones are trivially evaded.
13
86
  * They run against a *dequoted* copy of the command (see `dequote`), because `su''do` and
14
87
  * `sh""utdown` are the same program to a shell and no word-boundary rule survives them.
15
88
  */
16
- const PATTERNS: Array<[label: string, re: RegExp]> = [
89
+ const PATTERNS: Array<[label: string, re: RegExp | ((command: string) => boolean)]> = [
17
90
  ["sudo invocation", /\b(sudo|doas|pkexec)\b/],
18
91
  // Stopping at the first pipe is not enough; a command can reach a shell through any number of them.
19
- ["curl/wget piped into a shell", /\b(curl|wget)\b[\s\S]*\|\s*\S*\b(bash|sh|zsh|fish|dash|ksh)\b/],
20
- ["dd writing to a block device", /\bdd\b[^\n]*\bof=\/dev\/(disk|sd[a-z]|nvme|hd[a-z])/],
92
+ ["curl/wget piped into a shell", curlIntoShell],
93
+ ["dd writing to a block device", ddToBlockDevice],
21
94
  ["mkfs / format command", /\bmkfs(\.|\s)/],
22
95
  // Requiring 777 immediately after chmod misses any flag (-R) walks past it.
23
- ["chmod 777 on an absolute path", /\bchmod\b(\s+-\S+)*\s+0?777\s+\//],
96
+ ["chmod 777 on an absolute path", chmod777Absolute],
24
97
  ["shutdown / reboot / halt", /\b(shutdown|reboot|halt|poweroff)\b/],
25
98
  // Matching only `--force`/`-f` misses `+main`, which is the same thing through a refspec.
26
- ["git push --force on main/master", /\bgit\s+push\b[^\n]*(--force|--force-with-lease|\s-f\b|\s\+)[^\n]*\b(main|master)\b/],
99
+ ["git push --force on main/master", forcePushMain],
27
100
  ["piping or substituting into eval", /(\|\s*eval\b|\beval\s+["'`]?\$\()/],
28
101
  [":(){:|:&};: forkbomb", /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/],
29
102
  ];
30
103
 
104
+ /** A run stopping other runs, or every loop on the machine, is a person's call, not a loop's. */
105
+ const PI_LOOPS_STOP = "pi-loops abort / stop / restart";
106
+ /**
107
+ * The program, by name: `pi-loops` anywhere in the word (a path, `$(which pi-loops)`, an npm
108
+ * package spec, an option's value such as `-O'pi-loops stop'`), or the bin that loads it.
109
+ */
110
+ const PI_LOOPS_NAME = /(?:pi-loops|cli-entry\.mjs)\b/;
111
+ /**
112
+ * A stop verb after the program: separated by whitespace, a brace or comma (brace expansion), a
113
+ * quote, or `$IFS`, which the shell turns into whitespace. Tested on what follows the first
114
+ * `PI_LOOPS_NAME` only, so the search stays linear in the length of the command.
115
+ */
116
+ const STOP_AFTER = /(?:[\s{,"']|\$\{?IFS\}?)(?:host\s+)?["']?(abort|stop|restart)\b/;
117
+ /** Where one command ends and the next starts, for this policy: a newline, `;`, `&&`, `||`, `|`, `&`. */
118
+ const SUBCOMMANDS = /;|&&|\|\||\||&|\n/;
119
+ /**
120
+ * Programs whose quoted arguments are text (a message, a pattern, a path), not a command they run.
121
+ * Quoted text is read as text only when every sub-command starts with one of them; anything else
122
+ * (`ssh`, `python3 -c`, `$SHELL -c`, `env -S`, `tmux new`) may run what it is given, and a list of
123
+ * those could never be complete. `git` counts only with a sub-command in `GIT_TEXT` and no option
124
+ * that runs a program (`gitTakesText`), `gh` not for `codespace`, and `npm` only as `npm test` or
125
+ * `npm run <name>` with no quote of its own, since npm hands its arguments to a script's shell.
126
+ */
127
+ const TAKES_TEXT = new Set(["git", "grep", "egrep", "fgrep", "rg", "ag", "echo", "printf", "gh", "cat", "cd", "ls", "test", "[", "true", "false", "jq", "npm"]);
128
+ const GIT_TEXT = new Set(["commit", "tag", "stash", "notes", "add", "status", "fetch", "log"]);
129
+ /** git options that run a program they are handed: `--upload-pack`, `--receive-pack`, `--exec`. */
130
+ const GIT_RUNS = /^--(upload-pack|receive-pack|exec)/;
131
+ /** `git log` options that show a patch, which runs the diff drivers git is configured with. */
132
+ const GIT_LOG_PATCH = /^(-[a-zA-Z]*[puc]|--(patch|ext-diff|textconv|cc|output|function-context))/;
133
+ /** What `blankQuotedPhrases` leaves where a quoted phrase was. */
134
+ const BLANKED = "\u0001";
135
+
136
+ /** Whether a `git` sub-command takes its quoted text as text: past `-C <dir>`, `--git-dir` and `--work-tree`, a `GIT_TEXT` sub-command with no option that runs a program, and `log` without a patch. */
137
+ function gitTakesText(words: string[]): boolean {
138
+ let i = 1;
139
+ while (i < words.length) {
140
+ if (words[i] === "-C" || words[i] === "--git-dir" || words[i] === "--work-tree") i += 2;
141
+ else if (/^--(git-dir|work-tree)=/.test(words[i])) i++;
142
+ else break;
143
+ }
144
+ const sub = words[i] ?? "";
145
+ const rest = words.slice(i + 1);
146
+ if (!GIT_TEXT.has(sub) || rest.some((w) => GIT_RUNS.test(w))) return false;
147
+ return sub !== "log" || !rest.some((w) => GIT_LOG_PATCH.test(w));
148
+ }
149
+
150
+ /**
151
+ * Whether the quoted text of `joined` is only text, so `blanked` (its copy from `blankQuotedPhrases`)
152
+ * may be read in its place. Not when the command substitutes anything (a backtick, `$(`, `<(`, `>(`,
153
+ * `${ `, `${|`), quoted or not, since the shell runs what that holds; not when it redirects output
154
+ * (`>`), which can write a config that runs a program; and only when every sub-command starts with
155
+ * a `TAKES_TEXT` program, with no `VAR=value` before it (`GIT_EXTERNAL_DIFF='…' git diff`).
156
+ */
157
+ function quotesAreText(joined: string, blanked: string): boolean {
158
+ if (/`|[$<>]\(|\$\{[\s|]|>/.test(joined)) return false;
159
+ for (const sub of blanked.split(SUBCOMMANDS)) {
160
+ const words = sub.trim().split(/\s+/).filter(Boolean);
161
+ if (!words.length) continue;
162
+ const name = words[0].split("/").pop() ?? "";
163
+ const next = words[1] ?? "";
164
+ if (!TAKES_TEXT.has(name) || words.some((w) => w.includes("alias"))) return false;
165
+ if (name === "git" && !gitTakesText(words)) return false;
166
+ if (name === "gh" && next === "codespace") return false;
167
+ if (name === "npm" && !((next === "test" || (next === "run" && words.length > 2)) && !/["'\u0001]/.test(sub))) return false;
168
+ }
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Blank every quoted string (`'…'`, `"…"`, `$'…'`) that holds whitespace, so a message that
174
+ * mentions pi-loops (`git commit -m "pi-loops: stop retrying"`) is not read as the command. A quoted
175
+ * single word stays, since the shell joins `"st"op` back into the verb. A quoted string across lines
176
+ * stays, and so does a double-quoted string that substitutes a command (`$(…)` or a backtick), since
177
+ * the shell runs it. A blanked phrase becomes `BLANKED`, so a sub-command can tell it held one. A
178
+ * backslash outside quotes escapes the next character. An unterminated quote leaves the rest as
179
+ * written. One pass.
180
+ */
181
+ function blankQuotedPhrases(s: string): string {
182
+ let out = "";
183
+ let i = 0;
184
+ while (i < s.length) {
185
+ const c = s[i];
186
+ if (c === "\\") {
187
+ out += s.slice(i, i + 2);
188
+ i += 2;
189
+ continue;
190
+ }
191
+ const ansi = c === "$" && s[i + 1] === "'";
192
+ if (c !== "'" && c !== '"' && !ansi) {
193
+ out += c;
194
+ i++;
195
+ continue;
196
+ }
197
+ const open = ansi ? i + 1 : i;
198
+ const q = s[open];
199
+ let j = open + 1;
200
+ if (q === "'" && !ansi) j = s.indexOf("'", j);
201
+ else {
202
+ while (j < s.length && s[j] !== q) j += s[j] === "\\" ? 2 : 1;
203
+ if (j >= s.length) j = -1;
204
+ }
205
+ if (j === -1) return out + s.slice(i);
206
+ const body = s.slice(open + 1, j);
207
+ const substitutes = q === '"' && /`|\$\(/.test(body);
208
+ out += /\s/.test(body) && !/[\n\r]/.test(body) && !substitutes ? BLANKED : s.slice(i, j + 1);
209
+ i = j + 1;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Whether `command` runs `pi-loops abort|stop|restart`. Fail closed: pi-loops anywhere in a
216
+ * sub-command (cut at `;`, `&&`, `||`, `|`, `&` and newlines) followed by a stop verb counts, so
217
+ * `echo pi-loops stop` is refused too; only another sub-command (`cd ~/code/pi-loops && npm stop`),
218
+ * and a quoted phrase given to a program that takes it as text (`quotesAreText`), are not read as the command. A verb the shell
219
+ * expands (`pi-loops $V`, `$(…)`, a backtick) is refused, since what it expands to is not known here.
220
+ * This catches what an agent types to stop pi-loops; it is not a sandbox. Known gaps: a program name
221
+ * the shell expands (`P=pi-loops; $P stop`), a verb read from input (`echo stop | xargs pi-loops`),
222
+ * an alias or a function defined to run it, and stopping the process another way (`kill`).
223
+ */
224
+ function stopsPiLoops(command: string): boolean {
225
+ const joined = command.replace(/\\\r?\n/g, ""); // a line continuation is one line to the shell
226
+ const blanked = blankQuotedPhrases(joined);
227
+ const flat = dequote(quotesAreText(joined, blanked) ? blanked.replaceAll(BLANKED, " ") : joined).replace(/["']/g, "");
228
+ for (const sub of flat.split(SUBCOMMANDS)) {
229
+ const at = sub.search(PI_LOOPS_NAME);
230
+ if (at === -1) continue;
231
+ const rest = sub.slice(at);
232
+ if (STOP_AFTER.test(rest)) return true;
233
+ if (expandedVerb(rest.split(/\s+/))) return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ /** Whether a pi-loops in these words takes a verb the shell expands: the first word after its flags (and `host`) starts with `$` or a backtick. */
239
+ function expandedVerb(words: string[]): boolean {
240
+ for (let i = 0; i < words.length; i++) {
241
+ if (!PI_LOOPS_NAME.test(words[i])) continue;
242
+ let k = i + 1;
243
+ while (k < words.length && words[k].startsWith("-")) k++;
244
+ if (words[k] === "host") k++;
245
+ if (/^[$`]/.test(words[k] ?? "")) return true;
246
+ i = Math.max(i, k - 1); // a pi-loops among the flags skipped has the same verb: each word is read once
247
+ }
248
+ return false;
249
+ }
250
+
31
251
  /** Shell syntax that starts a second command; an allowlist entry must not carry one along. */
32
252
  const CHAINS = /[;&|\n\r`$(){}<>]/;
33
253
 
@@ -123,6 +343,7 @@ export function dangerousCommandReason(command: string, home: string = process.e
123
343
  const trimmed = command.trim();
124
344
  if (allow.some((a) => allows(a, trimmed))) return undefined;
125
345
  const scanned = dequote(command);
126
- for (const [label, re] of PATTERNS) if (re.test(scanned)) return label;
346
+ for (const [label, re] of PATTERNS) if (typeof re === "function" ? re(scanned) : re.test(scanned)) return label;
347
+ if (stopsPiLoops(command)) return PI_LOOPS_STOP;
127
348
  return rmRecursiveForceOnDangerousTarget(scanned, home || undefined);
128
349
  }