pipane 0.1.5 → 0.1.7

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 (138) hide show
  1. package/README.md +45 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/bin/pipane-rendezvous.js +21 -0
  4. package/bin/pipane.js +21 -1
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  65. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  66. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  67. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  68. package/dist/client/assets/index-iblQYBAl.css +1 -0
  69. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  70. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  71. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  72. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  73. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  74. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  77. package/dist/server/rendezvous/server.js +247 -0
  78. package/dist/server/rendezvous/trust-store.js +432 -0
  79. package/dist/server/server/auth-guard.js +61 -0
  80. package/dist/server/server/backend-connection-authorizer.js +29 -0
  81. package/dist/server/server/backend-identity.js +167 -0
  82. package/dist/server/server/backend-protocol-handler.js +132 -0
  83. package/dist/server/server/backend-trust-store.js +157 -0
  84. package/dist/server/server/backend-webrtc.js +239 -0
  85. package/dist/server/server/bundled-extensions.js +6 -0
  86. package/dist/server/server/conversation-file-access.js +97 -0
  87. package/dist/server/server/extension-status.js +31 -0
  88. package/dist/server/server/frame-connection.js +48 -0
  89. package/dist/server/server/frame-router.js +45 -0
  90. package/dist/server/server/ice-servers.js +24 -0
  91. package/dist/server/server/local-backend-api.js +389 -0
  92. package/dist/server/server/local-settings.js +28 -0
  93. package/dist/server/server/pi-rpc-protocol.js +407 -0
  94. package/dist/server/server/pi-runtime.js +1 -1
  95. package/dist/server/server/process-pool.js +179 -136
  96. package/dist/server/server/rendezvous-client.js +282 -0
  97. package/dist/server/server/rest-api.js +121 -231
  98. package/dist/server/server/server.js +188 -134
  99. package/dist/server/server/session-actor.js +202 -0
  100. package/dist/server/server/session-index.js +111 -18
  101. package/dist/server/server/session-jsonl.js +128 -8
  102. package/dist/server/server/session-path.js +145 -0
  103. package/dist/server/server/session-registry.js +67 -0
  104. package/dist/server/server/update-api.js +28 -0
  105. package/dist/server/server/update-check.js +33 -13
  106. package/dist/server/server/update-manager.js +231 -0
  107. package/dist/server/server/worktree-name.js +124 -0
  108. package/dist/server/server/ws-handler.js +784 -478
  109. package/dist/server/shared/backend-api.js +1 -0
  110. package/dist/server/shared/backend-protocol.js +164 -0
  111. package/dist/server/shared/jsonl-sync.js +41 -0
  112. package/dist/server/shared/node-trust-crypto.js +61 -0
  113. package/dist/server/shared/rendezvous-protocol.js +243 -0
  114. package/dist/server/shared/rpc-timeouts.js +4 -0
  115. package/dist/server/shared/thinking-levels.js +94 -0
  116. package/dist/server/shared/tool-runtime.js +1 -0
  117. package/dist/server/shared/trust-protocol.js +97 -0
  118. package/dist/server/shared/updates.js +4 -0
  119. package/dist/server/shared/ws-protocol.js +473 -0
  120. package/docs/protocol.md +127 -0
  121. package/extensions/canvas.ts +1 -1
  122. package/package.json +42 -17
  123. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  124. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  125. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  126. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  127. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  128. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  129. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  130. package/dist/client/assets/index-CAQqlK61.js +0 -131
  131. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  132. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  133. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  134. package/dist/client/assets/index-woI4agCy.js +0 -45
  135. package/dist/server/server/attached-session.js +0 -209
  136. package/dist/server/server/load-trace-store.js +0 -48
  137. package/dist/server/server/session-lifecycle.js +0 -155
  138. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -9,10 +9,9 @@
9
9
  * switched to that session, and runs one turn. After the turn completes,
10
10
  * the process is released back to the pool.
11
11
  *
12
- * Session lifecycle is managed by SessionLifecycle (single source of truth
13
- * for session→process mappings and status). Process spawning and pooling
14
- * is handled by ProcessPool. The WsHandler routes WebSocket commands to
15
- * these modules.
12
+ * Each session is owned by a serialized SessionActor registered in
13
+ * SessionRegistry. ProcessPool owns exclusive process leases, and WsHandler
14
+ * only routes transport commands and publishes actor state.
16
15
  */
