@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
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The local pre-check: a rule whose condition is only "<path> exists / appears" is looked at with
3
+ * `stat` before any model is asked about it (docs/triggers.md, "Rules that wait for a path").
4
+ *
5
+ * Whether a condition is path-only is decided once, when the rule is created, and recorded on the
6
+ * rule (`precheck`): the creating process is the person's own window or page, so `~` is their home
7
+ * even when the background process was started with another one. The grammar matches whole
8
+ * strings and knows a fixed list of verbs. A refused condition is safe — the model checks it as it
9
+ * always did — so every doubt (a glob, a second clause, an unknown variable) is a refusal, and each
10
+ * refusal says why.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ /** Recorded on a rule when it is created. `of` is the condition it was read from; `raw` the path as typed; `resolved` the absolute path. */
16
+ export interface PathWatch {
17
+ v: 1;
18
+ of: string;
19
+ raw: string;
20
+ resolved: string;
21
+ }
22
+
23
+ export type PathOnly = { watch: PathWatch } | { why: string };
24
+
25
+ /** Paths a `stat` would send over the network: an SMB/UNC share, an automount, a macOS server. */
26
+ const NETWORK = /^(?:\/\/|\/net\/|\/Network\/)/;
27
+ const NETWORK_WHY = "a network path needs the model";
28
+
29
+ /** A path longer than this is not something a person typed as a path. */
30
+ export const PATH_MAX_BYTES = 1024;
31
+ /** How long one look may take before it counts as `unknown` (a hung network mount). */
32
+ export const LOOK_TIMEOUT_MS = 2000;
33
+
34
+ const EN_VERBS = ["exists", "appears", "is created", "shows up", "is there", "is present"];
35
+ const ZH_VERBS = ["存在", "出现", "被创建", "生成"];
36
+ const ZH_NOUNS = ["文件", "目录"];
37
+ const EN_LEADS = ["when ", "if ", "once "];
38
+ const ZH_LEADS = ["当", "如果", "一旦"];
39
+ const ZH_TAILS = ["的时候", "时", "后"];
40
+ const EN_NOUN = /^(?:the\s+)?(?:(?:file|directory|folder|path)\s+)?/i;
41
+
42
+ /**
43
+ * Whether `condition` is only "<path> exists / appears", and the path it waits for. `at.cwd` is the
44
+ * rule's project (a relative path resolves there); `at.home` is the creating process's home.
45
+ */
46
+ export function pathOnlyCondition(condition: string, at: { cwd: string; home: string }): PathOnly {
47
+ let s = condition.trim().replace(/\s+/g, " ");
48
+ if (!s) return { why: "the condition is empty" };
49
+ s = s.replace(/[.。!!]$/, "").trim();
50
+ const lower = s.toLowerCase();
51
+ const enLead = EN_LEADS.find((w) => lower.startsWith(w));
52
+ if (enLead) s = s.slice(enLead.length).trim();
53
+ else {
54
+ const zhLead = ZH_LEADS.find((w) => s.startsWith(w));
55
+ if (zhLead) s = s.slice(zhLead.length).trim();
56
+ }
57
+ const zhTail = ZH_TAILS.find((w) => s.endsWith(w));
58
+ if (zhTail) s = s.slice(0, -zhTail.length).trim();
59
+ // "the file ~/x exists": the noun is only allowed in front of the path.
60
+ s = s.replace(EN_NOUN, "");
61
+
62
+ const token = pathToken(s);
63
+ if ("why" in token) return token;
64
+ const rest = token.rest.trim();
65
+ const verbWhy = verbOnly(rest);
66
+ if (verbWhy) return { why: verbWhy };
67
+ const bad = badPath(token.path);
68
+ if (bad) return { why: bad };
69
+ const resolved = resolvePath(token.path, at);
70
+ // Again on the resolved path: `/./net/h/x` and `../../net/h/x` are network paths too.
71
+ if (NETWORK.test(resolved)) return { why: NETWORK_WHY };
72
+ return { watch: { v: 1, of: condition, raw: token.path, resolved } };
73
+ }
74
+
75
+ /** The path at the start of `s` (quoted, backticked, or one run without spaces) and what follows it. */
76
+ function pathToken(s: string): { path: string; rest: string } | { why: string } {
77
+ const q = s[0];
78
+ if (q === '"' || q === "'" || q === "`") {
79
+ const end = s.indexOf(q, 1);
80
+ if (end < 0) return { why: "a quote is not closed" };
81
+ const p = s.slice(1, end);
82
+ if (!p.trim()) return { why: "the quotes hold no path" };
83
+ return { path: p, rest: s.slice(end + 1) };
84
+ }
85
+ const m = /^[^\s"'`]+/.exec(s);
86
+ if (!m) return { why: "no path at the start of the condition" };
87
+ let p = m[0];
88
+ let rest = s.slice(p.length);
89
+ // Chinese needs no space before the verb: "$HOME/x存在".
90
+ for (const verb of ZH_VERBS) {
91
+ const i = p.indexOf(verb);
92
+ if (i > 0) {
93
+ rest = p.slice(i) + rest;
94
+ p = p.slice(0, i);
95
+ break;
96
+ }
97
+ }
98
+ // A noun at the end of the token is part of the path ("~/备份文件"): the noun before the verb is
99
+ // only read off when a space sets it apart ("~/x 文件存在"), in verbOnly.
100
+ return { path: p, rest };
101
+ }
102
+
103
+ /** Undefined when `rest` is exactly one verb of the list; otherwise why it is not. */
104
+ function verbOnly(rest: string): string | undefined {
105
+ if (!rest) return "no verb: say that the path exists or appears";
106
+ const lower = rest.toLowerCase();
107
+ for (const verb of EN_VERBS) {
108
+ if (lower === verb) return undefined;
109
+ if (lower.startsWith(`${verb} `)) return `an extra clause after "${verb}" needs the model`;
110
+ }
111
+ let zh = rest;
112
+ const noun = ZH_NOUNS.find((n) => zh.startsWith(n));
113
+ if (noun) zh = zh.slice(noun.length).trim();
114
+ for (const verb of ZH_VERBS) {
115
+ if (zh === verb) return undefined;
116
+ if (zh.startsWith(verb)) return `an extra clause after "${verb}" needs the model`;
117
+ }
118
+ return "not only that the path exists or appears";
119
+ }
120
+
121
+ /** Why `p` is not clearly one path, or undefined. */
122
+ function badPath(p: string): string | undefined {
123
+ if (Buffer.byteLength(p, "utf8") > PATH_MAX_BYTES) return `the path is longer than ${PATH_MAX_BYTES} bytes`;
124
+ if (/[\u0000-\u001f\u007f]/.test(p)) return "the path holds a control character";
125
+ if (p.includes("://")) return "a URL is not a local path";
126
+ if (NETWORK.test(p)) return NETWORK_WHY;
127
+ if (/[*?[\]{}]/.test(p.replace(/^\$\{HOME\}/, ""))) return "a glob matches many paths, not one";
128
+ if (/\$(?!HOME\/|\{HOME\}\/)/.test(p) || /.\$/.test(p)) return "only $HOME is expanded; another variable needs the model";
129
+ if (/^~[^/]/.test(p)) return "only your own home (~/) is expanded";
130
+ if (/^(?:~\/|\$HOME\/|\$\{HOME\}\/|\/|\.\.?\/)/.test(p)) return undefined;
131
+ if (p.includes("/")) return undefined;
132
+ if (/^[^/]+\.[A-Za-z0-9]{1,10}$/.test(p)) return undefined;
133
+ return "not clearly a path: give it a /, a ~/ or a file extension";
134
+ }
135
+
136
+ function resolvePath(p: string, at: { cwd: string; home: string }): string {
137
+ const home = /^(?:~|\$HOME|\$\{HOME\})\//.exec(p);
138
+ if (home) return path.join(at.home, p.slice(home[0].length));
139
+ return path.resolve(at.cwd, p);
140
+ }
141
+
142
+ /** The watch that still applies: undefined when `of` is no longer the rule's condition (a hand edit) or the record is not one this build wrote. */
143
+ export function watchOf(rule: { condition: string; precheck?: unknown }): PathWatch | undefined {
144
+ const w = rule.precheck as Partial<PathWatch> | undefined;
145
+ if (!w || w.v !== 1 || w.of !== rule.condition || typeof w.raw !== "string" || typeof w.resolved !== "string" || !path.isAbsolute(w.resolved)) return undefined;
146
+ return w as PathWatch;
147
+ }
148
+
149
+ export type Look = "present" | "absent" | "unknown";
150
+
151
+ /**
152
+ * One `stat` that follows symlinks: the path, or what a dangling link points at. `ENOENT` and
153
+ * `ENOTDIR` are absent; any other error, or no answer within `timeoutMs`, is `unknown`, which the
154
+ * caller treats as "ask the model", as it did before there was a pre-check.
155
+ */
156
+ export async function lookAt(w: PathWatch, opts: { timeoutMs?: number; stat?: (p: string) => Promise<unknown> } = {}): Promise<Look> {
157
+ const stat = opts.stat ?? ((p: string) => fs.promises.stat(p));
158
+ let timer: NodeJS.Timeout | undefined;
159
+ const timeout = new Promise<Look>((resolve) => {
160
+ timer = setTimeout(() => resolve("unknown"), opts.timeoutMs ?? LOOK_TIMEOUT_MS);
161
+ });
162
+ const looked = stat(w.resolved).then(
163
+ (): Look => "present",
164
+ (err: any): Look => (err?.code === "ENOENT" || err?.code === "ENOTDIR" ? "absent" : "unknown"),
165
+ );
166
+ try {
167
+ return await Promise.race([looked, timeout]);
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }
172
+
173
+ /** Stats one process has out at once, across every rule: two hung ones still leave libuv two threads. */
174
+ export const LOOKS_IN_FLIGHT_MAX = 2;
175
+
176
+ /**
177
+ * Looks, with few in flight. A `stat` on a dead network mount can hang past its timeout and hold
178
+ * one of libuv's four threads, so a look starts nothing and answers `unknown` while the same rule or
179
+ * the same path has one out, or while `LOOKS_IN_FLIGHT_MAX` are out. The process has one of these:
180
+ * its trigger runtime's.
181
+ */
182
+ export class PathLooks {
183
+ private readonly rules = new Set<string>();
184
+ private readonly paths = new Set<string>();
185
+ private readonly opts: { timeoutMs?: number; stat?: (p: string) => Promise<unknown>; max?: number };
186
+ constructor(opts: { timeoutMs?: number; stat?: (p: string) => Promise<unknown>; max?: number } = {}) {
187
+ this.opts = opts;
188
+ }
189
+
190
+ look(ruleId: string, w: PathWatch): Promise<Look> {
191
+ if (this.rules.has(ruleId) || this.paths.has(w.resolved) || this.paths.size >= (this.opts.max ?? LOOKS_IN_FLIGHT_MAX)) return Promise.resolve("unknown");
192
+ this.rules.add(ruleId);
193
+ this.paths.add(w.resolved);
194
+ const stat = this.opts.stat ?? ((p: string) => fs.promises.stat(p));
195
+ // Cleared when the stat itself settles, not when the timeout answers.
196
+ const settled = () => {
197
+ this.rules.delete(ruleId);
198
+ this.paths.delete(w.resolved);
199
+ };
200
+ const tracked = (p: string) => {
201
+ const pending = stat(p);
202
+ pending.then(settled, settled);
203
+ return pending;
204
+ };
205
+ return lookAt(w, { timeoutMs: this.opts.timeoutMs, stat: tracked });
206
+ }
207
+ }
208
+
209
+ /** What the rule's row on the page says about its path: "next look 09:22 · no model call until the file exists", and the other two states. */
210
+ export function lookLine(look: Look | undefined, nextLookAt: number | undefined, clock: (ms: number) => string): string {
211
+ if (look === "present") return "the file is there · the next look asks the model";
212
+ if (look === "unknown") return "could not look at the path · checked by the model";
213
+ const wait = "no model call until the file exists";
214
+ return nextLookAt === undefined ? wait : `next look ${clock(nextLookAt)} · ${wait}`;
215
+ }
package/src/paths.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * directories.
9
9
  */
10
10
  import * as fs from "node:fs";
11
+ import * as os from "node:os";
11
12
  import * as path from "node:path";
12
13
 
13
14
  /**
@@ -31,3 +32,26 @@ export function realpathish(p: string): string {
31
32
  }
32
33
  }
33
34
  }
35
+
36
+ /** A root so broad that containment would mean "everything": never treated as one project. */
37
+ function tooBroad(dir: string): boolean {
38
+ return dir === "/" || dir === os.homedir() || path.dirname(dir) === dir;
39
+ }
40
+
41
+ /** True when `p` is the project rooted at `root` or a directory inside it, symlinks resolved. */
42
+ export function withinProject(root: string, p: string): boolean {
43
+ const a = realpathish(root);
44
+ const b = realpathish(p);
45
+ if (!a || !b) return false;
46
+ if (a === b) return true;
47
+ // Containment is what makes a worktree or a subdirectory the same project. `/` and `$HOME`
48
+ // contain everything, so a rule created with one of those as its cwd governs only itself.
49
+ if (tooBroad(a)) return false;
50
+ const rel = path.relative(a, b);
51
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
52
+ }
53
+
54
+ /** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
55
+ export function sameProject(a: string, b: string): boolean {
56
+ return withinProject(b, a) || withinProject(a, b);
57
+ }
package/src/pi-floor.ts CHANGED
@@ -12,6 +12,16 @@
12
12
  */
13
13
  export const PI_MIN_VERSION = "0.84.3";
14
14
 
15
+ /** -1, 0 or 1 comparing the leading N.N.N of two versions; a side that does not parse counts as 0.0.0. */
16
+ export function compareVersions(a: string, b: string): number {
17
+ const left = parse(a) ?? [0, 0, 0];
18
+ const right = parse(b) ?? [0, 0, 0];
19
+ for (let i = 0; i < 3; i++) {
20
+ if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1;
21
+ }
22
+ return 0;
23
+ }
24
+
15
25
  /** `[major, minor, patch]`, or undefined for anything that does not start `N.N.N` (a `v` in front is fine). */
16
26
  function parse(version: string): [number, number, number] | undefined {
17
27
  const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());