@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -0
  3. package/bin/pimote.js +8 -0
  4. package/client/build/_app/env.js +1 -0
  5. package/client/build/_app/immutable/assets/0.CsjXJ2oE.css +2 -0
  6. package/client/build/_app/immutable/assets/2.CIRqqeIr.css +1 -0
  7. package/client/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  8. package/client/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  9. package/client/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  10. package/client/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  11. package/client/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  12. package/client/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  13. package/client/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  14. package/client/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  15. package/client/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  16. package/client/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  17. package/client/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  18. package/client/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  19. package/client/build/_app/immutable/chunks/5FogVG_p.js +1 -0
  20. package/client/build/_app/immutable/chunks/BN18Mjoo.js +1 -0
  21. package/client/build/_app/immutable/chunks/BTSGQ0LP.js +3 -0
  22. package/client/build/_app/immutable/chunks/BTW4yCoz.js +1 -0
  23. package/client/build/_app/immutable/chunks/BgJ-X-tf.js +3 -0
  24. package/client/build/_app/immutable/chunks/CHncfsjL.js +1 -0
  25. package/client/build/_app/immutable/chunks/CnTTbAN2.js +1 -0
  26. package/client/build/_app/immutable/chunks/CnuZs6QA.js +1 -0
  27. package/client/build/_app/immutable/chunks/CvWR-ThL.js +1 -0
  28. package/client/build/_app/immutable/chunks/D1hYfEew.js +1 -0
  29. package/client/build/_app/immutable/chunks/D5m3x_L9.js +5 -0
  30. package/client/build/_app/immutable/chunks/L5t1qIFa.js +50 -0
  31. package/client/build/_app/immutable/entry/app.BjHwmkZK.js +2 -0
  32. package/client/build/_app/immutable/entry/start.CZeUhs5D.js +1 -0
  33. package/client/build/_app/immutable/nodes/0.HHf1ps7Y.js +5 -0
  34. package/client/build/_app/immutable/nodes/1.CjbUSBAL.js +1 -0
  35. package/client/build/_app/immutable/nodes/2.C22f_gRz.js +49 -0
  36. package/client/build/_app/version.json +1 -0
  37. package/client/build/index.html +45 -0
  38. package/client/build/pwa/badge-96.png +0 -0
  39. package/client/build/pwa/icon-192.png +0 -0
  40. package/client/build/pwa/icon-512.png +0 -0
  41. package/client/build/pwa/manifest.json +39 -0
  42. package/client/build/robots.txt +3 -0
  43. package/client/build/sw.js +2 -0
  44. package/package.json +81 -0
  45. package/patches/@mariozechner+pi-coding-agent+0.65.0.patch +24 -0
  46. package/scripts/postinstall-patches.mjs +55 -0
  47. package/server/dist/cli.js +347 -0
  48. package/server/dist/config.js +78 -0
  49. package/server/dist/event-buffer.js +223 -0
  50. package/server/dist/extension-ui-bridge.js +175 -0
  51. package/server/dist/folder-index.js +126 -0
  52. package/server/dist/index.js +54 -0
  53. package/server/dist/message-mapper.js +80 -0
  54. package/server/dist/panel-state.js +28 -0
  55. package/server/dist/paths.js +14 -0
  56. package/server/dist/push-infrastructure.js +73 -0
  57. package/server/dist/push-notification.js +56 -0
  58. package/server/dist/server.js +223 -0
  59. package/server/dist/session-manager.js +313 -0
  60. package/server/dist/session-metadata.js +81 -0
  61. package/server/dist/takeover.js +172 -0
  62. 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
+ }