17
16
  import express from "express";
18
17
  import { createServer } from "node:http";
@@ -21,26 +20,40 @@ import path from "node:path";
21
20
  import { hostname } from "node:os";
22
21
  import { randomBytes } from "node:crypto";
23
22
  import { existsSync, readFileSync, watch } from "node:fs";
24
- import { WebSocketServer, WebSocket } from "ws";
25
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
23
+ import { WebSocketServer } from "ws";
24
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
+ import { WS_PROTOCOL_VERSION } from "../shared/ws-protocol.js";
26
+ import { BACKEND_PROTOCOL_VERSION } from "../shared/backend-protocol.js";
26
27
  import { resolvePiLaunch } from "./pi-launch.js";
27
28
  import { checkCommandAvailable, makePiNotFoundMessage } from "./pi-runtime.js";
28
29
  import { registerRestApi } from "./rest-api.js";
29
- import { SessionLifecycle } from "./session-lifecycle.js";
30
+ import { SessionRegistry } from "./session-registry.js";
30
31
  import { ProcessPool } from "./process-pool.js";
31
32
  import { WsHandler } from "./ws-handler.js";
32
- import { LoadTraceStore } from "./load-trace-store.js";
33
33
  import { LocalSettingsStore } from "./local-settings.js";
34
- import { fetchLatestVersion, compareSemver } from "./update-check.js";
34
+ import { resolveUsageExtensionPath } from "./bundled-extensions.js";
35
+ import { UpdateManager } from "./update-manager.js";
36
+ import { registerUpdateApi } from "./update-api.js";
37
+ import { SessionPathGuard } from "./session-path.js";
38
+ import { AuthGuard } from "./auth-guard.js";
39
+ import { loadOrCreateBackendIdentity } from "./backend-identity.js";
40
+ import { BackendRendezvousClient } from "./rendezvous-client.js";
41
+ import { BackendWebRtcManager } from "./backend-webrtc.js";
42
+ import { BackendTrustStore } from "./backend-trust-store.js";
43
+ import { BackendConnectionAuthorizer } from "./backend-connection-authorizer.js";
44
+ import { DataChannelFrameConnection } from "./frame-connection.js";
45
+ import { routeFrameConnection } from "./frame-router.js";
46
+ import { BackendProtocolHandler } from "./backend-protocol-handler.js";
47
+ import { LocalBackendApi } from "./local-backend-api.js";
48
+ import qrcode from "qrcode-terminal";
35
49
  const DEFAULT_PORT = process.env.NODE_ENV === "production" ? "8222" : "18111";
36
- const PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
50
+ const REQUESTED_PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
51
+ const INSTANCE_ID = process.env.PIPANE_INSTANCE_ID || null;
37
52
  const PI_CWD = process.env.PI_CWD || process.cwd();
38
53
  // Quiet mode: only show URLs unless --verbose or PIPANE_VERBOSE=1
39
54
  const VERBOSE = process.argv.includes("--verbose") || process.env.PIPANE_VERBOSE === "1";
40
55
  if (!VERBOSE) {
41
56
  const origLog = console.log;
42
- const origError = console.error;
43
- const origWarn = console.warn;
44
57
  // Suppress all console output; we'll use _log for the few lines we want
45
58
  console.log = () => { };
46
59
  console.error = () => { };
@@ -59,6 +72,10 @@ const PI_LAUNCH = resolvePiLaunch(PI_CLI);
59
72
  const PI_AVAILABLE = checkCommandAvailable(PI_LAUNCH.command);
60
73
  const PI_MAX_PROCESSES = parseInt(process.env.PI_MAX_PROCESSES || "24", 10);
61
74
  const PI_PREWARM_COUNT = parseInt(process.env.PI_PREWARM_COUNT || "2", 10);
75
+ const USAGE_EXTENSION_ENABLED = process.env.PIPANE_USAGE_EXTENSION !== "0";
76
+ const RENDEZVOUS_URL = process.env.PIPANE_RENDEZVOUS_URL;
77
+ let rendezvousPairingRuntime;
78
+ let registeredBackendId;
62
79
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
63
80
  // Read own version from package.json
64
81
  const PKG_JSON_PATH = path.resolve(__dirname, "../../../package.json");
@@ -71,45 +88,79 @@ const PKG_VERSION = (() => {
71
88
  }
72
89
  })();
