@computesdk/loops 0.1.2 → 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.
Files changed (75) hide show
  1. package/API.md +35 -5
  2. package/README.md +67 -21
  3. package/bin/loops.mjs +13 -4
  4. package/bin/pi-rpc-startup.mjs +38 -0
  5. package/bin/pi-rpc.mjs +27 -0
  6. package/client.ts +16 -1
  7. package/deploy/loops.service.example +24 -0
  8. package/dist/assets/{abnfDiagram-VCTEODGH-CqEf5Tyi.js → abnfDiagram-VCTEODGH-BjWORKhB.js} +1 -1
  9. package/dist/assets/{arc-CrYrleHr.js → arc-iaXuj2E3.js} +1 -1
  10. package/dist/assets/{architectureDiagram-5GKGNRK7-D5K4RlB6.js → architectureDiagram-5GKGNRK7-BGYlRe5d.js} +1 -1
  11. package/dist/assets/{blockDiagram-I7D4REHJ-Co-w8wX9.js → blockDiagram-I7D4REHJ-DbRX-Eko.js} +1 -1
  12. package/dist/assets/{c4Diagram-7LVT6UL2-DlcBV2hX.js → c4Diagram-7LVT6UL2-DFNI3QWM.js} +1 -1
  13. package/dist/assets/channel-mVgxi6SM.js +1 -0
  14. package/dist/assets/{chunk-2Q5K7J3B-Dswi4LXs.js → chunk-2Q5K7J3B-DqhjvffG.js} +1 -1
  15. package/dist/assets/{chunk-5VM5RSS4-CtLKoCf3.js → chunk-5VM5RSS4-pskYIVgB.js} +1 -1
  16. package/dist/assets/{chunk-F27PBJKO-Bbck4p8J.js → chunk-F27PBJKO-CQcyYiby.js} +1 -1
  17. package/dist/assets/{chunk-IMKFNOWR-CvTEJd26.js → chunk-IMKFNOWR-B0MpoITM.js} +1 -1
  18. package/dist/assets/{chunk-JWPE2WC7-AIHpJEyX.js → chunk-JWPE2WC7-BsNxH0py.js} +1 -1
  19. package/dist/assets/{chunk-POPQ4Y6H-C_B3I7hU.js → chunk-POPQ4Y6H-VnAuAhI3.js} +1 -1
  20. package/dist/assets/{chunk-SVP7TREG-au1NQO2y.js → chunk-SVP7TREG-fiQQ7YON.js} +1 -1
  21. package/dist/assets/{chunk-TICWLB2K-DOQXr0QZ.js → chunk-TICWLB2K-agM_rWnc.js} +1 -1
  22. package/dist/assets/{chunk-XXDRQBXY-U36QglrB.js → chunk-XXDRQBXY-BrnsKK60.js} +1 -1
  23. package/dist/assets/classDiagram-ZZMXUADV-BNg4EoCj.js +1 -0
  24. package/dist/assets/classDiagram-v2-VYDZK3BY-BNg4EoCj.js +1 -0
  25. package/dist/assets/{cose-bilkent-JH36ORCC-D7GIzP1z.js → cose-bilkent-JH36ORCC-skVRi8C0.js} +1 -1
  26. package/dist/assets/{cynefin-OW5HDTMX-CjokfIiy.js → cynefin-OW5HDTMX-sugAAQ5D.js} +1 -1
  27. package/dist/assets/{cynefinDiagram-5FMLGOSQ-1aEkMuPh.js → cynefinDiagram-5FMLGOSQ-BTjQ5HD4.js} +1 -1
  28. package/dist/assets/{dagre-GXQ25YYZ-BiIp4Lpg.js → dagre-GXQ25YYZ-wS0CVEvh.js} +1 -1
  29. package/dist/assets/{diagram-S7CK7UJ4-BZSI7zSx.js → diagram-S7CK7UJ4-0P_U489Q.js} +1 -1
  30. package/dist/assets/{diagram-UQ7AKVKN-BRdBU2w8.js → diagram-UQ7AKVKN-D85SHcZz.js} +1 -1
  31. package/dist/assets/{diagram-VSXAHHWV-DQ-h_ycl.js → diagram-VSXAHHWV-CozaqKLI.js} +1 -1
  32. package/dist/assets/{diagram-VX7I27RA-DmLQWSdL.js → diagram-VX7I27RA-kzpgzRzP.js} +1 -1
  33. package/dist/assets/{diagram-Z3DM3KII-m8FGF4_n.js → diagram-Z3DM3KII-DeIAvld9.js} +1 -1
  34. package/dist/assets/{ebnfDiagram-PWID7BFC-DtQ-Rw5F.js → ebnfDiagram-PWID7BFC-Cvo7CZD4.js} +1 -1
  35. package/dist/assets/{erDiagram-RLTQ6QDP-DIf-60L3.js → erDiagram-RLTQ6QDP-BILowZs8.js} +1 -1
  36. package/dist/assets/{flowDiagram-HODETNUW-COvzkFo5.js → flowDiagram-HODETNUW-D58_1bOZ.js} +1 -1
  37. package/dist/assets/{ganttDiagram-EL5Y4UJY-Dhz_v-Sb.js → ganttDiagram-EL5Y4UJY-Wc2egLDH.js} +1 -1
  38. package/dist/assets/{gitGraphDiagram-WWUBYQGX-BwkibubM.js → gitGraphDiagram-WWUBYQGX-prvx2Pvu.js} +1 -1
  39. package/dist/assets/{highlighted-body-JLMTFACH-CbkekRih.js → highlighted-body-JLMTFACH-D3B11uXh.js} +1 -1
  40. package/dist/assets/index-C68b-bL6.css +1 -0
  41. package/dist/assets/index-CoHgGrA6.js +900 -0
  42. package/dist/assets/{infoDiagram-27XIBGKW-BltzH8hk.js → infoDiagram-27XIBGKW-S1_RXMDl.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-5VMMS53U-CKXLbULh.js → ishikawaDiagram-5VMMS53U-DPVVwIcA.js} +1 -1
  44. package/dist/assets/{journeyDiagram-3NMN7TZE-Co-OVAQM.js → journeyDiagram-3NMN7TZE-Dmlf7Ej8.js} +1 -1
  45. package/dist/assets/{kanban-definition-UXKFOSKX-QEXNvAHJ.js → kanban-definition-UXKFOSKX-D7SJAgUZ.js} +1 -1
  46. package/dist/assets/{layout-DlW7eXy-.js → layout-DWrir7bj.js} +1 -1
  47. package/dist/assets/{linear-DmNmIGNl.js → linear-B7B0s6Fb.js} +1 -1
  48. package/dist/assets/{mindmap-definition-YA3MSWOX-CfrGbvrI.js → mindmap-definition-YA3MSWOX-CvytaBvR.js} +1 -1
  49. package/dist/assets/{pegDiagram-XKGWAZYB-BRPtQX-b.js → pegDiagram-XKGWAZYB-unjus9we.js} +1 -1
  50. package/dist/assets/{pieDiagram-E7YTZNPT-cyRAi108.js → pieDiagram-E7YTZNPT-Bc0mRNN1.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-AXDQQJYC-BBwMoijR.js → quadrantDiagram-AXDQQJYC-DCMBYddi.js} +1 -1
  52. package/dist/assets/{railroadDiagram-O6MQD6OU-DicocbzE.js → railroadDiagram-O6MQD6OU-Bbb5CPfd.js} +1 -1
  53. package/dist/assets/{requirementDiagram-BXWQKSXE-DDiXCN4F.js → requirementDiagram-BXWQKSXE-C8qmEESJ.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-P5KCCOFB-DnDGP5-W.js → sankeyDiagram-P5KCCOFB-ChzXGPH8.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-WJ2MYXX4-C1NFZjf0.js → sequenceDiagram-WJ2MYXX4-BeKUAzAe.js} +1 -1
  56. package/dist/assets/{sizeCapture-INFHLROL-CEF6O58z.js → sizeCapture-INFHLROL-YJiYQ0Mn.js} +1 -1
  57. package/dist/assets/{stateDiagram-D77RDMKH-Ibszr2iY.js → stateDiagram-D77RDMKH-D6NvtAkq.js} +1 -1
  58. package/dist/assets/stateDiagram-v2-MP3YSRHH-C9eBMjpA.js +1 -0
  59. package/dist/assets/{swimlanes-42K2YHIH-DbvtObGJ.js → swimlanes-42K2YHIH-Cjb-j5cm.js} +1 -1
  60. package/dist/assets/swimlanesDiagram-VR7AAH4N-tvNn9kix.js +8 -0
  61. package/dist/assets/{timeline-definition-24CTP7MA-C6qVzSzu.js → timeline-definition-24CTP7MA-CaBJ4VuK.js} +1 -1
  62. package/dist/assets/{vennDiagram-4TSXK5OY-B3ZZFoCj.js → vennDiagram-4TSXK5OY-D8MLav5x.js} +1 -1
  63. package/dist/assets/{wardleyDiagram-VM6X3IG4-C9pFlzx9.js → wardleyDiagram-VM6X3IG4-CSy7rn-Q.js} +1 -1
  64. package/dist/assets/{xychartDiagram-S5SC5T6Z-mUkoBUkH.js → xychartDiagram-S5SC5T6Z-BtRqCsaE.js} +1 -1
  65. package/dist/index.html +2 -2
  66. package/dist/server.mjs +646 -311
  67. package/package.json +5 -3
  68. package/src/shared/types.ts +1 -1
  69. package/dist/assets/channel-DoVi6ksL.js +0 -1
  70. package/dist/assets/classDiagram-ZZMXUADV-Cyi65D9L.js +0 -1
  71. package/dist/assets/classDiagram-v2-VYDZK3BY-Cyi65D9L.js +0 -1
  72. package/dist/assets/index-DhZYU086.js +0 -900
  73. package/dist/assets/index-NzHlk7dF.css +0 -1
  74. package/dist/assets/stateDiagram-v2-MP3YSRHH-CCH6TZvE.js +0 -1
  75. package/dist/assets/swimlanesDiagram-VR7AAH4N-DyRa4rww.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/v1/meta` and `GET /api/health` are open. A server on loopback
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 }` — the repos this server may loop in |
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? }` | new chat in a workspace (default: the server's repo) |
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 }` | add a repo (path on the server's machine) |
71
- | `POST /workspaces/remove` | `{ id }` | forget a repo (not the one the server was started in) |
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 # the browser app for this repo, and the URL to open
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 repo is also recoverable from Pi itself: each session header records its `cwd`, and the session dir names the
35
- workspace. `workspaces.json` is a cache — if it is lost or stale, the server rebuilds the chat-to-repo binding from the
36
- sessions on startup, and writes it atomically.
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
- ## Local only, by design
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
- The server binds `127.0.0.1` and refuses any other `--host`. It is not a service: it can prompt an agent that has
41
- `bash` on this machine, and it serves code that attempts wrote. There is no auth because there is no remote. If you need
42
- it from another device, tunnel to it (`ssh -L 8001:127.0.0.1:8001 …`) rather than exposing the port.
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 point it at repos from the tab bar:
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 — the one command, from any repo
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
- # or: pi-loops-ui --port 8001 --cwd ~/code/some-repo (seeds one tab)
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
- is a runtime dependency and is resolved out of `node_modules`; `pi` itself still has to be on the machine for a round
57
- to run, which the boot says out loud rather than at the first prompt.
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 `pi` is not on PATH, or an `--extensions` path does not exist, it prints what is wrong and the fix under `!! a loop
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 **tab is a repo** ("workspace"): its chats, its runs, its `.pi/loops.json`. `+` adds a repo — the path is resolved
76
- server-side and must be a directory — and `×` closes it; loops and chats stay on disk. Workspaces, the tab you had
77
- open, and chats are remembered in `~/.pi/loops-ui`, so a restart resumes everything. A chat remembers its repo, so
78
- its `pi --mode rpc` child spawns in that directory with that directory's session dir.
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` | `process.cwd()` | the repo the loops work in |
86
- | `--pi` | `pi` | the Pi executable each chat spawns |
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 only; anything else is refused (this drives an agent with tools) |
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
- the browser app for this repo (localhost only), prints the URL and opens it
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, "--cwd", CWD, "--port", String(opt("port", "8001")), "--pi", PI];
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?: string) { return this.call<{ chat: string; ws: string }>("/chat", { method: "POST", json: { 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-au1NQO2y.js";import{p as f}from"./chunk-JWPE2WC7-AIHpJEyX.js";import{_ as n,l as o}from"./index-DhZYU086.js";import{M as c,b as d}from"./cynefin-OW5HDTMX-CjokfIiy.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
+ 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-DhZYU086.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};
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};