@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,81 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export class FileSessionMetadataStore {
|
|
4
|
+
filePath;
|
|
5
|
+
sessions = new Map();
|
|
6
|
+
constructor(filePath) {
|
|
7
|
+
this.filePath = filePath;
|
|
8
|
+
}
|
|
9
|
+
async initialize() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
const entries = parsed.sessions ?? {};
|
|
14
|
+
this.sessions = new Map(Object.entries(entries).filter(([key, value]) => typeof key === 'string' &&
|
|
15
|
+
typeof value === 'object' &&
|
|
16
|
+
value !== null &&
|
|
17
|
+
(value.archived === undefined || typeof value.archived === 'boolean') &&
|
|
18
|
+
(value.archivedAt === undefined || typeof value.archivedAt === 'string')));
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
|
22
|
+
this.sessions = new Map();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('Failed to load session metadata', { cause: err });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
get(path) {
|
|
29
|
+
return this.sessions.get(path);
|
|
30
|
+
}
|
|
31
|
+
isArchived(path) {
|
|
32
|
+
return this.sessions.get(path)?.archived === true;
|
|
33
|
+
}
|
|
34
|
+
getArchivedLookup(paths) {
|
|
35
|
+
return new Map(paths.map((path) => [path, this.isArchived(path)]));
|
|
36
|
+
}
|
|
37
|
+
async setArchived(path, archived) {
|
|
38
|
+
const existing = this.sessions.get(path) ?? {};
|
|
39
|
+
if (archived) {
|
|
40
|
+
this.sessions.set(path, {
|
|
41
|
+
...existing,
|
|
42
|
+
archived: true,
|
|
43
|
+
archivedAt: existing.archivedAt ?? new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else if (Object.keys(existing).length === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const next = { ...existing, archived: false };
|
|
51
|
+
delete next.archivedAt;
|
|
52
|
+
if (!next.archived) {
|
|
53
|
+
this.sessions.delete(path);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
this.sessions.set(path, next);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await this.save();
|
|
60
|
+
}
|
|
61
|
+
async delete(path) {
|
|
62
|
+
if (!this.sessions.delete(path))
|
|
63
|
+
return;
|
|
64
|
+
await this.save();
|
|
65
|
+
}
|
|
66
|
+
async save() {
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
69
|
+
const tmpPath = this.filePath + '.tmp';
|
|
70
|
+
const payload = {
|
|
71
|
+
version: 1,
|
|
72
|
+
sessions: Object.fromEntries(this.sessions.entries()),
|
|
73
|
+
};
|
|
74
|
+
await writeFile(tmpPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
75
|
+
await rename(tmpPath, this.filePath);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new Error('Failed to save session metadata', { cause: err });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readdir, readlink, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Check if `pid` is a descendant (child, grandchild, etc.) of any PID in `ancestorPids`
|
|
4
|
+
* by walking the ppid chain via /proc/<pid>/stat.
|
|
5
|
+
* A PID is not considered a descendant of itself.
|
|
6
|
+
*/
|
|
7
|
+
async function isDescendantOfAny(pid, ancestorPids) {
|
|
8
|
+
let current = pid;
|
|
9
|
+
// Walk up to 64 levels to avoid infinite loops on broken /proc data
|
|
10
|
+
for (let i = 0; i < 64; i++) {
|
|
11
|
+
let stat;
|
|
12
|
+
try {
|
|
13
|
+
stat = await readFile(`/proc/${current}/stat`, 'utf-8');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// /proc/<pid>/stat format: pid (comm) state ppid ...
|
|
19
|
+
// comm can contain spaces and parens, so find the last ')' first
|
|
20
|
+
const lastParen = stat.lastIndexOf(')');
|
|
21
|
+
if (lastParen === -1)
|
|
22
|
+
return false;
|
|
23
|
+
const afterComm = stat.slice(lastParen + 2); // skip ') '
|
|
24
|
+
const fields = afterComm.split(' ');
|
|
25
|
+
// fields[0] = state, fields[1] = ppid
|
|
26
|
+
const ppid = Number(fields[1]);
|
|
27
|
+
if (!Number.isInteger(ppid) || ppid <= 0)
|
|
28
|
+
return false;
|
|
29
|
+
if (ancestorPids.has(ppid))
|
|
30
|
+
return true;
|
|
31
|
+
// Reached init — not a descendant
|
|
32
|
+
if (ppid === 1)
|
|
33
|
+
return false;
|
|
34
|
+
current = ppid;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Find external pi processes whose working directory matches `folderPath`.
|
|
40
|
+
* Scans /proc to find processes running pi-coding-agent in the given folder.
|
|
41
|
+
*
|
|
42
|
+
* Excludes:
|
|
43
|
+
* - The current process (pimote server)
|
|
44
|
+
* - Descendants of the current process (sub-agents spawned by this server)
|
|
45
|
+
* - Descendants of any other pi process (sub-agents spawned by an external pi)
|
|
46
|
+
*
|
|
47
|
+
* Only "root" pi processes — those not parented by another pi process or this
|
|
48
|
+
* server — are returned as conflicts.
|
|
49
|
+
*/
|
|
50
|
+
export async function findExternalPiProcesses(folderPath) {
|
|
51
|
+
const myPid = process.pid;
|
|
52
|
+
let resolvedFolder;
|
|
53
|
+
try {
|
|
54
|
+
resolvedFolder = await realpath(folderPath);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir('/proc');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
// First pass: collect all pi processes in this folder (excluding ourselves)
|
|
67
|
+
const allPiPids = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const pid = Number(entry);
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === myPid)
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
// Check cwd
|
|
74
|
+
const cwd = await readlink(`/proc/${pid}/cwd`);
|
|
75
|
+
let resolvedCwd;
|
|
76
|
+
try {
|
|
77
|
+
resolvedCwd = await realpath(`/proc/${pid}/cwd`);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
resolvedCwd = cwd;
|
|
81
|
+
}
|
|
82
|
+
if (resolvedCwd !== resolvedFolder)
|
|
83
|
+
continue;
|
|
84
|
+
// Check cmdline to verify it's a pi process
|
|
85
|
+
const cmdlineRaw = await readFile(`/proc/${pid}/cmdline`, 'utf-8');
|
|
86
|
+
// cmdline uses null bytes as separators
|
|
87
|
+
const cmdline = cmdlineRaw.replace(/\0/g, ' ').trim();
|
|
88
|
+
if (!isPiProcess(cmdline))
|
|
89
|
+
continue;
|
|
90
|
+
allPiPids.push(pid);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
// ENOENT (process exited), EACCES (no permission) — skip
|
|
94
|
+
const code = err.code;
|
|
95
|
+
if (code === 'ENOENT' || code === 'EACCES')
|
|
96
|
+
continue;
|
|
97
|
+
// Other errors — skip too, but don't crash
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (allPiPids.length === 0)
|
|
102
|
+
return [];
|
|
103
|
+
// Second pass: filter out any pi process that is a descendant of
|
|
104
|
+
// this server or of another pi process in the set.
|
|
105
|
+
const ancestorPids = new Set([myPid, ...allPiPids]);
|
|
106
|
+
const rootPids = [];
|
|
107
|
+
for (const pid of allPiPids) {
|
|
108
|
+
if (await isDescendantOfAny(pid, ancestorPids))
|
|
109
|
+
continue;
|
|
110
|
+
rootPids.push(pid);
|
|
111
|
+
}
|
|
112
|
+
return rootPids;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check if a cmdline string belongs to a pi-coding-agent process.
|
|
116
|
+
*/
|
|
117
|
+
function isPiProcess(cmdline) {
|
|
118
|
+
if (cmdline.includes('pi-coding-agent'))
|
|
119
|
+
return true;
|
|
120
|
+
// Check if any argv[0] ends with '/pi' or is exactly 'pi'
|
|
121
|
+
const parts = cmdline.split(/\s+/);
|
|
122
|
+
for (const part of parts) {
|
|
123
|
+
if (part === 'pi' || part.endsWith('/pi'))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Kill external pi processes in the given folder.
|
|
130
|
+
* Sends SIGTERM, waits 1 second, then SIGKILL if needed.
|
|
131
|
+
* Returns the number of processes killed.
|
|
132
|
+
*/
|
|
133
|
+
export async function killExternalPiProcesses(folderPath, targetPids) {
|
|
134
|
+
let pids = await findExternalPiProcesses(folderPath);
|
|
135
|
+
if (targetPids && targetPids.length > 0) {
|
|
136
|
+
pids = pids.filter((pid) => targetPids.includes(pid));
|
|
137
|
+
}
|
|
138
|
+
if (pids.length === 0)
|
|
139
|
+
return 0;
|
|
140
|
+
let killed = 0;
|
|
141
|
+
// Send SIGTERM to all
|
|
142
|
+
for (const pid of pids) {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 'SIGTERM');
|
|
145
|
+
killed++;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Process already gone
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (killed === 0)
|
|
152
|
+
return 0;
|
|
153
|
+
// Wait 1 second for graceful shutdown
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
155
|
+
// Check survivors and SIGKILL
|
|
156
|
+
for (const pid of pids) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0); // Check if alive
|
|
159
|
+
// Still alive — force kill
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, 'SIGKILL');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Already gone between check and kill
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Already dead — good
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return killed;
|
|
172
|
+
}
|