73
90
  const PKG_NAME = "pipane";
74
- const AUTH_COOKIE_NAME = "pipane_auth";
75
- const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
76
- const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
77
- const AUTH_URL = `http://${PUBLIC_HOSTNAME}:${PORT}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
78
- function parseCookies(header) {
79
- const out = {};
80
- if (!header)
81
- return out;
82
- for (const part of header.split(";")) {
83
- const idx = part.indexOf("=");
84
- if (idx <= 0)
85
- continue;
86
- const k = part.slice(0, idx).trim();
87
- const v = part.slice(idx + 1).trim();
88
- out[k] = decodeURIComponent(v);
91
+ async function startRendezvousRegistration() {
92
+ if (!RENDEZVOUS_URL)
93
+ return;
94
+ const identity = loadOrCreateBackendIdentity(process.env.PIPANE_BACKEND_IDENTITY_FILE);
95
+ const trustStore = new BackendTrustStore({ filePath: process.env.PIPANE_BACKEND_TRUST_FILE });
96
+ registeredBackendId = identity.backendId;
97
+ const client = new BackendRendezvousClient({
98
+ url: RENDEZVOUS_URL,
99
+ identity,
100
+ metadata: {
101
+ name: process.env.PIPANE_BACKEND_NAME || hostname(),
102
+ softwareVersion: PKG_VERSION,
103
+ protocolVersions: [WS_PROTOCOL_VERSION, BACKEND_PROTOCOL_VERSION],
104
+ },
105
+ });
106
+ client.onError((error) => {
107
+ console.warn("[rendezvous]", error instanceof Error ? error.message : error.message);
108
+ });
109
+ let peers;
110
+ client.onAuthorizationRevoked(({ accountId, deviceId }) => {
111
+ trustStore.applyRevocation(accountId, deviceId);
112
+ peers?.closeAuthorization(accountId, deviceId);
113
+ });
114
+ const backendId = await client.start();
115
+ const authorizer = new BackendConnectionAuthorizer(trustStore, client);
116
+ peers = new BackendWebRtcManager({
117
+ signaling: client,
118
+ identity,
119
+ ticketPublicKey: () => client.ticketPublicKey,
120
+ authorize: (context) => authorizer.authorize(context),
121
+ iceTransportPolicy: process.env.PIPANE_ICE_TRANSPORT_POLICY === "relay" ? "relay" : "all",
122
+ });
123
+ peers.onDataChannel(({ channel, deviceId }) => {
124
+ const routes = routeFrameConnection(new DataChannelFrameConnection(channel));
125
+ wsHandler.acceptAuthenticatedConnection(routes.application);
126
+ backendProtocolHandler.accept(routes.semantic, deviceId);
127
+ });
128
+ peers.onError((_connectionId, error) => console.warn("[webrtc]", error.message));
129
+ const appUrl = new URL(process.env.PIPANE_APP_URL || RENDEZVOUS_URL);
130
+ if (appUrl.protocol === "ws:")
131
+ appUrl.protocol = "http:";
132
+ if (appUrl.protocol === "wss:")
133
+ appUrl.protocol = "https:";
134
+ appUrl.pathname = `/backend/${backendId}`;
135
+ appUrl.search = "";
136
+ appUrl.hash = "";
137
+ log(` Backend: ${backendId}`);
138
+ log(` Web: ${appUrl.toString()}`);
139
+ const createPairingUrl = async () => {
140
+ const pairing = trustStore.createPairing();
141
+ await client.openPairing(pairing.pairId, pairing.expiresAt);
142
+ const pairingUrl = new URL(appUrl);
143
+ pairingUrl.pathname = `/pair/${encodeURIComponent(pairing.pairId)}`;
144
+ pairingUrl.hash = new URLSearchParams({ backend: backendId, secret: pairing.secret }).toString();
145
+ return pairingUrl.toString();
146
+ };
147
+ rendezvousPairingRuntime = { createPairingUrl };
148
+ for (const pairing of trustStore.listActivePairings()) {
149
+ void client.openPairing(pairing.pairId, pairing.expiresAt).catch((error) => console.warn("[pairing]", error.message));
150
+ }
151
+ if (!trustStore.ownerAccountId || process.argv.includes("--pair")) {
152
+ const pairingUrl = await createPairingUrl();
153
+ log(` Pair: ${pairingUrl}`);
154
+ qrcode.generate(pairingUrl, { small: true }, (code) => log(code));
89
155
  }
90
- return out;
91
- }
92
- function isLocalAddress(addr) {
93
- if (!addr)
94
- return false;
95
- return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
96
- }
97
- function isLocalRequest(req) {
98
- if (process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1")
99
- return false;
100
- return isLocalAddress(req.socket.remoteAddress);
101
- }
102
- function setAuthCookie(res) {
103
- const secure = process.env.PIPANE_SECURE_COOKIE === "1" ? "; Secure" : "";
104
- const maxAgeSeconds = 60 * 60 * 24 * 30;
105
- res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`);
106
- }
107
- function isAuthorizedRequest(req) {
108
- if (isLocalRequest(req))
109
- return true;
110
- const cookies = parseCookies(req.headers.cookie);
111
- return cookies[AUTH_COOKIE_NAME] === AUTH_TOKEN;
112
156
  }
