@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.
@@ -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. Press **add device** in a browser that is already signed in and scan the QR, or type the six
47
- digits into the box on that page. The terminal that started the session printed a code too.
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
@@ -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] Thirteen controls do not fit across a narrow conversation. Below 1000 px of conversation,
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, clear, add device, theme and notifications go behind ⋯. The
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, a scheduled turn and a pi with no dialog keep today's gate: new_trigger asks the
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 is the header's clear) and by `/clear`, `/new` and `/resume` typed
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.0",
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",
@@ -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
- inChat: false,
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
+ }