@alphacoder-v0/pi-loops 0.24.0 → 0.24.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.
- package/CHANGELOG.md +136 -0
- package/README.md +37 -30
- package/README.zh-CN.md +4 -3
- package/docs/cli.md +104 -28
- package/docs/configuration.md +2 -2
- package/docs/design.md +12 -7
- package/docs/triggers.md +11 -7
- package/docs/troubleshooting.md +4 -2
- package/docs/web-ui-parity.md +18 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/browser-open.ts +169 -0
- package/src/cli.ts +300 -95
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +82 -35
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/ui-prefs.ts +6 -2
- package/src/web.mjs +70 -38
package/docs/troubleshooting.md
CHANGED
|
@@ -43,8 +43,10 @@ longer loaded.
|
|
|
43
43
|
|
|
44
44
|
**The browser window says "This browser has not been here before."** It has no cookie for this
|
|
45
45
|
address, which is normal for a new device, a new browser, a private window, or after clearing site
|
|
46
|
-
data.
|
|
47
|
-
|
|
46
|
+
data. On this machine, run `pi-loops open` in a terminal (or `/pi-loops web` inside pi) for a link
|
|
47
|
+
that signs this browser in. For another device, run `pi-loops pair`, or press **add device** in a
|
|
48
|
+
browser that is already signed in and scan the QR, then type the six digits into the box on that
|
|
49
|
+
page.
|
|
48
50
|
|
|
49
51
|
**The phone cannot reach it at all.** The server binds loopback by default, and a phone has no
|
|
50
52
|
route to that. Either `tailscale serve --bg 4173` (the server stays on loopback; the tailnet does
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -327,7 +327,8 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
327
327
|
**Waiting on** and **If you don't** when the loop said them, then news. A card names its loop
|
|
328
328
|
only when the list holds more than one loop's findings, and says where a claim runs only when
|
|
329
329
|
that is not the conversation on screen (another conversation's or project's finding, or a
|
|
330
|
-
terminal that takes no messages). **See the run** is there only when the finding has a run
|
|
330
|
+
terminal that takes no messages). **See the run** is there only when the finding has a run this page can read
|
|
331
|
+
(`test/page-server.test.ts`: *See the run is offered only for a transcript the server would read*).
|
|
331
332
|
Every card carries its finding's whole id; no button names a position. `test/web-page.test.ts`:
|
|
332
333
|
*the inbox shows decisions before news, each row only with a value, and where a claim runs
|
|
333
334
|
only when that is not here*. `test/page-server.test.ts`: *GET /inbox: one project's cards, decisions first,
|
|
@@ -502,10 +503,10 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
502
503
|
open this on a phone. Wide, it is a column beside the conversation, which the rail's Loops
|
|
503
504
|
button gives back to the conversation, remembered; up to 1100 px it is a drawer from that
|
|
504
505
|
button; on a phone (760 px and less) it is the Loops tab.
|
|
505
|
-
- [x]
|
|
506
|
+
- [x] Twelve controls do not fit across a narrow conversation. Below 1000 px of conversation,
|
|
506
507
|
whatever is beside it, the ones you reach for mid-conversation (undo, find, compact and resume)
|
|
507
508
|
stay in the header on one row with ⋯, and the header stays two rows at a 1280 or 1440 px
|
|
508
|
-
window; reload, save, share,
|
|
509
|
+
window; reload, save, share, add device, theme and notifications go behind ⋯. The
|
|
509
510
|
sheet lists all of them, moved — not copied — into it. On a phone (760 px and less) every one is behind ⋯.
|
|
510
511
|
`test/web-page.test.ts`: *the header's secondary actions move into a sheet and back*, *below
|
|
511
512
|
1000 px of conversation, undo, find, compact and resume stay in the header and the rest go
|
|
@@ -591,8 +592,8 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
591
592
|
first; it replaces `new_trigger`'s confirm, so there is one question, never two. A scheduled
|
|
592
593
|
turn, a sub-agent and pi-loops in the background ask nobody. `test/scoping.test.ts`: *the answer
|
|
593
594
|
is promote_to_chat and fire_once for a rule, and stateful and verify for a schedule*, *a
|
|
594
|
-
sub-agent
|
|
595
|
-
host's confirm once and cron_create creates*; J8 in `test/journey/journeys/j08-watch.mjs`
|
|
595
|
+
sub-agent has no new_trigger; a scheduled turn and a pi with no dialog keep today's gate:
|
|
596
|
+
new_trigger asks the host's confirm once and cron_create creates*; J8 in `test/journey/journeys/j08-watch.mjs`
|
|
596
597
|
drives the selector in tmux.
|
|
597
598
|
- [x] At 390 px the box and the card fit: nothing scrolls sideways, the chips and buttons wrap, and
|
|
598
599
|
every button is at least 32 px tall (J8 in `test/journey/journeys/j08-watch.mjs`).
|
|
@@ -742,7 +743,7 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
742
743
|
whether pi-loops runs*.
|
|
743
744
|
- [x] **Starting over does not mean going back to a terminal.** The context is finished with far
|
|
744
745
|
more often than the window is: a new session and going back to an earlier one are both here,
|
|
745
|
-
by button (the list's + New
|
|
746
|
+
by button (the list's + New in this project) and by `/clear`, `/new` and `/resume` typed
|
|
746
747
|
in the composer — the habit comes from a
|
|
747
748
|
terminal and the muscle memory arrives with it. Neither deletes anything: the session being
|
|
748
749
|
left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
|
|
@@ -969,6 +970,17 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
969
970
|
otherwise reloading a bookmark with a stale code on it burns the budget the phone needs.
|
|
970
971
|
`test/web.test.ts`: *a signed-in browser reloading a stale pairing code does not spend a
|
|
971
972
|
guess*.
|
|
973
|
+
- [x] The first browser on this machine gets in with one action. `pi-loops` prints one link with a
|
|
974
|
+
one-use key and opens the browser on it, or says it could not, so a paste of that link works
|
|
975
|
+
on the first try; on WSL the Windows browser is opened through `rundll32.exe`, then
|
|
976
|
+
`cmd.exe`, and "opened" is said only once a browser has spent the key. `pi-loops open` prints a fresh link, and the door names it.
|
|
977
|
+
`test/browser-open.test.ts`: *opened means the opener exited with a success code or is still
|
|
978
|
+
running*. `test/cli.test.ts`: *what pi-loops prints once the page has the conversation*,
|
|
979
|
+
*pi-loops open is a word of its own*. `test/web-page.test.ts`: *the door is a heading, one
|
|
980
|
+
line per way in, and the code field*.
|
|
981
|
+
- [x] `/pi-loops web` opens the conversation you are in from inside pi, with no `pi-loops` on your
|
|
982
|
+
PATH: the same link and opener, and the link copied to the clipboard. Checked by reading
|
|
983
|
+
(`src/pi-loops.ts` cannot be imported by a test).
|
|
972
984
|
- [x] Pairing survives an upgrade: the token and the paired devices are files outside the package,
|
|
973
985
|
so a device stays signed in across restarts and new versions. A browser paired before 0.24.0
|
|
974
986
|
pairs once more, because its cookie held the token, which no cookie may hold now.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.2",
|
|
4
4
|
"description": "An automation layer for pi: cron + stateful loops with a triage inbox, maker/checker verification, dynamic triggers, MCP notifications and tools, lifecycle hooks, session archives. A non-invasive pi extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skills/pi-loops/SKILL.md
CHANGED
|
@@ -46,6 +46,8 @@ reported in your notes; report only new or newly closed issues". Findings should
|
|
|
46
46
|
(a reason is shown to the loop's next run, so a finding the user calls noise can say why).
|
|
47
47
|
`/cron` lists jobs, `/cron runs`, `/cron trace <job> [k]` shows what a run did. `/triggers rules`,
|
|
48
48
|
`/triggers audit` for dynamic triggers.
|
|
49
|
+
From a shell: `pi-loops inbox list --json`, `pi-loops inbox claim <id> --json`,
|
|
50
|
+
`pi-loops inbox dismiss <id> --reason <text> --json`.
|
|
49
51
|
|
|
50
52
|
## Confirmations
|
|
51
53
|
|
package/src/activity.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { InboxEntry } from "./inbox.ts";
|
|
|
8
8
|
import { ruleResultText, splitFinding } from "./inbox-card.ts";
|
|
9
9
|
import { failingJobs } from "./job-health.ts";
|
|
10
10
|
import { redact } from "./redact.ts";
|
|
11
|
-
import { runCardFor } from "./run-card.ts";
|
|
11
|
+
import { runCardFor, runCardsHere } from "./run-card.ts";
|
|
12
12
|
import type { LoopJob, RunRecord } from "./store.ts";
|
|
13
13
|
import type { AuditRecord } from "./triggers.ts";
|
|
14
14
|
|
|
@@ -45,7 +45,7 @@ export interface ActivityItem {
|
|
|
45
45
|
carded: boolean;
|
|
46
46
|
/** A rule check: its findings, so a row can point at the card. */
|
|
47
47
|
inboxIds: string[];
|
|
48
|
-
/** A rule check: the result also went to a conversation (`promote_to_chat`). */
|
|
48
|
+
/** A rule check: the result also went to a conversation (`promote_to_chat`). A run: its card is in this conversation. */
|
|
49
49
|
inChat: boolean;
|
|
50
50
|
/** The run's loop or the rule was made in the conversation asking. */
|
|
51
51
|
fromThisConversation: boolean;
|
|
@@ -123,7 +123,8 @@ export function activityFrom(input: ActivityInput): { items: ActivityItem[]; mor
|
|
|
123
123
|
...(!r.ok && r.error ? { error: clip(r.error) } : {}),
|
|
124
124
|
carded: runCardFor(r) === "card",
|
|
125
125
|
inboxIds: [],
|
|
126
|
-
|
|
126
|
+
// Where its card is: a run someone asked for cards where they asked, not where its loop was made.
|
|
127
|
+
inChat: runCardFor(r) === "card" && runCardsHere([r], { sessionId: input.sessionId, inProject: here }).length > 0,
|
|
127
128
|
fromThisConversation: !!input.sessionId && r.forSession === input.sessionId,
|
|
128
129
|
});
|
|
129
130
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which command opens a browser here, and whether one did. The table copies pi's own opener (macOS
|
|
3
|
+
* `open`, Windows `rundll32`) and adds the WSL branch pi lacks: there `xdg-open` finds no browser and
|
|
4
|
+
* exits 3 after five seconds, so the Windows side is asked directly, with pi's Windows opener. "Opened" comes from the exit
|
|
5
|
+
* code, never from the spawn alone: a terminal that says it opened a browser when nothing opened
|
|
6
|
+
* leaves a person waiting for a window that never comes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One command to try: what to run, from where, which exit codes mean the browser got it, and how
|
|
15
|
+
* long it may run before still running counts as opened.
|
|
16
|
+
*/
|
|
17
|
+
export type Opener = { cmd: string; args: string[]; cwd?: string; okExit: number[]; waitMs: number };
|
|
18
|
+
|
|
19
|
+
/** For an opener that exits as soon as it has handed the address on. */
|
|
20
|
+
const QUICK_MS = 1_500;
|
|
21
|
+
/** xdg-open that finds no browser exits 3 after about five seconds. */
|
|
22
|
+
const XDG_OPEN_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
/** What the choice depends on, passed in so a test can be any machine. */
|
|
25
|
+
export type OpenEnv = { platform: NodeJS.Platform; release: string; env: NodeJS.ProcessEnv };
|
|
26
|
+
|
|
27
|
+
/** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
|
|
28
|
+
export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
|
+
return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWsl(e: Pick<OpenEnv, "platform" | "release" | "env">): boolean {
|
|
33
|
+
return e.platform === "linux" && (!!e.env.WSL_DISTRO_NAME || /microsoft/i.test(e.release));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The commands to try in order, or why none is tried. */
|
|
37
|
+
export function openerFor(e: OpenEnv & { target: string }): Opener[] | "no-display" | "remote" {
|
|
38
|
+
if (isRemoteTty(e.env)) return "remote";
|
|
39
|
+
const t = e.target;
|
|
40
|
+
if (e.platform === "darwin") return [{ cmd: "open", args: [t], okExit: [0], waitMs: QUICK_MS }];
|
|
41
|
+
// No shell: cmd would read `&` in the address as the end of the command.
|
|
42
|
+
if (e.platform === "win32") return [{ cmd: "rundll32", args: ["url.dll,FileProtocolHandler", t], okExit: [0], waitMs: QUICK_MS }];
|
|
43
|
+
// Both start from a Windows drive: a long \\wsl.localhost cwd breaks them. Not wslview: wslu 3.2.3
|
|
44
|
+
// reads a wsl.localhost address as a relative Linux path and exits 0 without opening anything. Not
|
|
45
|
+
// explorer.exe: it exits 1 whether or not anything opened. rundll32 exits 0 even for a missing
|
|
46
|
+
// file, so cmd.exe is reached only when rundll32.exe cannot start.
|
|
47
|
+
if (isWsl(e))
|
|
48
|
+
return [
|
|
49
|
+
{ cmd: "rundll32.exe", args: ["url.dll,FileProtocolHandler", t], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
|
|
50
|
+
// cmd re-reads its line: `^` escapes the characters it would act on.
|
|
51
|
+
{ cmd: "cmd.exe", args: ["/c", "start", "", t.replace(/[\^&|]/g, "^$&")], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
|
|
52
|
+
];
|
|
53
|
+
if (!e.env.DISPLAY && !e.env.WAYLAND_DISPLAY) return "no-display";
|
|
54
|
+
return [{ cmd: "xdg-open", args: [t], okExit: [0], waitMs: XDG_OPEN_MS }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The address the opener is given for the launch file, which holds the one-use key so no argv does.
|
|
59
|
+
* On WSL the browser is a Windows one, which reads the file through `\\wsl.localhost`: measured on
|
|
60
|
+
* WSL 2 with rundll32 and `cmd.exe /c start`, each loads the file and follows it to the page.
|
|
61
|
+
* Undefined on WSL without `WSL_DISTRO_NAME`, which that address needs, and for a path holding a
|
|
62
|
+
* character Windows does not allow in a name: `\\wsl.localhost` shows those under other names, so
|
|
63
|
+
* the browser cannot find the file while rundll32 still exits 0.
|
|
64
|
+
*/
|
|
65
|
+
export function openFileTarget(file: string, e: Pick<OpenEnv, "platform" | "release" | "env">): string | undefined {
|
|
66
|
+
const href = pathToFileURL(file).href;
|
|
67
|
+
if (!isWsl(e)) return href;
|
|
68
|
+
const distro = e.env.WSL_DISTRO_NAME;
|
|
69
|
+
return distro && !/[\\:*?"<>|]/.test(file) ? `file://wsl.localhost/${encodeURIComponent(distro)}${href.slice("file://".length)}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** This machine, as `openerFor` reads it. */
|
|
73
|
+
export function thisMachine(): OpenEnv {
|
|
74
|
+
return { platform: process.platform, release: os.release(), env: process.env };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hand `target` to each opener in turn until one takes it: true when one exits with a code it
|
|
79
|
+
* counts as success, or is still running at its own deadline, which is how a browser started
|
|
80
|
+
* straight from the opener looks. `waitMs` replaces every deadline, for a test.
|
|
81
|
+
*/
|
|
82
|
+
export async function openUrl(target: string, e: OpenEnv = thisMachine(), waitMs?: number): Promise<boolean> {
|
|
83
|
+
const openers = openerFor({ ...e, target });
|
|
84
|
+
if (typeof openers === "string") return false;
|
|
85
|
+
for (const opener of openers) if (await tryOpener(opener, e.env, waitMs ?? opener.waitMs)) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tryOpener(o: Opener, env: NodeJS.ProcessEnv, waitMs: number): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let child: ReturnType<typeof spawn>;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(o.cmd, o.args, { stdio: "ignore", detached: true, cwd: o.cwd, env });
|
|
94
|
+
} catch {
|
|
95
|
+
resolve(false); // the next opener, or the printed link
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
child.unref();
|
|
100
|
+
resolve(true);
|
|
101
|
+
}, waitMs);
|
|
102
|
+
child.once("error", () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve(false);
|
|
105
|
+
});
|
|
106
|
+
child.once("exit", (code) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve(code !== null && o.okExit.includes(code));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One command that copies its stdin to the clipboard and exits 0 when it has. */
|
|
114
|
+
export type CopyCommand = { cmd: string; args: string[]; cwd?: string };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The clipboard commands to try in order, the ones pi's `copyToClipboard` runs. Not OSC 52, which
|
|
118
|
+
* pi also writes and which no terminal confirms: tmux in Windows Terminal drops it, so a terminal
|
|
119
|
+
* that said "copied" after it could be wrong. None over ssh or mosh, where this machine's clipboard
|
|
120
|
+
* is not the one in front of you.
|
|
121
|
+
*/
|
|
122
|
+
export function copyCommandsFor(e: OpenEnv): CopyCommand[] {
|
|
123
|
+
if (isRemoteTty(e.env)) return [];
|
|
124
|
+
if (e.platform === "darwin") return [{ cmd: "pbcopy", args: [] }];
|
|
125
|
+
if (e.platform === "win32") return [{ cmd: "clip", args: [] }];
|
|
126
|
+
if (e.platform !== "linux") return [];
|
|
127
|
+
// The link is ASCII, so clip.exe reading it in the console code page changes nothing.
|
|
128
|
+
const windows = isWsl(e) ? [{ cmd: "clip.exe", args: [], cwd: "/mnt/c" }] : [];
|
|
129
|
+
return [
|
|
130
|
+
...windows,
|
|
131
|
+
...(e.env.WAYLAND_DISPLAY ? [{ cmd: "wl-copy", args: [] }] : []),
|
|
132
|
+
...(e.env.DISPLAY ? [{ cmd: "xclip", args: ["-selection", "clipboard"] }, { cmd: "xsel", args: ["--clipboard", "--input"] }] : []),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Copy `text` with the first command that takes it: true only when one exited 0. */
|
|
137
|
+
export async function copyText(text: string, e: OpenEnv = thisMachine()): Promise<boolean> {
|
|
138
|
+
for (const c of copyCommandsFor(e)) if (await runCopy(c, text, e.env)) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const COPY_WAIT_MS = 5_000;
|
|
143
|
+
|
|
144
|
+
function runCopy(c: CopyCommand, text: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
let child: ReturnType<typeof spawn>;
|
|
147
|
+
try {
|
|
148
|
+
// xclip and xsel stay behind to own the selection; "exit" is the command that answered.
|
|
149
|
+
child = spawn(c.cmd, c.args, { stdio: ["pipe", "ignore", "ignore"], cwd: c.cwd, env });
|
|
150
|
+
} catch {
|
|
151
|
+
resolve(false); // the next command
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
child.kill();
|
|
156
|
+
resolve(false);
|
|
157
|
+
}, COPY_WAIT_MS);
|
|
158
|
+
child.once("error", () => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolve(false);
|
|
161
|
+
});
|
|
162
|
+
child.once("exit", (code) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolve(code === 0);
|
|
165
|
+
});
|
|
166
|
+
child.stdin?.once("error", () => {}); // EPIPE from a command that exited first: its exit code decides
|
|
167
|
+
child.stdin?.end(text);
|
|
168
|
+
});
|
|
169
|
+
}
|