157
+ const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
158
+ const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
159
+ const authGuard = new AuthGuard({
160
+ token: AUTH_TOKEN,
161
+ disableLocalBypass: process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1",
162
+ secureCookie: process.env.PIPANE_SECURE_COOKIE === "1",
163
+ });
113
164
  // ============================================================================
114
165
  // Express + HTTP server
115
166
  // ============================================================================
@@ -140,76 +191,45 @@ const pingInterval = setInterval(() => {
140
191
  }
141
192
  }, WS_PING_INTERVAL);
142
193
  wss.on("close", () => { clearInterval(pingInterval); });
143
- app.get("/auth", (req, res) => {
144
- const token = typeof req.query.token === "string" ? req.query.token : undefined;
145
- if (isLocalRequest(req) || token === AUTH_TOKEN) {
146
- setAuthCookie(res);
147
- res.redirect("/");
148
- return;
149
- }
150
- res.status(401).type("html").send("<h3>Unauthorized</h3><p>Invalid auth token.</p>");
194
+ authGuard.register(app);
195
+ // A harness can provide a unique ID and verify that it reached the child it
196
+ // launched, rather than an unrelated process that happened to acquire a port.
197
+ app.get("/api/debug/health", (_req, res) => {
198
+ res.json({ ok: true, instanceId: INSTANCE_ID, pid: process.pid });
151
199
  });
152
- app.use((req, res, next) => {
153
- if (isAuthorizedRequest(req)) {
154
- if (isLocalRequest(req)) {
155
- setAuthCookie(res);
156
- }
157
- next();
200
+ app.post("/api/pairing", async (_req, res) => {
201
+ res.setHeader("Cache-Control", "no-store");
202
+ if (!rendezvousPairingRuntime) {
203
+ res.status(503).json({ error: "Backend is not registered with rendezvous" });
158
204
  return;
159
205
  }
160
- res.status(401).type("html").send("<h3>Unauthorized</h3><p>Open the one-time auth URL shown in the pipane terminal.</p>");
161
- });
162
- const traceStore = new LoadTraceStore();
163
- const localSettingsStore = new LocalSettingsStore();
164
- app.use((req, res, next) => {
165
- const traceId = typeof req.headers["x-pi-trace-id"] === "string" ? req.headers["x-pi-trace-id"] : "";
166
- if (!traceId) {
167
- next();
168
- return;
206
+ try {
207
+ res.json({ url: await rendezvousPairingRuntime.createPairingUrl() });
208
+ }
209
+ catch (error) {
210
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
169
211
  }
170
- const start = performance.now();
171
- res.on("finish", () => {
172
- traceStore.record(traceId, {
173
- ts: new Date().toISOString(),
174
- source: "backend",
175
- kind: "span",
176
- name: `http ${req.method} ${req.path}`,
177
- durationMs: Number((performance.now() - start).toFixed(2)),
178
- attrs: {
179
- statusCode: res.statusCode,
180
- },
181
- });
182
- });
183
- next();
184
212
  });
213
+ const localSettingsStore = new LocalSettingsStore();
214
+ const registry = new SessionRegistry();
215
+ const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
216
+ const sessionPaths = new SessionPathGuard(SESSIONS_DIR);
185
217
  // Serve static files in production
186
218
  const clientDist = path.resolve(__dirname, "../../client");
187
219
  app.use(express.static(clientDist));
188
- // Register REST endpoints
189
- registerRestApi(app, {
190
- traceStore,
191
- localSettingsStore,
192
- onLocalSettingsReloaded: () => {
193
- wss.clients.forEach((client) => {
194
- if (client.readyState === WebSocket.OPEN) {
195
- client.send(JSON.stringify({
196
- type: "sessions_changed",
197
- file: "__local_settings__",
198
- }));
199
- }
200
- });
201
- },
202
- });
203
220
  // ============================================================================
204
221
  // Core modules
205
222
  // ============================================================================
206
- const lifecycle = new SessionLifecycle();
207
- // Resolve canvas extension path relative to project root
223
+ // Resolve bundled extension entrypoints once at startup.
208
224
  const canvasExtension = path.resolve(__dirname, "../../../extensions/canvas.ts");
225
+ const usageExtension = resolveUsageExtensionPath();
209
226
  const pool = new ProcessPool({
210
227
  command: PI_LAUNCH.command,
211
228
  baseArgs: () => {
212
229
  const args = [...PI_LAUNCH.baseArgs, "--mode", "rpc"];
230
+ if (USAGE_EXTENSION_ENABLED) {
231
+ args.push("-e", usageExtension);
232
+ }
213
233
  if (localSettingsStore.canvasEnabled) {
214
234
  args.push("-e", canvasExtension);
215
235
  }
@@ -219,12 +239,8 @@ const pool = new ProcessPool({
219
239
  maxProcesses: PI_MAX_PROCESSES,
220
240
  prewarmCount: PI_PREWARM_COUNT,
221
241
  onProcessExit: (proc) => {
222
- // If the process was attached to a session, handle the crash
223
- const sessionPath = lifecycle.getAttachedSessionForProcess(proc);
224
- if (sessionPath) {
225
- console.log(`[pool] pi#${proc.id} crashed while attached to ${path.basename(sessionPath)} — marking done`);
226
- lifecycle.crash(sessionPath);
227
- }
242
+ // WsHandler owns attached-session snapshots and listener bookkeeping.
243
+ wsHandler.handleProcessExit(proc);
228
244
  // Replenish the pool for the default cwd
229
245
  if (PI_AVAILABLE) {
230
246
  pool.prewarm(PI_CWD);
@@ -232,8 +248,9 @@ const pool = new ProcessPool({
232
248
  },
233
249
  });
234
250
  const wsHandler = new WsHandler({
235
- lifecycle,
251
+ registry,
236
252
  pool,
253
+ sessionPaths,
237
254
  defaultCwd: PI_CWD,
238
255
  piLaunch: PI_LAUNCH,
239
256
  ensurePool: () => {
@@ -241,11 +258,38 @@ const wsHandler = new WsHandler({
241
258
  pool.prewarm(PI_CWD);
242
259
  }
243
260
  },
244
- isRequestAuthorized: (req) => isAuthorizedRequest(req),
245
- traceStore,
261
+ isRequestAuthorized: (req) => authGuard.isAuthorizedRequest(req),
246
262
  });
247
263
  // Register WS handler
248
264
  wsHandler.register(wss);
265
+ const updateManager = new UpdateManager({
266
+ pipaneVersion: PKG_VERSION,
267
+ pipanePackageName: PKG_NAME,
268
+ piLaunch: PI_LAUNCH,
269
+ cwd: PI_CWD,
270
+ onPiRuntimeChanged: async () => {
271
+ pool.decommissionAll();
272
+ if (wsHandler.isPiAvailable)
273
+ await pool.prewarm(PI_CWD);
274
+ },
275
+ });
276
+ registerUpdateApi(app, updateManager);
277
+ const backendApi = new LocalBackendApi({
278
+ localSettingsStore,
279
+ sessionPaths,
280
+ backendId: () => registeredBackendId,
281
+ updateManager,
282
+ runSessionMutation: (sessionPath, operation, mutation) => {
283
+ const actor = registry.get(sessionPath);
284
+ return actor.enqueue(operation, async () => {
285
+ actor.assertAvailable(operation);
286
+ await mutation();
287
+ });
288
+ },
289
+ onLocalSettingsReloaded: () => wsHandler.notifySessionsChanged("__local_settings__"),
290
+ });
291
+ registerRestApi(app, { api: backendApi });
292
+ const backendProtocolHandler = new BackendProtocolHandler(backendApi);
249
293
  // ============================================================================
250
294
  // Debug endpoints
251
295
  // ============================================================================
@@ -304,7 +348,6 @@ setInterval(tick, 1000); tick();
304
348
  // ============================================================================
305
349
  // Sessions Directory Watcher
306
350
  // ============================================================================
307
- const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
308
351
  function startSessionsWatcher() {
309
352
  if (!existsSync(SESSIONS_DIR)) {
310
353
  console.log(`Sessions dir does not exist yet: ${SESSIONS_DIR}`);
@@ -320,18 +363,10 @@ function startSessionsWatcher() {
320
363
  clearTimeout(debounceTimer);
321
364
  debounceTimer = setTimeout(() => {
322
365
  const fullPath = path.join(SESSIONS_DIR, lastChangedFile);
323
- // Notify the message cache it will re-read from disk if changed
324
- // and push session_messages to the subscribed client if needed.
366
+ // Re-read detached session state and push it to active subscribers.
325
367
  wsHandler.notifySessionFileChanged(fullPath);
326
- // Also notify all WS clients about the file change (for sidebar refresh)
327
- wss.clients.forEach((client) => {
328
- if (client.readyState === WebSocket.OPEN) {
329
- client.send(JSON.stringify({
330
- type: "sessions_changed",
331
- file: fullPath,
332
- }));
333
- }
334
- });
368
+ // Notify every local WebSocket and authenticated DataChannel client.
369
+ wsHandler.notifySessionsChanged(fullPath);
335
370
  }, 300);
336
371
  });
337
372
  console.log(`Watching sessions directory: ${SESSIONS_DIR}`);
@@ -348,7 +383,13 @@ if (PI_AVAILABLE) {
348
383
  else {
349
384
  console.log(`[pi] ${makePiNotFoundMessage(PI_LAUNCH.command)}`);
350
385
  }
351
- server.listen(PORT, async () => {
386
+ server.listen(REQUESTED_PORT, () => {
387
+ void startRendezvousRegistration().catch((error) => {
388
+ console.warn("[rendezvous] registration stopped:", error);
389
+ });
390
+ const address = server.address();
391
+ const port = address && typeof address !== "string" ? address.port : REQUESTED_PORT;
392
+ const authUrl = `http://${PUBLIC_HOSTNAME}:${port}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
352
393
  log("");
353
394
  log(" _ ");
354
395
  log(" _ __ (_)_ __ __ _ _ __ ___ ");
@@ -358,18 +399,31 @@ server.listen(PORT, async () => {
358
399
  log(" |_| |_| ");
359
400
  log(` v${PKG_VERSION}`);
360
401
  log("");
361
- log(` Local: http://localhost:${PORT}`);
362
- log(` Remote: ${AUTH_URL}`);
402
+ log(` Local: http://localhost:${port}`);
403
+ log(` Remote: ${authUrl}`);
363
404
  if (!process.env.PIPANE_AUTH_TOKEN) {
364
405
  log(`\n Auth token is random and changes on restart.`);
365
406
  log(` Set PIPANE_AUTH_TOKEN to use a fixed token.`);
366
407
  }
367
408
  log("");
368
- // Non-blocking update check
369
- const latest = await fetchLatestVersion(PKG_NAME);
370
- if (latest && compareSemver(PKG_VERSION, latest) < 0) {
371
- log(` Update available: v${PKG_VERSION} → v${latest}`);
372
- log(` Run \`npm install -g ${PKG_NAME}\` to upgrade.`);
373
- log("");
374
- }
409
+ // The same asynchronous check feeds the web UI and preserves terminal hints.
410
+ void updateManager.check().then(({ notices }) => {
411
+ for (const notice of notices) {
412
+ if (notice.target === "pipane") {
413
+ log(` Update available: v${notice.currentVersion} v${notice.latestVersion}`);
414
+ log(` Open pipane in the browser to install it, or run \`npm install -g ${PKG_NAME}\`.`);
415
+ }
416
+ else if (notice.target === "pi") {
417
+ log(` Pi update available: v${notice.currentVersion} → v${notice.latestVersion}`);
418
+ log(" Open pipane in the browser to install it, or run `pi update --self`.");
419
+ }
420
+ else if (notice.packages?.length) {
421
+ log(` Pi package updates available: ${notice.packages.join(", ")}`);
422
+ log(" Open pipane in the browser to install them, or run `pi update --extensions`.");
423
+ }
424
+ log("");
425
+ }
426
+ }).catch((error) => {
427
+ console.warn("[updates] Update check failed:", error);
428
+ });
375
429
  });
@@ -0,0 +1,202 @@
1
+ const LEGAL_TRANSITIONS = {
2
+ detached: ["attaching"],
3
+ attaching: ["ready", "failed", "releasing"],
4
+ ready: ["starting", "releasing", "failed"],
5
+ starting: ["running", "settling", "releasing", "failed"],
6
+ running: ["settling", "releasing", "failed"],
7
+ settling: ["releasing", "failed"],
8
+ releasing: ["detached"],
9
+ failed: ["releasing"],
10
+ };
11
+ /**
12
+ * Serialized owner of one session's mutable runtime state.
13
+ *
14
+ * Long-running turns do not hold the command mailbox: prompt startup commits the
15
+ * `starting` phase and returns, Pi events are enqueued as internal commands, and
16
+ * final settlement is enqueued separately. This leaves steer/abort responsive
17
+ * while preventing compact/fork/rename from overlapping the turn.
18
+ */
19
+ export class SessionActor {
20
+ sessionPath;
21
+ _phase = "detached";
22
+ _lease;
23
+ _session;
24
+ _turnGeneration = 0;
25
+ _steeringQueue = [];
26
+ _turnEventCleanup;
27
+ commandTail = Promise.resolve();
28
+ emitEvent;
29
+ constructor(sessionPath, emitEvent) {
30
+ this.sessionPath = sessionPath;
31
+ this.emitEvent = emitEvent;
32
+ }
33
+ get phase() { return this._phase; }
34
+ get process() { return this._lease?.process; }
35
+ get session() { return this._session; }
36
+ get turnGeneration() { return this._turnGeneration; }
37
+ get steeringQueue() { return this._steeringQueue; }
38
+ get isAttached() { return this._lease !== undefined; }
39
+ get isTurnActive() {
40
+ return this._phase === "starting" || this._phase === "running" || this._phase === "settling";
41
+ }
42
+ /** Serialize one command or internal event for this session. */
43
+ enqueue(operation, fn) {
44
+ const run = this.commandTail.then(fn);
45
+ this.commandTail = run.then(() => undefined, () => undefined);
46
+ return run.catch((error) => {
47
+ if (error instanceof Error) {
48
+ error.message = `[${operation}] ${error.message}`;
49
+ }
50
+ throw error;
51
+ });
52
+ }
53
+ assertAvailable(operation) {
54
+ if (this.isTurnActive) {
55
+ throw new Error(`Cannot ${operation} while session turn is ${this._phase}`);
56
+ }
57
+ if (this._phase !== "detached" && this._phase !== "ready") {
58
+ throw new Error(`Cannot ${operation} while session is ${this._phase}`);
59
+ }
60
+ }
61
+ attach(lease, session) {
62
+ if (this._phase !== "detached" || this._lease) {
63
+ throw new Error(`Session is already owned in phase ${this._phase}`);
64
+ }
65
+ this.transition("attaching");
66
+ this._lease = lease;
67
+ this._session = session;
68
+ this.transition("ready");
69
+ this.emitEvent({ type: "attached", proc: lease.process });
70
+ }
71
+ beginTurn() {
72
+ if (this._phase !== "ready" || !this._lease || !this._session) {
73
+ throw new Error(`Cannot start turn while session is ${this._phase}`);
74
+ }
75
+ this._turnGeneration += 1;
76
+ this.transition("starting");
77
+ return this._turnGeneration;
78
+ }
79
+ setTurnEventCleanup(generation, cleanup) {
80
+ if (generation !== this._turnGeneration || !this.isTurnActive) {
81
+ cleanup();
82
+ return;
83
+ }
84
+ this._turnEventCleanup?.();
85
+ this._turnEventCleanup = cleanup;
86
+ }
87
+ owns(proc, generation) {
88
+ return this.process === proc && (generation === undefined || generation === this._turnGeneration);
89
+ }
90
+ async applyProcessEvent(proc, generation, event, replacementMessages) {
91
+ return this.enqueue("process event", () => {
92
+ if (!this.owns(proc, generation) || !this._session) {
93
+ return { accepted: false, changed: false, started: false, ended: false, settled: false };
94
+ }
95
+ let started = false;
96
+ let ended = false;
97
+ let settled = false;
98
+ if (event.type === "agent_start") {
99
+ started = true;
100
+ if (this._phase === "starting")
101
+ this.transition("running");
102
+ }
103
+ if (event.type === "agent_end") {
104
+ ended = true;
105
+ if (this._phase === "starting" || this._phase === "running")
106
+ this.transition("settling");
107
+ this.clearSteering();
108
+ }
109
+ if (event.type === "agent_settled") {
110
+ started = true;
111
+ ended = true;
112
+ settled = true;
113
+ if (this._phase === "starting" || this._phase === "running")
114
+ this.transition("settling");
115
+ }
116
+ let changed = this._session.applyEvent(event);
117
+ if (replacementMessages) {
118
+ this._session.replaceMessages(replacementMessages);
119
+ changed = true;
120
+ }
121
+ if (event.type === "message_end" && event.message?.role === "user") {
122
+ const text = typeof event.message.content === "string"
123
+ ? event.message.content
124
+ : (event.message.content || [])
125
+ .filter((content) => content.type === "text")
126
+ .map((content) => content.text)
127
+ .join(" ");
128
+ this.dequeueSteering(text);
129
+ }
130
+ return { accepted: true, changed, started, ended, settled };
131
+ });
132
+ }
133
+ enqueueSteering(message) {
134
+ this._steeringQueue = [...this._steeringQueue, message];
135
+ this.syncSteeringQueue();
136
+ }
137
+ removeSteeringByIndex(index) {
138
+ if (index < 0 || index >= this._steeringQueue.length)
139
+ return false;
140
+ this._steeringQueue = this._steeringQueue.filter((_, itemIndex) => itemIndex !== index);
141
+ this.syncSteeringQueue();
142
+ return true;
143
+ }
144
+ dequeueSteering(text) {
145
+ const index = this._steeringQueue.indexOf(text);
146
+ if (index === -1)
147
+ return false;
148
+ this._steeringQueue = this._steeringQueue.filter((_, itemIndex) => itemIndex !== index);
149
+ this.syncSteeringQueue();
150
+ return true;
151
+ }
152
+ clearSteering() {
153
+ if (this._steeringQueue.length === 0)
154
+ return;
155
+ this._steeringQueue = [];
156
+ this.syncSteeringQueue();
157
+ }
158
+ markFailed() {
159
+ if (this._phase === "detached" || this._phase === "releasing" || this._phase === "failed")
160
+ return;
161
+ this.transition("failed");
162
+ }
163
+ /**
164
+ * Release all runtime ownership. Safe to call once the actor command holding
165
+ * the current mutation reaches a terminal path.
166
+ */
167
+ detach() {
168
+ const lease = this._lease;
169
+ const liveSession = this._session;
170
+ if (!lease || !liveSession)
171
+ return undefined;
172
+ if (this._phase !== "releasing")
173
+ this.transition("releasing");
174
+ this._turnEventCleanup?.();
175
+ this._turnEventCleanup = undefined;
176
+ if (this._steeringQueue.length > 0) {
177
+ this._steeringQueue = [];
178
+ this.emitEvent({ type: "steering_queue_update", queue: [] });
179
+ }
180
+ const proc = lease.process;
181
+ this._lease = undefined;
182
+ this._session = undefined;
183
+ lease.release();
184
+ this.transition("detached");
185
+ this.emitEvent({ type: "detached", proc });
186
+ return { proc, liveSession };
187
+ }
188
+ syncSteeringQueue() {
189
+ if (this._session)
190
+ this._session.steeringQueue = [...this._steeringQueue];
191
+ this.emitEvent({ type: "steering_queue_update", queue: [...this._steeringQueue] });
192
+ }
193
+ transition(next) {
194
+ if (next === this._phase)
195
+ return;
196
+ if (!LEGAL_TRANSITIONS[this._phase].includes(next)) {
197
+ throw new Error(`Illegal session transition ${this._phase} -> ${next}`);
198
+ }
199
+ this._phase = next;
200
+ this.emitEvent({ type: "phase_change", phase: next });
201
+ }
202
+ }