@pimote/pimote 0.1.0
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/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/pimote.js +8 -0
- package/client/build/_app/env.js +1 -0
- package/client/build/_app/immutable/assets/0.CsjXJ2oE.css +2 -0
- package/client/build/_app/immutable/assets/2.CIRqqeIr.css +1 -0
- package/client/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/client/build/_app/immutable/chunks/5FogVG_p.js +1 -0
- package/client/build/_app/immutable/chunks/BN18Mjoo.js +1 -0
- package/client/build/_app/immutable/chunks/BTSGQ0LP.js +3 -0
- package/client/build/_app/immutable/chunks/BTW4yCoz.js +1 -0
- package/client/build/_app/immutable/chunks/BgJ-X-tf.js +3 -0
- package/client/build/_app/immutable/chunks/CHncfsjL.js +1 -0
- package/client/build/_app/immutable/chunks/CnTTbAN2.js +1 -0
- package/client/build/_app/immutable/chunks/CnuZs6QA.js +1 -0
- package/client/build/_app/immutable/chunks/CvWR-ThL.js +1 -0
- package/client/build/_app/immutable/chunks/D1hYfEew.js +1 -0
- package/client/build/_app/immutable/chunks/D5m3x_L9.js +5 -0
- package/client/build/_app/immutable/chunks/L5t1qIFa.js +50 -0
- package/client/build/_app/immutable/entry/app.BjHwmkZK.js +2 -0
- package/client/build/_app/immutable/entry/start.CZeUhs5D.js +1 -0
- package/client/build/_app/immutable/nodes/0.HHf1ps7Y.js +5 -0
- package/client/build/_app/immutable/nodes/1.CjbUSBAL.js +1 -0
- package/client/build/_app/immutable/nodes/2.C22f_gRz.js +49 -0
- package/client/build/_app/version.json +1 -0
- package/client/build/index.html +45 -0
- package/client/build/pwa/badge-96.png +0 -0
- package/client/build/pwa/icon-192.png +0 -0
- package/client/build/pwa/icon-512.png +0 -0
- package/client/build/pwa/manifest.json +39 -0
- package/client/build/robots.txt +3 -0
- package/client/build/sw.js +2 -0
- package/package.json +81 -0
- package/patches/@mariozechner+pi-coding-agent+0.65.0.patch +24 -0
- package/scripts/postinstall-patches.mjs +55 -0
- package/server/dist/cli.js +347 -0
- package/server/dist/config.js +78 -0
- package/server/dist/event-buffer.js +223 -0
- package/server/dist/extension-ui-bridge.js +175 -0
- package/server/dist/folder-index.js +126 -0
- package/server/dist/index.js +54 -0
- package/server/dist/message-mapper.js +80 -0
- package/server/dist/panel-state.js +28 -0
- package/server/dist/paths.js +14 -0
- package/server/dist/push-infrastructure.js +73 -0
- package/server/dist/push-notification.js +56 -0
- package/server/dist/server.js +223 -0
- package/server/dist/session-manager.js +313 -0
- package/server/dist/session-metadata.js +81 -0
- package/server/dist/takeover.js +172 -0
- package/server/dist/ws-handler.js +989 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
function getXdgConfigHome() {
|
|
4
|
+
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
5
|
+
}
|
|
6
|
+
function getXdgStateHome() {
|
|
7
|
+
return process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
|
|
8
|
+
}
|
|
9
|
+
export const PIMOTE_CONFIG_DIR = join(getXdgConfigHome(), 'pimote');
|
|
10
|
+
export const PIMOTE_STATE_DIR = join(getXdgStateHome(), 'pimote');
|
|
11
|
+
export const PIMOTE_CONFIG_PATH = join(PIMOTE_CONFIG_DIR, 'config.json');
|
|
12
|
+
export const PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_STATE_DIR, 'push-subscriptions.json');
|
|
13
|
+
export const PIMOTE_SESSION_METADATA_PATH = join(PIMOTE_STATE_DIR, 'session-metadata.json');
|
|
14
|
+
export const LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_CONFIG_DIR, 'push-subscriptions.json');
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, copyFile, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import webpush from 'web-push';
|
|
4
|
+
async function exists(path) {
|
|
5
|
+
try {
|
|
6
|
+
await readFile(path);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function migratePushSubscriptionStore(oldPath, newPath) {
|
|
17
|
+
if (oldPath === newPath)
|
|
18
|
+
return;
|
|
19
|
+
if (await exists(newPath))
|
|
20
|
+
return;
|
|
21
|
+
if (!(await exists(oldPath)))
|
|
22
|
+
return;
|
|
23
|
+
await mkdir(dirname(newPath), { recursive: true });
|
|
24
|
+
try {
|
|
25
|
+
await rename(oldPath, newPath);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'EXDEV') {
|
|
29
|
+
await copyFile(oldPath, newPath);
|
|
30
|
+
await unlink(oldPath);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw new Error('Failed to migrate push subscriptions', { cause: err });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class FilePushSubscriptionStore {
|
|
37
|
+
filePath;
|
|
38
|
+
constructor(filePath) {
|
|
39
|
+
this.filePath = filePath;
|
|
40
|
+
}
|
|
41
|
+
async load() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
44
|
+
return JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
throw new Error('Failed to load push subscriptions', { cause: err });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async save(subscriptions) {
|
|
54
|
+
try {
|
|
55
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
56
|
+
const tmpPath = this.filePath + '.tmp';
|
|
57
|
+
await writeFile(tmpPath, JSON.stringify(subscriptions, null, 2) + '\n', 'utf-8');
|
|
58
|
+
await rename(tmpPath, this.filePath);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error('Failed to save push subscriptions');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class WebPushSender {
|
|
66
|
+
constructor(vapidPublicKey, vapidPrivateKey, vapidEmail) {
|
|
67
|
+
webpush.setVapidDetails('mailto:' + vapidEmail, vapidPublicKey, vapidPrivateKey);
|
|
68
|
+
}
|
|
69
|
+
async sendNotification(subscription, payload) {
|
|
70
|
+
const response = await webpush.sendNotification(subscription, payload);
|
|
71
|
+
return { statusCode: response.statusCode };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class PushNotificationService {
|
|
2
|
+
sender;
|
|
3
|
+
store;
|
|
4
|
+
subscriptions = [];
|
|
5
|
+
constructor(sender, store) {
|
|
6
|
+
this.sender = sender;
|
|
7
|
+
this.store = store;
|
|
8
|
+
}
|
|
9
|
+
/** Load subscriptions from store on startup */
|
|
10
|
+
async initialize() {
|
|
11
|
+
this.subscriptions = await this.store.load();
|
|
12
|
+
}
|
|
13
|
+
/** Store a new push subscription (or update if endpoint matches) */
|
|
14
|
+
async addSubscription(subscription) {
|
|
15
|
+
const idx = this.subscriptions.findIndex((s) => s.endpoint === subscription.endpoint);
|
|
16
|
+
if (idx !== -1) {
|
|
17
|
+
this.subscriptions[idx] = subscription;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
this.subscriptions.push(subscription);
|
|
21
|
+
}
|
|
22
|
+
await this.store.save(this.subscriptions);
|
|
23
|
+
}
|
|
24
|
+
/** Remove a subscription by endpoint */
|
|
25
|
+
async removeSubscription(endpoint) {
|
|
26
|
+
const before = this.subscriptions.length;
|
|
27
|
+
this.subscriptions = this.subscriptions.filter((s) => s.endpoint !== endpoint);
|
|
28
|
+
if (this.subscriptions.length !== before) {
|
|
29
|
+
await this.store.save(this.subscriptions);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Get all current subscriptions */
|
|
33
|
+
getSubscriptions() {
|
|
34
|
+
return [...this.subscriptions];
|
|
35
|
+
}
|
|
36
|
+
/** Send push notification to all subscriptions */
|
|
37
|
+
async notify(payload) {
|
|
38
|
+
const expiredEndpoints = [];
|
|
39
|
+
const payloadStr = JSON.stringify(payload);
|
|
40
|
+
for (const sub of this.subscriptions) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await this.sender.sendNotification(sub, payloadStr);
|
|
43
|
+
if (result.statusCode === 410) {
|
|
44
|
+
expiredEndpoints.push(sub.endpoint);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
console.warn('[PushNotificationService] Failed to send notification:', err.message ?? err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (expiredEndpoints.length > 0) {
|
|
52
|
+
this.subscriptions = this.subscriptions.filter((s) => !expiredEndpoints.includes(s.endpoint));
|
|
53
|
+
await this.store.save(this.subscriptions);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import { WsHandler } from './ws-handler.js';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
9
|
+
const CLIENT_DIR = process.env.CLIENT_DIR || join(__dirname, '..', '..', 'client', 'build');
|
|
10
|
+
/** Read the SvelteKit build version from _app/version.json. Returns null if unavailable. */
|
|
11
|
+
async function loadClientVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(join(CLIENT_DIR, '_app', 'version.json'), 'utf-8');
|
|
14
|
+
const data = JSON.parse(raw);
|
|
15
|
+
return data.version ?? null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const MIME_TYPES = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
24
|
+
'.css': 'text/css; charset=utf-8',
|
|
25
|
+
'.json': 'application/json; charset=utf-8',
|
|
26
|
+
'.png': 'image/png',
|
|
27
|
+
'.jpg': 'image/jpeg',
|
|
28
|
+
'.svg': 'image/svg+xml',
|
|
29
|
+
'.woff': 'font/woff',
|
|
30
|
+
'.woff2': 'font/woff2',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.webmanifest': 'application/manifest+json',
|
|
33
|
+
};
|
|
34
|
+
/** Try to serve a static file from CLIENT_DIR. Returns true if served. */
|
|
35
|
+
async function serveStatic(req, res) {
|
|
36
|
+
const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
|
37
|
+
const filePath = join(CLIENT_DIR, urlPath);
|
|
38
|
+
// Prevent directory traversal — ensure path is within CLIENT_DIR
|
|
39
|
+
if (!filePath.startsWith(CLIENT_DIR + '/') && filePath !== CLIENT_DIR) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const stats = await stat(filePath);
|
|
44
|
+
if (stats.isFile()) {
|
|
45
|
+
const ext = extname(filePath);
|
|
46
|
+
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
47
|
+
const content = await readFile(filePath);
|
|
48
|
+
const headers = { 'Content-Type': mime };
|
|
49
|
+
// HTML, SW, and manifest must not be cached by CDN/proxies.
|
|
50
|
+
// Immutable hashed assets (_app/immutable/) are safe to cache.
|
|
51
|
+
if (urlPath === '/sw.js' || urlPath === '/pwa/manifest.json' || ext === '.html') {
|
|
52
|
+
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
|
|
53
|
+
headers['CDN-Cache-Control'] = 'no-store';
|
|
54
|
+
headers['Cloudflare-CDN-Cache-Control'] = 'no-store';
|
|
55
|
+
}
|
|
56
|
+
else if (urlPath.startsWith('/_app/immutable/')) {
|
|
57
|
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(200, headers);
|
|
60
|
+
res.end(content);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// File not found — fall through
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/** Serve index.html as SPA fallback. */
|
|
70
|
+
async function serveFallback(res) {
|
|
71
|
+
try {
|
|
72
|
+
const indexPath = join(CLIENT_DIR, 'index.html');
|
|
73
|
+
const content = await readFile(indexPath);
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
75
|
+
res.end(content);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
79
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore) {
|
|
83
|
+
const clientVersion = await loadClientVersion();
|
|
84
|
+
if (clientVersion) {
|
|
85
|
+
console.log(`[pimote] Client build version: ${clientVersion}`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.warn(`[pimote] Could not read client build version from ${CLIENT_DIR}/_app/version.json`);
|
|
89
|
+
}
|
|
90
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
91
|
+
// 1. Health check
|
|
92
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
94
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// 2. VAPID public key for push notification subscription
|
|
98
|
+
if (req.method === 'GET' && req.url === '/api/vapid-key') {
|
|
99
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
100
|
+
res.end(JSON.stringify({ publicKey: config.vapidPublicKey ?? '' }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// 3. Static file lookup
|
|
104
|
+
if (req.method === 'GET') {
|
|
105
|
+
const served = await serveStatic(req, res);
|
|
106
|
+
if (served)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// 4. SPA fallback — serve index.html for unmatched GET routes
|
|
110
|
+
if (req.method === 'GET') {
|
|
111
|
+
await serveFallback(res);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
116
|
+
});
|
|
117
|
+
// Wire up session manager callbacks for sidebar broadcasts
|
|
118
|
+
sessionManager.onStatusChange = (sessionId, folderPath) => {
|
|
119
|
+
WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
|
|
120
|
+
};
|
|
121
|
+
sessionManager.onSessionClosed = (sessionId, folderPath) => {
|
|
122
|
+
WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
|
|
123
|
+
};
|
|
124
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
125
|
+
const clientRegistry = new Map();
|
|
126
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
127
|
+
const url = new URL(req.url ?? '', `http://${req.headers.host}`);
|
|
128
|
+
if (url.pathname === '/ws') {
|
|
129
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
130
|
+
wss.emit('connection', ws, req);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
socket.destroy();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
wss.on('connection', (ws, req) => {
|
|
138
|
+
const url = new URL(req.url ?? '', `http://${req.headers.host}`);
|
|
139
|
+
const clientId = url.searchParams.get('clientId') ?? crypto.randomUUID();
|
|
140
|
+
console.log(`[pimote] WebSocket client connected (clientId=${clientId})`);
|
|
141
|
+
// Version check — if the client's build version doesn't match the server's,
|
|
142
|
+
// send a version_mismatch event and close. The client will reload.
|
|
143
|
+
const incomingVersion = url.searchParams.get('version');
|
|
144
|
+
if (clientVersion && incomingVersion && incomingVersion !== clientVersion) {
|
|
145
|
+
console.log(`[pimote] Version mismatch: client=${incomingVersion}, server=${clientVersion} — requesting reload`);
|
|
146
|
+
const event = { type: 'version_mismatch', serverVersion: clientVersion };
|
|
147
|
+
ws.send(JSON.stringify(event), () => ws.close());
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Register new handler first, then clean up and close any stale connection.
|
|
151
|
+
// cleanup() disconnects the old handler's WebSocket routing (sets slot.connection = null).
|
|
152
|
+
// Pending UI responses survive on the ManagedSlot for replay when the new
|
|
153
|
+
// handler's reconnect commands reclaim sessions via claimSession().
|
|
154
|
+
// The close handler skips cleanup when the registry already points to a
|
|
155
|
+
// different handler, so this is the only place it runs.
|
|
156
|
+
const existing = clientRegistry.get(clientId);
|
|
157
|
+
const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
|
|
158
|
+
clientRegistry.set(clientId, handler);
|
|
159
|
+
if (existing) {
|
|
160
|
+
existing.cleanup();
|
|
161
|
+
existing.closeWebSocket();
|
|
162
|
+
}
|
|
163
|
+
ws.on('message', (data) => {
|
|
164
|
+
handler.handleMessage(data.toString()).catch((err) => {
|
|
165
|
+
console.error('[pimote] Unhandled error in message handler:', err);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
ws.on('close', () => {
|
|
169
|
+
console.log(`[pimote] WebSocket client disconnected (clientId=${clientId})`);
|
|
170
|
+
// Only clean up if this handler is still the current entry.
|
|
171
|
+
// If a new handler replaced us (stale-connection reconnect), the new
|
|
172
|
+
// handler owns the sessions — don't orphan them.
|
|
173
|
+
if (clientRegistry.get(clientId) === handler) {
|
|
174
|
+
clientRegistry.delete(clientId);
|
|
175
|
+
handler.cleanup();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
httpServer,
|
|
181
|
+
wss,
|
|
182
|
+
clientRegistry,
|
|
183
|
+
start(port) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
httpServer.listen(port, () => {
|
|
186
|
+
resolve();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
close() {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
// Force-close all active WebSocket connections
|
|
193
|
+
wss.clients.forEach((client) => {
|
|
194
|
+
client.close();
|
|
195
|
+
});
|
|
196
|
+
// Close the WebSocket server with a timeout to prevent hanging
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
console.warn('[pimote] WebSocket server close timeout — forcing shutdown');
|
|
199
|
+
httpServer.close((err) => {
|
|
200
|
+
if (err)
|
|
201
|
+
reject(err);
|
|
202
|
+
else
|
|
203
|
+
resolve();
|
|
204
|
+
});
|
|
205
|
+
}, 5000);
|
|
206
|
+
wss.close((err) => {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
if (err) {
|
|
209
|
+
reject(err);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
httpServer.close((err) => {
|
|
213
|
+
if (err)
|
|
214
|
+
reject(err);
|
|
215
|
+
else
|
|
216
|
+
resolve();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import { EventBuffer } from './event-buffer.js';
|
|
3
|
+
import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
|
|
4
|
+
// ---- Slot-based helpers (operate on ManagedSlot) ----
|
|
5
|
+
/** Send an event to the client connected to this slot. No-op if disconnected. */
|
|
6
|
+
export function sendSlotEvent(slot, event) {
|
|
7
|
+
const ws = slot.connection?.ws;
|
|
8
|
+
if (!ws || ws.readyState !== 1)
|
|
9
|
+
return;
|
|
10
|
+
try {
|
|
11
|
+
ws.send(JSON.stringify(event));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// WebSocket send failed — ignore (client disconnecting)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Create a pending promise for a UI dialog response. Stores the request event for replay on reconnect. */
|
|
18
|
+
export function waitForSlotUiResponse(slot, requestId, requestEvent) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
slot.sessionState.pendingUiResponses.set(requestId, { resolve, event: requestEvent });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Resolve a specific pending UI response by requestId. */
|
|
24
|
+
export function resolveSlotPendingUi(slot, requestId, value) {
|
|
25
|
+
const pending = slot.sessionState.pendingUiResponses.get(requestId);
|
|
26
|
+
if (pending) {
|
|
27
|
+
slot.sessionState.pendingUiResponses.delete(requestId);
|
|
28
|
+
pending.resolve(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Resolve all pending UI responses with undefined. Used on abort, session close, or session reset. */
|
|
32
|
+
export function resolveAllSlotPendingUi(slot) {
|
|
33
|
+
for (const [, pending] of slot.sessionState.pendingUiResponses) {
|
|
34
|
+
pending.resolve(undefined);
|
|
35
|
+
}
|
|
36
|
+
slot.sessionState.pendingUiResponses.clear();
|
|
37
|
+
}
|
|
38
|
+
/** Re-send all pending UI request events to the current client. Called on reconnect to recover lost dialogs. */
|
|
39
|
+
export function replaySlotPendingUiRequests(slot) {
|
|
40
|
+
for (const [, pending] of slot.sessionState.pendingUiResponses) {
|
|
41
|
+
sendSlotEvent(slot, pending.event);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ---- Slot-based session state lifecycle helpers ----
|
|
45
|
+
/** Construct a SessionState from an AgentSession and EventBus.
|
|
46
|
+
* Subscribes to session events and sets up panel listeners. */
|
|
47
|
+
function createSessionState(session, eventBus, config, callbacks, slotRef, folderPath) {
|
|
48
|
+
const sessionId = session.sessionId;
|
|
49
|
+
const eventBuffer = new EventBuffer(config.bufferSize);
|
|
50
|
+
const state = {
|
|
51
|
+
id: sessionId,
|
|
52
|
+
eventBuffer,
|
|
53
|
+
status: session.isStreaming ? 'working' : 'idle',
|
|
54
|
+
needsAttention: false,
|
|
55
|
+
lastActivity: Date.now(),
|
|
56
|
+
unsubscribe: () => { },
|
|
57
|
+
pendingUiResponses: new Map(),
|
|
58
|
+
extensionsBound: false,
|
|
59
|
+
panelState: new Map(),
|
|
60
|
+
panelListenerUnsubs: [],
|
|
61
|
+
panelThrottleTimer: null,
|
|
62
|
+
};
|
|
63
|
+
// Subscribe to session events
|
|
64
|
+
const unsubscribe = session.subscribe((event) => {
|
|
65
|
+
if (event.type === 'agent_start' && state.status !== 'working') {
|
|
66
|
+
state.status = 'working';
|
|
67
|
+
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
68
|
+
}
|
|
69
|
+
else if (event.type === 'agent_end' && state.status !== 'idle') {
|
|
70
|
+
state.status = 'idle';
|
|
71
|
+
state.needsAttention = true;
|
|
72
|
+
if (slotRef.slot)
|
|
73
|
+
callbacks.onAgentEnd?.(sessionId, slotRef.slot);
|
|
74
|
+
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
75
|
+
}
|
|
76
|
+
eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
|
|
77
|
+
});
|
|
78
|
+
state.unsubscribe = unsubscribe;
|
|
79
|
+
// Set up panel listeners on the EventBus
|
|
80
|
+
state.panelListenerUnsubs = setupSlotPanelListeners(eventBus, state, sessionId, callbacks.sendEvent);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
/** Clean up a SessionState: resolve pending UI, clear timers, unsubscribe listeners. */
|
|
84
|
+
function teardownSessionState(state) {
|
|
85
|
+
// Resolve all pending UI responses
|
|
86
|
+
for (const [, pending] of state.pendingUiResponses) {
|
|
87
|
+
pending.resolve(undefined);
|
|
88
|
+
}
|
|
89
|
+
state.pendingUiResponses.clear();
|
|
90
|
+
// Clear panel throttle timer
|
|
91
|
+
if (state.panelThrottleTimer)
|
|
92
|
+
clearTimeout(state.panelThrottleTimer);
|
|
93
|
+
// Remove panel listeners
|
|
94
|
+
for (const unsub of state.panelListenerUnsubs)
|
|
95
|
+
unsub();
|
|
96
|
+
state.panelListenerUnsubs = [];
|
|
97
|
+
// Unsubscribe from session events
|
|
98
|
+
state.unsubscribe();
|
|
99
|
+
}
|
|
100
|
+
/** Register panel detection and data listeners for a SessionState on an EventBus. */
|
|
101
|
+
function setupSlotPanelListeners(eventBus, state, sessionId, sendEvent) {
|
|
102
|
+
const unsub1 = eventBus.on('pimote:detect:request', () => {
|
|
103
|
+
eventBus.emit('pimote:detect:response', { detected: true });
|
|
104
|
+
});
|
|
105
|
+
const unsub2 = eventBus.on('pimote:panels', (data) => {
|
|
106
|
+
applyPanelMessage(state.panelState, data);
|
|
107
|
+
scheduleSlotPanelPush(state, sessionId, sendEvent);
|
|
108
|
+
});
|
|
109
|
+
return [unsub1, unsub2];
|
|
110
|
+
}
|
|
111
|
+
/** Schedule a throttled panel push (~200ms) for a SessionState. */
|
|
112
|
+
function scheduleSlotPanelPush(state, sessionId, sendEvent) {
|
|
113
|
+
if (state.panelThrottleTimer !== null)
|
|
114
|
+
return;
|
|
115
|
+
state.panelThrottleTimer = setTimeout(() => {
|
|
116
|
+
state.panelThrottleTimer = null;
|
|
117
|
+
const cards = getMergedPanelCards(state.panelState);
|
|
118
|
+
sendEvent({ type: 'panel_update', sessionId, cards });
|
|
119
|
+
}, 200);
|
|
120
|
+
}
|
|
121
|
+
export class PimoteSessionManager {
|
|
122
|
+
config;
|
|
123
|
+
pushNotificationService;
|
|
124
|
+
authStorage;
|
|
125
|
+
modelRegistry;
|
|
126
|
+
sessions = new Map();
|
|
127
|
+
idleCheckHandle = null;
|
|
128
|
+
onStatusChange;
|
|
129
|
+
onSessionClosed;
|
|
130
|
+
constructor(config, pushNotificationService) {
|
|
131
|
+
this.config = config;
|
|
132
|
+
this.pushNotificationService = pushNotificationService;
|
|
133
|
+
this.authStorage = AuthStorage.create();
|
|
134
|
+
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
135
|
+
}
|
|
136
|
+
async openSession(folderPath, sessionFilePath) {
|
|
137
|
+
const eventBusRef = { current: null };
|
|
138
|
+
const sharedAuthStorage = this.authStorage;
|
|
139
|
+
const sharedModelRegistry = this.modelRegistry;
|
|
140
|
+
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
141
|
+
const eventBus = createEventBus();
|
|
142
|
+
eventBusRef.current = eventBus;
|
|
143
|
+
const services = await createAgentSessionServices({
|
|
144
|
+
cwd,
|
|
145
|
+
agentDir,
|
|
146
|
+
authStorage: sharedAuthStorage,
|
|
147
|
+
modelRegistry: sharedModelRegistry,
|
|
148
|
+
resourceLoaderOptions: { eventBus },
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
|
|
152
|
+
services,
|
|
153
|
+
diagnostics: services.diagnostics,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const runtime = await createAgentSessionRuntime(factory, {
|
|
157
|
+
cwd: folderPath,
|
|
158
|
+
agentDir: getAgentDir(),
|
|
159
|
+
sessionManager: sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath),
|
|
160
|
+
});
|
|
161
|
+
const session = runtime.session;
|
|
162
|
+
const sessionId = session.sessionId;
|
|
163
|
+
// Diagnostics: log model registry state after factory has loaded extensions
|
|
164
|
+
const availableModels = this.modelRegistry.getAvailable();
|
|
165
|
+
console.log(`[pimote] openSession: ${availableModels.length} models available, session model: ${session.model ? `${session.model.provider}/${session.model.id}` : 'none'}`);
|
|
166
|
+
// Apply default model from config (only for new sessions without an existing model preference)
|
|
167
|
+
if (!sessionFilePath && this.config.defaultProvider && this.config.defaultModel) {
|
|
168
|
+
const models = this.modelRegistry.getAvailable();
|
|
169
|
+
const defaultModel = models.find((m) => m.provider === this.config.defaultProvider && m.id === this.config.defaultModel);
|
|
170
|
+
if (defaultModel) {
|
|
171
|
+
await session.setModel(defaultModel);
|
|
172
|
+
console.log(`[pimote] Set default model: ${defaultModel.provider}/${defaultModel.id}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
console.warn(`[pimote] Default model not found: ${this.config.defaultProvider}/${this.config.defaultModel}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Apply default thinking level from config
|
|
179
|
+
if (!sessionFilePath && this.config.defaultThinkingLevel) {
|
|
180
|
+
session.setThinkingLevel(this.config.defaultThinkingLevel);
|
|
181
|
+
console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
|
|
182
|
+
}
|
|
183
|
+
// Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
|
|
184
|
+
const slotRef = { slot: null };
|
|
185
|
+
const sessionState = createSessionState(session, eventBusRef.current, this.config, {
|
|
186
|
+
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
187
|
+
onAgentEnd: (sid, s) => this.handleAgentEnd(sid, s),
|
|
188
|
+
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
189
|
+
}, slotRef, folderPath);
|
|
190
|
+
const slot = {
|
|
191
|
+
runtime,
|
|
192
|
+
folderPath,
|
|
193
|
+
eventBusRef,
|
|
194
|
+
connection: null,
|
|
195
|
+
sessionState,
|
|
196
|
+
get session() {
|
|
197
|
+
return this.runtime.session;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
slotRef.slot = slot;
|
|
201
|
+
this.sessions.set(sessionId, slot);
|
|
202
|
+
return sessionId;
|
|
203
|
+
}
|
|
204
|
+
handleAgentEnd(sessionId, slot) {
|
|
205
|
+
const folderPath = slot.folderPath;
|
|
206
|
+
const projectName = folderPath.split('/').pop() ?? 'Unknown';
|
|
207
|
+
const firstMessage = this.extractFirstMessage(slot);
|
|
208
|
+
const lastAgentMessage = this.extractLastAgentMessage(slot);
|
|
209
|
+
this.pushNotificationService
|
|
210
|
+
.notify({
|
|
211
|
+
projectName,
|
|
212
|
+
folderPath,
|
|
213
|
+
sessionId,
|
|
214
|
+
sessionName: slot.session.sessionName,
|
|
215
|
+
firstMessage,
|
|
216
|
+
reason: 'idle',
|
|
217
|
+
lastAgentMessage,
|
|
218
|
+
})
|
|
219
|
+
.catch((err) => console.error('[SessionManager] Push notification error:', err));
|
|
220
|
+
}
|
|
221
|
+
extractLastAgentMessage(slot) {
|
|
222
|
+
const messages = slot.session.messages ?? [];
|
|
223
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
224
|
+
const msg = messages[i];
|
|
225
|
+
if (msg.role !== 'assistant')
|
|
226
|
+
continue;
|
|
227
|
+
const text = msg.content
|
|
228
|
+
.filter((c) => c.type === 'text')
|
|
229
|
+
.map((c) => c.text)
|
|
230
|
+
.join('');
|
|
231
|
+
if (text)
|
|
232
|
+
return text.slice(0, 200);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
extractFirstMessage(slot) {
|
|
238
|
+
const messages = slot.session.messages ?? [];
|
|
239
|
+
for (const msg of messages) {
|
|
240
|
+
if (msg.role !== 'user')
|
|
241
|
+
continue;
|
|
242
|
+
const { content } = msg;
|
|
243
|
+
if (typeof content === 'string')
|
|
244
|
+
return content.slice(0, 100);
|
|
245
|
+
if (Array.isArray(content)) {
|
|
246
|
+
for (const c of content) {
|
|
247
|
+
if (c.type === 'text')
|
|
248
|
+
return c.text.slice(0, 100);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
async closeSession(sessionId) {
|
|
256
|
+
const slot = this.sessions.get(sessionId);
|
|
257
|
+
if (!slot)
|
|
258
|
+
return;
|
|
259
|
+
teardownSessionState(slot.sessionState);
|
|
260
|
+
slot.eventBusRef.current?.clear();
|
|
261
|
+
const folderPath = slot.folderPath;
|
|
262
|
+
await slot.runtime.dispose();
|
|
263
|
+
this.sessions.delete(sessionId);
|
|
264
|
+
this.onSessionClosed?.(sessionId, folderPath);
|
|
265
|
+
}
|
|
266
|
+
/** Re-key a slot in the session map after session replacement. */
|
|
267
|
+
reKeySession(slot, oldId, newId) {
|
|
268
|
+
this.sessions.delete(oldId);
|
|
269
|
+
this.sessions.set(newId, slot);
|
|
270
|
+
}
|
|
271
|
+
/** Rebuild a slot's SessionState after session replacement.
|
|
272
|
+
* Tears down the old state and creates a new one from the current runtime.session. */
|
|
273
|
+
rebuildSessionState(slot) {
|
|
274
|
+
teardownSessionState(slot.sessionState);
|
|
275
|
+
const slotRef = { slot: slot };
|
|
276
|
+
slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
|
|
277
|
+
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
278
|
+
onAgentEnd: (sid, s) => this.handleAgentEnd(sid, s),
|
|
279
|
+
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
280
|
+
}, slotRef, slot.folderPath);
|
|
281
|
+
}
|
|
282
|
+
getSession(sessionId) {
|
|
283
|
+
return this.sessions.get(sessionId);
|
|
284
|
+
}
|
|
285
|
+
getAllSessions() {
|
|
286
|
+
return Array.from(this.sessions.values());
|
|
287
|
+
}
|
|
288
|
+
startIdleCheck(idleTimeout, isClientConnected) {
|
|
289
|
+
this.stopIdleCheck();
|
|
290
|
+
this.idleCheckHandle = setInterval(() => {
|
|
291
|
+
for (const [sessionId, slot] of this.sessions) {
|
|
292
|
+
const clientId = slot.connection?.connectedClientId ?? null;
|
|
293
|
+
const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
|
|
294
|
+
if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
|
|
295
|
+
this.closeSession(sessionId).catch(() => {
|
|
296
|
+
// Best-effort cleanup — swallow errors during idle reaping
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}, 60_000);
|
|
301
|
+
}
|
|
302
|
+
stopIdleCheck() {
|
|
303
|
+
if (this.idleCheckHandle !== null) {
|
|
304
|
+
clearInterval(this.idleCheckHandle);
|
|
305
|
+
this.idleCheckHandle = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async dispose() {
|
|
309
|
+
this.stopIdleCheck();
|
|
310
|
+
const sessionIds = Array.from(this.sessions.keys());
|
|
311
|
+
await Promise.all(sessionIds.map((id) => this.closeSession(id)));
|
|
312
|
+
}
|
|
313
|
+
}
|