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.
- package/README.md +45 -1
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-DwbBcYUf.js +2 -0
- package/dist/client/assets/index-iblQYBAl.css +1 -0
- package/dist/client/assets/main-LGItV9Aj.js +2807 -0
- package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
- package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
- package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +239 -0
- package/dist/server/server/bundled-extensions.js +6 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/extension-status.js +31 -0
- package/dist/server/server/frame-connection.js +48 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +28 -0
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/pi-runtime.js +1 -1
- package/dist/server/server/process-pool.js +179 -136
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +121 -231
- package/dist/server/server/server.js +188 -134
- package/dist/server/server/session-actor.js +202 -0
- package/dist/server/server/session-index.js +111 -18
- package/dist/server/server/session-jsonl.js +128 -8
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/session-registry.js +67 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +231 -0
- package/dist/server/server/worktree-name.js +124 -0
- package/dist/server/server/ws-handler.js +784 -478
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/jsonl-sync.js +41 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/rpc-timeouts.js +4 -0
- package/dist/server/shared/thinking-levels.js +94 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -0
- package/docs/protocol.md +127 -0
- package/extensions/canvas.ts +1 -1
- package/package.json +42 -17
- package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
- package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
- package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
- package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
- package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
- package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
- package/dist/client/assets/index-BtDAXAo1.css +0 -1
- package/dist/client/assets/index-CAQqlK61.js +0 -131
- package/dist/client/assets/index-CJEeBmBj.js +0 -3
- package/dist/client/assets/index-DUEhKmPQ.js +0 -1
- package/dist/client/assets/index-XEVKscP-.js +0 -4443
- package/dist/client/assets/index-woI4agCy.js +0 -45
- package/dist/server/server/attached-session.js +0 -209
- package/dist/server/server/load-trace-store.js +0 -48
- package/dist/server/server/session-lifecycle.js +0 -155
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
25
|
-
import { getAgentDir } from "@
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
327
|
-
|
|
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(
|
|
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:${
|
|
362
|
-
log(` Remote: ${
|
|
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
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
+
}
|