@computesdk/loops 0.1.1 → 0.1.3
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/API.md +35 -5
- package/README.md +67 -21
- package/bin/loops.mjs +13 -4
- package/bin/pi-rpc-startup.mjs +38 -0
- package/bin/pi-rpc.mjs +27 -0
- package/client.ts +16 -1
- package/deploy/loops.service.example +24 -0
- package/dist/assets/{abnfDiagram-VCTEODGH-CceVg1jC.js → abnfDiagram-VCTEODGH-BjWORKhB.js} +1 -1
- package/dist/assets/{arc-CurbUKc7.js → arc-iaXuj2E3.js} +1 -1
- package/dist/assets/{architectureDiagram-5GKGNRK7-BG6bMida.js → architectureDiagram-5GKGNRK7-BGYlRe5d.js} +1 -1
- package/dist/assets/{blockDiagram-I7D4REHJ-B11cpZbJ.js → blockDiagram-I7D4REHJ-DbRX-Eko.js} +1 -1
- package/dist/assets/{c4Diagram-7LVT6UL2-DiXBjlUf.js → c4Diagram-7LVT6UL2-DFNI3QWM.js} +1 -1
- package/dist/assets/channel-mVgxi6SM.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-DMtau_ZO.js → chunk-2Q5K7J3B-DqhjvffG.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-CkDw9Dwr.js → chunk-5VM5RSS4-pskYIVgB.js} +1 -1
- package/dist/assets/{chunk-F27PBJKO-DduXbVaR.js → chunk-F27PBJKO-CQcyYiby.js} +1 -1
- package/dist/assets/{chunk-IMKFNOWR-CA3FEpcX.js → chunk-IMKFNOWR-B0MpoITM.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-_aPFiznH.js → chunk-JWPE2WC7-BsNxH0py.js} +1 -1
- package/dist/assets/{chunk-POPQ4Y6H-BJ1FxtOa.js → chunk-POPQ4Y6H-VnAuAhI3.js} +1 -1
- package/dist/assets/{chunk-SVP7TREG-BISx4Qsv.js → chunk-SVP7TREG-fiQQ7YON.js} +1 -1
- package/dist/assets/{chunk-TICWLB2K-jN0ScesT.js → chunk-TICWLB2K-agM_rWnc.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-Bp2w1xsm.js → chunk-XXDRQBXY-BrnsKK60.js} +1 -1
- package/dist/assets/classDiagram-ZZMXUADV-BNg4EoCj.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BNg4EoCj.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-RJxH4pHE.js → cose-bilkent-JH36ORCC-skVRi8C0.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-RQb2d53E.js → cynefin-OW5HDTMX-sugAAQ5D.js} +1 -1
- package/dist/assets/{cynefinDiagram-5FMLGOSQ-BkWuDtvv.js → cynefinDiagram-5FMLGOSQ-BTjQ5HD4.js} +1 -1
- package/dist/assets/{dagre-GXQ25YYZ-Dw_p1MY7.js → dagre-GXQ25YYZ-wS0CVEvh.js} +1 -1
- package/dist/assets/{diagram-S7CK7UJ4-CNvafPnD.js → diagram-S7CK7UJ4-0P_U489Q.js} +1 -1
- package/dist/assets/{diagram-UQ7AKVKN-Ca50sGck.js → diagram-UQ7AKVKN-D85SHcZz.js} +1 -1
- package/dist/assets/{diagram-VSXAHHWV-B9EkCAhx.js → diagram-VSXAHHWV-CozaqKLI.js} +1 -1
- package/dist/assets/{diagram-VX7I27RA-Be2j3KkX.js → diagram-VX7I27RA-kzpgzRzP.js} +1 -1
- package/dist/assets/{diagram-Z3DM3KII-Iki9IPof.js → diagram-Z3DM3KII-DeIAvld9.js} +1 -1
- package/dist/assets/{ebnfDiagram-PWID7BFC-C_DacKX3.js → ebnfDiagram-PWID7BFC-Cvo7CZD4.js} +1 -1
- package/dist/assets/{erDiagram-RLTQ6QDP-CN090mdT.js → erDiagram-RLTQ6QDP-BILowZs8.js} +1 -1
- package/dist/assets/{flowDiagram-HODETNUW-z9R5Hzp_.js → flowDiagram-HODETNUW-D58_1bOZ.js} +1 -1
- package/dist/assets/{ganttDiagram-EL5Y4UJY-Ctsoo8fA.js → ganttDiagram-EL5Y4UJY-Wc2egLDH.js} +1 -1
- package/dist/assets/{gitGraphDiagram-WWUBYQGX-BAfnTLBI.js → gitGraphDiagram-WWUBYQGX-prvx2Pvu.js} +1 -1
- package/dist/assets/{highlighted-body-JLMTFACH-JGCpsH-e.js → highlighted-body-JLMTFACH-D3B11uXh.js} +1 -1
- package/dist/assets/index-C68b-bL6.css +1 -0
- package/dist/assets/index-CoHgGrA6.js +900 -0
- package/dist/assets/{infoDiagram-27XIBGKW-BMe5_1z8.js → infoDiagram-27XIBGKW-S1_RXMDl.js} +1 -1
- package/dist/assets/{ishikawaDiagram-5VMMS53U-BRSqbbUo.js → ishikawaDiagram-5VMMS53U-DPVVwIcA.js} +1 -1
- package/dist/assets/{journeyDiagram-3NMN7TZE-WkAorMt-.js → journeyDiagram-3NMN7TZE-Dmlf7Ej8.js} +1 -1
- package/dist/assets/{kanban-definition-UXKFOSKX-aa6aiAN2.js → kanban-definition-UXKFOSKX-D7SJAgUZ.js} +1 -1
- package/dist/assets/{layout-BDC_N0BU.js → layout-DWrir7bj.js} +1 -1
- package/dist/assets/{linear-CznrJ42T.js → linear-B7B0s6Fb.js} +1 -1
- package/dist/assets/{mindmap-definition-YA3MSWOX-CW6ybBUC.js → mindmap-definition-YA3MSWOX-CvytaBvR.js} +1 -1
- package/dist/assets/{pegDiagram-XKGWAZYB-Dr3qzrfb.js → pegDiagram-XKGWAZYB-unjus9we.js} +1 -1
- package/dist/assets/{pieDiagram-E7YTZNPT-BkgMYztr.js → pieDiagram-E7YTZNPT-Bc0mRNN1.js} +1 -1
- package/dist/assets/{quadrantDiagram-AXDQQJYC-BTswMqBf.js → quadrantDiagram-AXDQQJYC-DCMBYddi.js} +1 -1
- package/dist/assets/{railroadDiagram-O6MQD6OU-DVCTkg3c.js → railroadDiagram-O6MQD6OU-Bbb5CPfd.js} +1 -1
- package/dist/assets/{requirementDiagram-BXWQKSXE-DDSFlh61.js → requirementDiagram-BXWQKSXE-C8qmEESJ.js} +1 -1
- package/dist/assets/{sankeyDiagram-P5KCCOFB-DHP89LFt.js → sankeyDiagram-P5KCCOFB-ChzXGPH8.js} +1 -1
- package/dist/assets/{sequenceDiagram-WJ2MYXX4-CUcSveH3.js → sequenceDiagram-WJ2MYXX4-BeKUAzAe.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-fGlaZz_7.js → sizeCapture-INFHLROL-YJiYQ0Mn.js} +1 -1
- package/dist/assets/{stateDiagram-D77RDMKH-DqcVPNAG.js → stateDiagram-D77RDMKH-D6NvtAkq.js} +1 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-C9eBMjpA.js +1 -0
- package/dist/assets/{swimlanes-42K2YHIH-L-pgkzCN.js → swimlanes-42K2YHIH-Cjb-j5cm.js} +1 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-tvNn9kix.js +8 -0
- package/dist/assets/{timeline-definition-24CTP7MA-C__pbpoz.js → timeline-definition-24CTP7MA-CaBJ4VuK.js} +1 -1
- package/dist/assets/{vennDiagram-4TSXK5OY-B4f-azPR.js → vennDiagram-4TSXK5OY-D8MLav5x.js} +1 -1
- package/dist/assets/{wardleyDiagram-VM6X3IG4-01ZelHA6.js → wardleyDiagram-VM6X3IG4-CSy7rn-Q.js} +1 -1
- package/dist/assets/{xychartDiagram-S5SC5T6Z-CNfj8huj.js → xychartDiagram-S5SC5T6Z-BtRqCsaE.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/server.mjs +661 -312
- package/package.json +5 -3
- package/src/shared/types.ts +1 -1
- package/dist/assets/channel-Cy2zP8Uh.js +0 -1
- package/dist/assets/classDiagram-ZZMXUADV-BHTKitbG.js +0 -1
- package/dist/assets/classDiagram-v2-VYDZK3BY-BHTKitbG.js +0 -1
- package/dist/assets/index-DkxpcXIf.js +0 -900
- package/dist/assets/index-NzHlk7dF.css +0 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-_t9xbbjh.js +0 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-6eL6UTv7.js +0 -8
package/API.md
CHANGED
|
@@ -5,7 +5,7 @@ everything a UI needs is HTTP, and the rendering is yours.
|
|
|
5
5
|
|
|
6
6
|
- **Base**: `http://<host>:<port>/api/v1` (the same routes also answer on `/api`, which is what this package's own
|
|
7
7
|
client uses).
|
|
8
|
-
- **Auth**: `Authorization: Bearer <token>`. `GET /api/
|
|
8
|
+
- **Auth**: `Authorization: Bearer <token>`. `GET /api/health` is open. A server on loopback
|
|
9
9
|
may have no token at all; a server that listens on anything else always does.
|
|
10
10
|
- **Errors**: a failed request is `{"error": "..."}` with a 4xx/5xx. Success bodies are JSON unless noted.
|
|
11
11
|
- **CORS**: a loopback server answers only its own origins (`http://127.0.0.1:<port>`, `http://localhost:<port>`,
|
|
@@ -14,6 +14,10 @@ everything a UI needs is HTTP, and the rendering is yours.
|
|
|
14
14
|
`--allow-origin a,b` (or `*`) narrows or widens that. The token is not a cookie, so credentials are never sent
|
|
15
15
|
implicitly.
|
|
16
16
|
|
|
17
|
+
For a loopback server behind an authenticating reverse proxy, `--public-origin https://vm.example.com`
|
|
18
|
+
adds that exact Host/Origin to its own names. Multiple origins are comma-separated; forwarded headers are not
|
|
19
|
+
trusted. Access control must be provided by the proxy (or a bearer token).
|
|
20
|
+
|
|
17
21
|
A typed client with no dependencies lives in [`client.ts`](./client.ts):
|
|
18
22
|
|
|
19
23
|
```ts
|
|
@@ -32,7 +36,8 @@ await loops.prompt(chat, "make search() faster; bench is node bench.mjs");
|
|
|
32
36
|
| Route | Returns |
|
|
33
37
|
|---|---|
|
|
34
38
|
| `GET /meta` | name, api version, package version, host, whether a token is required, and the workspaces |
|
|
35
|
-
| `GET /workspaces` | `Workspace[]`: `{ id, path, sessionDir, title }` —
|
|
39
|
+
| `GET /workspaces` | `Workspace[]`: `{ id, path, sessionDir, title, open, available }` — all known workspaces, including closed tabs and unavailable paths |
|
|
40
|
+
| `GET /directories?path=&hidden=1` | `{ path, parent, home, entries: [{ name, path, symlink }], truncated, loopable }` — directories on the server; omitted `path` starts at its home |
|
|
36
41
|
| `GET /chats?ws=` | `ChatInfo[]`: `{ id, title, last, ws, summary }`, newest first, all repos or one |
|
|
37
42
|
| `GET /state?chat=` | `ChatState`: `{ chat, title, items, live, runs, questions, dialogs, workspaces, ws, proposals }` |
|
|
38
43
|
| `GET /chats/:id/runs` | `Run[]` for a canvas, without the transcript |
|
|
@@ -64,11 +69,13 @@ reader (the client above does) so the token can travel in `Authorization`.
|
|
|
64
69
|
|
|
65
70
|
| Route | Body | Effect |
|
|
66
71
|
|---|---|---|
|
|
67
|
-
| `POST /chat` | `{ ws
|
|
72
|
+
| `POST /chat` | `{ ws }` | create a chat in an explicitly selected, open workspace |
|
|
73
|
+
| `POST /chat` | `{ id }` | resume an existing chat in its recorded directory, never rebind it |
|
|
74
|
+
| `POST /chat/retry` | `{ chat }` | explicitly restart an exited agent in the same session; `409` if already running |
|
|
68
75
|
| `POST /rename` | `{ chat, name }` | names the Pi session, so the TUI sees it too |
|
|
69
76
|
| `POST /chat/delete` | `{ chat }` | deletes the conversation; its loops stay in the repo |
|
|
70
|
-
| `POST /workspaces` | `{ path }` |
|
|
71
|
-
| `POST /workspaces/remove` | `{ id }` |
|
|
77
|
+
| `POST /workspaces` | `{ path }` | open/reopen a directory on the server; absolute path or `~/`; canonical paths deduplicate aliases |
|
|
78
|
+
| `POST /workspaces/remove` | `{ id }` | close a workspace tab; keep sessions, bindings, and running agents |
|
|
72
79
|
| `POST /prompt` | `{ chat, text, behavior? }` | send a message; `behavior` is `steer`/`followUp` while running |
|
|
73
80
|
| `POST /abort` | `{ chat }` | stop the active turn |
|
|
74
81
|
| `POST /answer` | `{ chat, question, choice }` | answer a queued question (a "needs you" card) |
|
|
@@ -96,6 +103,29 @@ reader (the client above does) so the token can travel in `Authorization`.
|
|
|
96
103
|
`clean` frees the attempt worktrees (the winner's stays), `more` asks the agent for another round. Responses say
|
|
97
104
|
`{"ok": true, "sent": "<disposition>"}`; `sent: "handled"` means an extension consumed the prompt and no run started.
|
|
98
105
|
|
|
106
|
+
### Workspace and session invariants
|
|
107
|
+
|
|
108
|
+
The server can start without a workspace. Listing/opening directories or closing tabs never creates a Pi child.
|
|
109
|
+
New chats require a workspace ID; omitted IDs are not inferred from the server's launch directory.
|
|
110
|
+
Unknown explicit workspace/chat IDs return `404`; unavailable directories return `409`. Creating a chat in a
|
|
111
|
+
closed workspace requires reopening it first. An existing chat with a conflicting `ws` returns `409`.
|
|
112
|
+
Session-header `cwd` is authoritative; a restart resumes the exact validated Pi file. Multiple browser opens
|
|
113
|
+
share that chat's one writer. Closing a tab does not stop it or make its directory available to another session.
|
|
114
|
+
|
|
115
|
+
An exited agent retains its error state; passive reads and SSE reconnects do not repeatedly spawn replacements.
|
|
116
|
+
Dead extension dialogs are cleared. `POST /chat/retry` is explicit recovery after the startup problem is resolved;
|
|
117
|
+
it does not create a new conversation or automatically answer an extension decision.
|
|
118
|
+
|
|
119
|
+
Startup extension dialogs are answerable before initialization finishes. Ordinary Pi commands wait for setup,
|
|
120
|
+
but `/ui` responses do not. A dead agent, expired decision ID, or invalid response returns `409` rather than
|
|
121
|
+
pretending an answer was delivered. A failed send leaves the decision pending so the client can retry.
|
|
122
|
+
|
|
123
|
+
Directory browsing uses the same authentication as other API routes. It returns directories only (including
|
|
124
|
+
directory symlinks), at most 500 entries after scanning at most 10,000 entries; `truncated` signals a bound.
|
|
125
|
+
`path` must be absolute or `~/`; missing paths return `404`, permissions failures `403`, invalid input `400`.
|
|
126
|
+
Opening a non-Git directory is permitted, but `loopable: false` means loops need Git with at least one commit.
|
|
127
|
+
The API does not initialize repositories or create directories.
|
|
128
|
+
|
|
99
129
|
## The loop lifecycle, for a client
|
|
100
130
|
|
|
101
131
|
1. `prompt` → the agent may call `loop`. In a repo with no config it first raises a **dialog** (`select`: save/change/
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ loops status # what ran here, read straight from the records (n
|
|
|
9
9
|
loops doctor --live # can a round work here? boots serve, runs check, asks the judge to read an image
|
|
10
10
|
loops run "<goal>" # a round — the one verb that costs tokens
|
|
11
11
|
loops clean | apply | take | rule | questions | answer
|
|
12
|
-
loops serve #
|
|
12
|
+
loops serve # one browser app for this VM; choose workspaces in the UI
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Installed from npm it is one command, and that is the one to hand someone:
|
|
@@ -31,30 +31,48 @@ the session is the durable record, and `.pi/loops/` is a rebuildable index plus
|
|
|
31
31
|
| the brief / approve card | the extension's `ctx.ui.select`, as an `extension_ui_request` |
|
|
32
32
|
| diffs, screenshots, sheets | read-only routes over the run's worktrees and archived shots under `.pi/loops/` |
|
|
33
33
|
|
|
34
|
-
A chat's
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
A chat's directory comes from Pi itself: each session header records its `cwd`.
|
|
35
|
+
The server resumes that exact session file in that directory; selecting a workspace cannot move a chat.
|
|
36
|
+
Pi remains the only session writer, with one RPC child per opened chat, shared by its browser subscribers.
|
|
37
|
+
There is no second transcript store and no workspace-wide editing lock.
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
`workspaces.json` indexes sessions and persists workspace IDs, session locations, and which tabs are open.
|
|
40
|
+
Closing a tab retains its workspace, chat bindings, and sessions. Recovery can rebuild bindings from session headers
|
|
41
|
+
if the registry is lost, but an intact registry's closed tabs stay closed across restarts.
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
## One server per VM
|
|
44
|
+
|
|
45
|
+
The server defaults to `127.0.0.1`. Remote listening requires a bearer token (generated automatically when needed);
|
|
46
|
+
this process can prompt an agent with `bash` access, so protect the token as a password to the VM.
|
|
47
|
+
An SSH tunnel is another option (`ssh -L 8001:127.0.0.1:8001 …`).
|
|
48
|
+
|
|
49
|
+
Behind an **authenticating reverse proxy**, keep the listener on loopback and explicitly name its public origin:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
loops serve --public-origin https://my-vm.exe.xyz,https://my-vm.exe.xyz:8001 --no-open
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This trusts exactly those Host/Origin names, not arbitrary `X-Forwarded-*` headers.
|
|
56
|
+
The proxy must restrict who can access the app; this flag does not add authentication.
|
|
57
|
+
Do not use it to expose an unauthenticated agent. See [`deploy/loops.service.example`](./deploy/loops.service.example)
|
|
58
|
+
for a supervised checkout deployment. Rebuild **and restart the server** after an update; rebuilding the SPA alone
|
|
59
|
+
can leave a new client talking to an old backend.
|
|
43
60
|
|
|
44
61
|
## Run it
|
|
45
62
|
|
|
46
|
-
Start it once for the machine, then
|
|
63
|
+
Start it once for the machine, then open directories from the workspace launcher:
|
|
47
64
|
|
|
48
65
|
```sh
|
|
49
|
-
npx @computesdk/loops serve # from npm —
|
|
66
|
+
npx @computesdk/loops serve # from npm — one server, independent of launch directory
|
|
50
67
|
# from a checkout:
|
|
51
68
|
loops serve # or: node server/index.ts --port 8001
|
|
52
|
-
#
|
|
69
|
+
# optionally seed a workspace:
|
|
70
|
+
loops serve --cwd ~/code/some-repo
|
|
53
71
|
```
|
|
54
72
|
|
|
55
73
|
It ships as one bundled server plus the built SPA, so there is no build step on the other end. The `pi-loops` extension
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
and Pi runtime are installed as runtime dependencies. Chats use the bundled Pi RPC runner by default;
|
|
75
|
+
`--pi <executable>` explicitly selects another Pi installation.
|
|
58
76
|
|
|
59
77
|
It prints the address and opens it:
|
|
60
78
|
|
|
@@ -68,25 +86,50 @@ loopback — the opened address is the attach form, `/?server=…&token=…`, wh
|
|
|
68
86
|
address bar.
|
|
69
87
|
|
|
70
88
|
A loop needs the engine, so the boot says when it is missing rather than leaving you to find out on the first prompt:
|
|
71
|
-
if
|
|
89
|
+
if the selected Pi executable or an `--extensions` path does not exist, it prints what is wrong and the fix under `!! a loop
|
|
72
90
|
cannot run here yet`, and `/api/health` reports `ok: false` with the same list. The app still serves — you can add
|
|
73
91
|
repos and read what already ran.
|
|
74
92
|
|
|
75
|
-
A **
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
A **workspace is a directory on the connected VM**, with its chats, runs, and `.pi/loops.json`.
|
|
94
|
+
`+` opens the launcher: choose a recent workspace, browse the VM's directories, or paste an absolute path (`~/` works).
|
|
95
|
+
Home, Up, breadcrumbs, and Show hidden navigate the server's filesystem, not the browser's computer.
|
|
96
|
+
The picker shows whether Git has a commit; opening a directory never initializes Git or edits the project.
|
|
97
|
+
|
|
98
|
+
**Opening a directory never creates a chat.** A new workspace starts with its chat list and no Pi child.
|
|
99
|
+
Choose an existing chat or explicitly create one with **New chat** in that workspace. The active full path stays visible.
|
|
100
|
+
Returning to a workspace restores its last selected chat (resuming that writer if necessary),
|
|
101
|
+
remembered separately for each connected server. `×` only closes a tab: it does not stop its agent,
|
|
102
|
+
delete history, or change the session's directory. Reopen it from Recent workspaces.
|
|
103
|
+
|
|
104
|
+
The first launch has no implicit project; `--cwd` optionally seeds one. All tabs can be closed.
|
|
105
|
+
Canonical paths deduplicate symlink aliases, same-named directories retain distinct stable IDs, and missing
|
|
106
|
+
directories remain visible as unavailable instead of silently falling back to another workspace.
|
|
107
|
+
|
|
108
|
+
If Pi exits, the chat keeps its diagnostic instead of restarting the child on every read.
|
|
109
|
+
**Retry agent** explicitly restarts that same session after the problem is resolved.
|
|
110
|
+
Startup extension decisions (including exe.dev's first-run LLM setup) can be answered directly in the browser.
|
|
111
|
+
The default runner uses Pi 1.4.0's own CLI with a narrow, in-memory compatibility fix: attach the RPC input reader
|
|
112
|
+
before awaiting startup extensions, handle their answers immediately, and hold ordinary commands until setup finishes.
|
|
113
|
+
It verifies the pinned version and exact upstream source before applying the fix; no global Pi files are modified.
|
|
114
|
+
Pi still owns the session, extensions, model configuration, and all decisions.
|
|
115
|
+
Failed answer submissions remain visible with a retryable error; Loops never accepts or declines an integration for you.
|
|
116
|
+
An explicit `--pi` bypasses this compatibility runner, so that executable must support startup dialogs itself.
|
|
117
|
+
|
|
118
|
+
Server metadata is in `~/.pi/loops-ui`; browser navigation state is kept in local storage.
|
|
119
|
+
`loops serve` is a foreground server command, not an attach command: start it once (or supervise it as a service),
|
|
120
|
+
then use that URL for every workspace. Running it again on the same port does not replace or attach to the first process.
|
|
79
121
|
|
|
80
122
|
Flags (each has a `LOOPS_UI_*` environment variable):
|
|
81
123
|
|
|
82
124
|
| flag | default | meaning |
|
|
83
125
|
|---|---|---|
|
|
84
126
|
| `--port` | `8001` | where the app listens |
|
|
85
|
-
| `--cwd` |
|
|
86
|
-
| `--pi` |
|
|
127
|
+
| `--cwd` | unset | optionally seed a workspace; launch directory is otherwise irrelevant |
|
|
128
|
+
| `--pi` | bundled Pi RPC runner | explicitly override the Pi executable each chat spawns |
|
|
87
129
|
| `--extensions` | `../pi-loops/extensions/loop.ts` | comma-separated `-e` paths for the child |
|
|
88
130
|
| `--data` | `~/.pi/loops-ui` | workspaces.json and the chats of every repo |
|
|
89
|
-
| `--host` | `127.0.0.1` | loopback
|
|
131
|
+
| `--host` | `127.0.0.1` | off-loopback listening requires a bearer token |
|
|
132
|
+
| `--public-origin` | unset | comma-separated exact origins of a trusted authenticating reverse proxy |
|
|
90
133
|
| `--model` | Pi's default | the model every chat starts on |
|
|
91
134
|
| `--no-open` | off | print the URL without opening a browser (`LOOPS_UI_OPEN=0`) |
|
|
92
135
|
| `--dev` | off | proxy the SPA to Vite on `:5173` (`npm run dev`) |
|
|
@@ -138,6 +181,7 @@ server/
|
|
|
138
181
|
pi-rpc.ts the RPC client: JSONL framing, id correlation, extension dialogs
|
|
139
182
|
runs.ts the durable session entries first, the `.pi/loops` index as fallback -> the shapes the canvas draws
|
|
140
183
|
workspaces.ts the repos this process may touch; chats are bound to one
|
|
184
|
+
directories.ts bounded, authenticated directory browsing on the server
|
|
141
185
|
config.ts settings with provenance, agent-write proposals, and the doctor checks
|
|
142
186
|
live.ts one live server for the winner, on demand
|
|
143
187
|
src/
|
|
@@ -223,5 +267,7 @@ because that is a decision the agent owns.
|
|
|
223
267
|
```sh
|
|
224
268
|
npm run dev # Hono on :8001 proxying the SPA to Vite on :5173
|
|
225
269
|
npm run check # typecheck the server and the client separately
|
|
270
|
+
npm test # offline engine/API tests, including workspace restart and routing regressions
|
|
271
|
+
npm run test:browser # build first; real Chromium workspace navigation, with a fake Pi (no model calls)
|
|
226
272
|
npm run shots # headless-chrome screenshots of every state
|
|
227
273
|
```
|
package/bin/loops.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const opt = (name, fallback = undefined) => {
|
|
|
23
23
|
/** Arguments that are not flags or flag values. */
|
|
24
24
|
function positional() {
|
|
25
25
|
const out = [];
|
|
26
|
-
const takesValue = new Set(["cwd", "port", "extension", "pi", "run", "attempt", "option", "goal"]);
|
|
26
|
+
const takesValue = new Set(["cwd", "port", "extension", "pi", "run", "attempt", "option", "goal", "host", "data", "token", "allow-origin", "public-origin"]);
|
|
27
27
|
for (let i = 0; i < argv.length; i++) {
|
|
28
28
|
if (argv[i].startsWith("--")) { if (takesValue.has(argv[i].slice(2))) i++; continue; }
|
|
29
29
|
out.push(argv[i]);
|
|
@@ -79,8 +79,8 @@ const usage = `loops — hill-climbing loops for a repo, without the TUI
|
|
|
79
79
|
loops rule <run|last> "<rule>" add a judging rule
|
|
80
80
|
loops apply [run] land the winner in your checkout
|
|
81
81
|
loops clean [run|all] remove attempt worktrees (commits and the winner stay)
|
|
82
|
-
loops serve [--port 8001] [--no-open]
|
|
83
|
-
|
|
82
|
+
loops serve [--port 8001] [--no-open] [--cwd <repo>]
|
|
83
|
+
one browser app for this machine (localhost only); optionally open a workspace
|
|
84
84
|
loops ui same thing, by the older name
|
|
85
85
|
|
|
86
86
|
--cwd <repo> the checkout to work on (default: this directory)
|
|
@@ -182,7 +182,16 @@ async function main() {
|
|
|
182
182
|
const entry = fs.existsSync(bundle)
|
|
183
183
|
? [bundle]
|
|
184
184
|
: ["--experimental-strip-types", path.join(PKG, "server", "index.ts")];
|
|
185
|
-
const args = [...entry, "--
|
|
185
|
+
const args = [...entry, "--port", String(opt("port", process.env.LOOPS_UI_PORT ?? "8001"))];
|
|
186
|
+
// The UI uses its startup-dialog-compatible Pi runner unless an executable was explicitly selected.
|
|
187
|
+
const uiPi = opt("pi", process.env.LOOPS_UI_PI ?? process.env.LOOPS_PI);
|
|
188
|
+
if (uiPi) args.push("--pi", uiPi);
|
|
189
|
+
const seed = opt("cwd", process.env.LOOPS_UI_CWD);
|
|
190
|
+
if (seed) args.push("--cwd", path.resolve(seed));
|
|
191
|
+
for (const name of ["host", "data", "token", "allow-origin", "public-origin"]) {
|
|
192
|
+
const value = opt(name);
|
|
193
|
+
if (value) args.push(`--${name}`, value);
|
|
194
|
+
}
|
|
186
195
|
if (flag("no-open")) args.push("--no-open");
|
|
187
196
|
// The extension is a runtime dependency, so it resolves on an install as well as in this workspace; without it the
|
|
188
197
|
// server's own boot check says so instead of the first prompt failing.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Compatibility patch for the unbundled Pi 1.4.0 RPC implementation only.
|
|
2
|
+
// Verify the entire source, not just the anchors: changed upstream code must be reviewed.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const expected = '631697cd35928fc827f4a423538c43ff227b8cbf63c2a2f11060616f55eba6db';
|
|
6
|
+
export function patchRpcStartup(source) {
|
|
7
|
+
if (createHash('sha256').update(source).digest('hex') !== expected) {
|
|
8
|
+
throw new Error('Unsupported Pi RPC source; startup compatibility patch requires Pi 1.4.0');
|
|
9
|
+
}
|
|
10
|
+
function replaceOnce(before, after) {
|
|
11
|
+
if (source.split(before).length !== 2) throw new Error('Pi RPC startup patch anchor mismatch');
|
|
12
|
+
source = source.replace(before, after);
|
|
13
|
+
}
|
|
14
|
+
replaceOnce(' await rebindSession();\n registerSignalHandlers();\n // Handle a single command',
|
|
15
|
+
' let initialization;\n // Handle a single command');
|
|
16
|
+
// A timed-out or aborted UI request is no longer actionable by the client.
|
|
17
|
+
// Normal response cleanup deliberately does not send this notification.
|
|
18
|
+
replaceOnce(' const onAbort = () => {\n cleanup();\n resolve(defaultValue);\n };',
|
|
19
|
+
' const onAbort = () => {\n cleanup();\n if (!shuttingDown) output({ type: "extension_ui_request_cancelled", id });\n resolve(defaultValue);\n };');
|
|
20
|
+
replaceOnce(' timeoutId = setTimeout(() => {\n cleanup();\n resolve(defaultValue);\n }, opts.timeout);',
|
|
21
|
+
' timeoutId = setTimeout(() => {\n cleanup();\n if (!shuttingDown) output({ type: "extension_ui_request_cancelled", id });\n resolve(defaultValue);\n }, opts.timeout);');
|
|
22
|
+
replaceOnce(' const command = parsed;\n try {\n const response = await handleCommand(command);',
|
|
23
|
+
' const command = parsed;\n try {\n await initialization;\n if (shuttingDown) return;\n const response = await handleCommand(command);');
|
|
24
|
+
replaceOnce(' // Keep process alive forever\n return new Promise(() => { });',
|
|
25
|
+
` registerSignalHandlers();
|
|
26
|
+
// Start binding only after the input reader is attached. UI replies bypass
|
|
27
|
+
// this promise, but every ordinary command must wait until binding finishes.
|
|
28
|
+
initialization = rebindSession();
|
|
29
|
+
try {
|
|
30
|
+
await initialization;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error("Pi RPC initialization failed:", err);
|
|
33
|
+
await shutdown(1);
|
|
34
|
+
}
|
|
35
|
+
// Keep process alive forever
|
|
36
|
+
return new Promise(() => { });`);
|
|
37
|
+
return source;
|
|
38
|
+
}
|
package/bin/pi-rpc.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run Pi's own CLI with a narrowly scoped, in-memory startup compatibility fix.
|
|
3
|
+
import { registerHooks } from 'node:module';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { patchRpcStartup } from './pi-rpc-startup.mjs';
|
|
8
|
+
|
|
9
|
+
const main = fileURLToPath(import.meta.resolve('@earendil-works/pi-coding-agent'));
|
|
10
|
+
const root = dirname(dirname(main));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
12
|
+
if (pkg.name !== '@earendil-works/pi-coding-agent' || pkg.version !== '1.4.0') {
|
|
13
|
+
throw new Error('Pi RPC runner requires @earendil-works/pi-coding-agent 1.4.0');
|
|
14
|
+
}
|
|
15
|
+
const target = realpathSync(join(root, 'dist/modes/rpc/rpc-mode.js'));
|
|
16
|
+
// Validate before the CLI starts, including when a future CLI stops loading RPC.
|
|
17
|
+
patchRpcStartup(readFileSync(target, 'utf8'));
|
|
18
|
+
registerHooks({
|
|
19
|
+
load(url, context, nextLoad) {
|
|
20
|
+
const result = nextLoad(url, context);
|
|
21
|
+
if (url.startsWith('file:') && fileURLToPath(url) === target) {
|
|
22
|
+
return { ...result, source: patchRpcStartup(String(result.source)) };
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
await import(pathToFileURL(join(root, 'dist/cli.js')).href);
|
package/client.ts
CHANGED
|
@@ -71,11 +71,20 @@ export class LoopsClient {
|
|
|
71
71
|
|
|
72
72
|
workspaces() { return this.call<Workspace[]>("/workspaces"); }
|
|
73
73
|
|
|
74
|
+
/** Browse directories on the connected server, not on the browser's machine. */
|
|
75
|
+
directories(path?: string, hidden = false) {
|
|
76
|
+
const query = new URLSearchParams();
|
|
77
|
+
if (path !== undefined) query.set("path", path);
|
|
78
|
+
if (hidden) query.set("hidden", "1");
|
|
79
|
+
return this.call<{ path: string; parent: string | null; home: string; entries: { name: string; path: string; symlink: boolean }[]; truncated: boolean; loopable: boolean }>(`/directories?${query}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
/** Add a repo by path on the server's machine. This is a privileged action: the path is resolved server-side. */
|
|
75
83
|
addWorkspace(path: string) {
|
|
76
84
|
return this.call<{ ok: boolean; workspace: Workspace; loopable: boolean }>("/workspaces", { method: "POST", json: { path } });
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/** Close a tab, retaining the workspace, its sessions, and running agents. */
|
|
79
88
|
removeWorkspace(id: string) {
|
|
80
89
|
return this.call<{ ok: boolean }>("/workspaces/remove", { method: "POST", json: { id } });
|
|
81
90
|
}
|
|
@@ -83,7 +92,13 @@ export class LoopsClient {
|
|
|
83
92
|
/** Chats in one repo, or across all of them. */
|
|
84
93
|
chats(ws?: string) { return this.call<ChatInfo[]>(`/chats${ws ? `?ws=${encodeURIComponent(ws)}` : ""}`); }
|
|
85
94
|
|
|
86
|
-
createChat(ws
|
|
95
|
+
createChat(ws: string) { return this.call<{ chat: string; ws: string }>("/chat", { method: "POST", json: { ws } }); }
|
|
96
|
+
|
|
97
|
+
/** Resume a chat in its recorded directory. This never changes its workspace. */
|
|
98
|
+
openChat(id: string) { return this.call<{ chat: string; ws: string }>("/chat", { method: "POST", json: { id } }); }
|
|
99
|
+
|
|
100
|
+
/** Explicitly restart an exited agent, retaining its chat/session identity. A running agent returns 409. */
|
|
101
|
+
retryChat(chat: string) { return this.call<{ ok: boolean }>("/chat/retry", { method: "POST", json: { chat } }); }
|
|
87
102
|
|
|
88
103
|
renameChat(chat: string, name: string) { return this.call<{ ok: boolean; title: string }>("/rename", { method: "POST", json: { chat, name } }); }
|
|
89
104
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy to /etc/systemd/system/loops.service after replacing the user, checkout, and VM names.
|
|
2
|
+
# Build from the checkout with `pnpm --filter @computesdk/loops build` before starting/restarting.
|
|
3
|
+
# The HTTPS proxy MUST authenticate access; public-origin is a trusted name, not authentication.
|
|
4
|
+
[Unit]
|
|
5
|
+
Description=Loops VM workspace server
|
|
6
|
+
After=network-online.target
|
|
7
|
+
Wants=network-online.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
User=exedev
|
|
12
|
+
WorkingDirectory=/home/exedev/github/computesdk/loops/packages/pi-loops-ui
|
|
13
|
+
Environment=HOME=/home/exedev
|
|
14
|
+
Environment=PATH=/home/exedev/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
15
|
+
Environment=LOOPS_UI_HOST=127.0.0.1
|
|
16
|
+
Environment=LOOPS_UI_PUBLIC_ORIGIN=https://YOUR_VM.exe.xyz,https://YOUR_VM.exe.xyz:8001
|
|
17
|
+
ExecStart=/home/exedev/.local/bin/node /home/exedev/github/computesdk/loops/packages/pi-loops-ui/dist/server.mjs --port 8001 --no-open
|
|
18
|
+
Restart=on-failure
|
|
19
|
+
RestartSec=3
|
|
20
|
+
TimeoutStopSec=15
|
|
21
|
+
KillMode=control-group
|
|
22
|
+
|
|
23
|
+
[Install]
|
|
24
|
+
WantedBy=multi-user.target
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{g as p,r as u,d as a}from"./chunk-SVP7TREG-
|
|
1
|
+
import{g as p,r as u,d as a}from"./chunk-SVP7TREG-fiQQ7YON.js";import{p as f}from"./chunk-JWPE2WC7-BsNxH0py.js";import{_ as n,l as o}from"./index-CoHgGrA6.js";import{M as c,b as d}from"./cynefin-OW5HDTMX-sugAAQ5D.js";var v=d().RailroadAbnf.parser.LangiumParser,l=n(e=>{const r=e.alternatives.map(g);return r.length===1?r[0]:{type:"choice",alternatives:r}},"transformAlternation"),g=n(e=>{const r=e.elements.map(y);return r.length===1?r[0]:{type:"sequence",elements:r}},"transformConcatenation"),b=n(e=>{if(e.includes("*")){const[t,s]=e.split("*"),i=t?parseInt(t,10):0,m=s?parseInt(s,10):1/0;return{min:i,max:m}}const r=parseInt(e,10);return{min:r,max:r}},"parseRepeat"),y=n(e=>{const r=A(e.primary);if(!e.repeat)return r;const{min:t,max:s}=b(e.repeat);return t===0&&s===1?{type:"optional",element:r}:{type:"repetition",element:r,min:t,max:s}},"transformElement"),A=n(e=>{switch(e.$type){case"AbnfStringLiteral":return{type:"terminal",value:e.value};case"AbnfNumVal":return{type:"terminal",value:e.value};case"AbnfRuleName":return{type:"nonterminal",name:e.name};case"AbnfGroup":return l(e.element);case"AbnfOptionalGroup":return{type:"optional",element:l(e.element)};default:throw new Error(`Unsupported ABNF primary node: ${e.$type}`)}},"transformPrimary"),P=n(e=>({name:e.name,definition:l(e.definition)}),"transformRule"),h=n(e=>{f(e,a),e.title&&a.setTitle(e.title),e.rules.map(r=>a.addRule(P(r)))},"populateDb"),R={parse:n(e=>{a.clear(),o.debug("[ABNF Parser] Starting Langium parse");const r=v.parse(e);if(r.lexerErrors.length>0||r.parserErrors.length>0)throw new c(r);const t=r.value;o.debug("[ABNF Parser] Parsed rules:",t.rules.length),h(t),o.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:a}},w={parser:R,db:a,renderer:u,styles:p};export{w as diagram};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{K as ln,L as an,M as y,N as tn,O as X,P as O,Q as _,S as un,T as rn,V as $,X as o,Y as M,Z as sn,$ as on,a0 as fn}from"./index-
|
|
1
|
+
import{K as ln,L as an,M as y,N as tn,O as X,P as O,Q as _,S as un,T as rn,V as $,X as o,Y as M,Z as sn,$ as on,a0 as fn}from"./index-CoHgGrA6.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,D,S,v,A,N,a){var E=D-l,i=S-h,n=N-v,d=a-A,u=d*E-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*E,h+u*i]}function G(l,h,D,S,v,A,N){var a=l-D,E=h-S,i=(N?A:-A)/$(a*a+E*E),n=i*E,d=-i*a,u=l+n,s=h+d,f=D+n,c=S+d,Q=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,I=(g<0?-1:1)*$(fn(0,T*T*R-P*P)),K=(P*g-m*I)/R,L=(-P*m-g*I)/R,w=(P*g+m*I)/R,p=(-P*m+g*I)/R,x=K-Q,e=L-t,r=w-Q,V=p-t;return x*x+e*e>r*r+V*V&&(K=w,L=p),{cx:K,cy:L,x01:-n,y01:-d,x11:K*(v/T-1),y11:L*(v/T-1)}}function hn(){var l=cn,h=yn,D=M(0),S=null,v=gn,A=dn,N=mn,a=null,E=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-an,c=A.apply(this,arguments)-an,Q=un(c-f),t=c>f;if(a||(a=n=E()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(Q>tn-y)a.moveTo(s*X(f),s*O(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*X(c),u*O(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=Q,I=Q,K=N.apply(this,arguments)/2,L=K>y&&(S?+S.apply(this,arguments):$(u*u+s*s)),w=_(un(s-u)/2,+D.apply(this,arguments)),p=w,x=w,e,r;if(L>y){var V=sn(L/u*O(K)),j=sn(L/s*O(K));(P-=V*2)>y?(V*=t?1:-1,R+=V,T-=V):(P=0,R=T=(f+c)/2),(I-=j*2)>y?(j*=t?1:-1,m+=j,g-=j):(I=0,m=g=(f+c)/2)}var Y=s*X(m),Z=s*O(m),z=u*X(T),B=u*O(T);if(w>y){var C=s*X(g),F=s*O(g),H=u*X(R),J=u*O(R),q;if(Q<rn)if(q=pn(Y,Z,H,J,C,F,z,B)){var U=Y-q[0],W=Z-q[1],k=C-q[0],b=F-q[1],nn=1/O(on((U*k+W*b)/($(U*U+W*W)*$(k*k+b*b)))/2),en=$(q[0]*q[0]+q[1]*q[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}I>y?x>y?(e=G(H,J,Y,Z,s,x,t),r=G(C,F,z,B,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(Y,Z),a.arc(0,0,s,m,g,!t)):a.moveTo(Y,Z),!(u>y)||!(P>y)?a.lineTo(z,B):p>y?(e=G(z,B,C,F,u,-p,t),r=G(Y,Z,H,J,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-rn/2;return[X(d)*n,O(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:M(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:M(+n),i):h},i.cornerRadius=function(n){return arguments.length?(D=typeof n=="function"?n:M(+n),i):D},i.padRadius=function(n){return arguments.length?(S=n==null?null:typeof n=="function"?n:M(+n),i):S},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:M(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:M(+n),i):A},i.padAngle=function(n){return arguments.length?(N=typeof n=="function"?n:M(+n),i):N},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};
|