@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,223 @@
1
+ import { mapAgentMessage } from './message-mapper.js';
2
+ /**
3
+ * Event buffer for reconnect replay with coalescing.
4
+ *
5
+ * All events are forwarded live via sendLive(). For the replay buffer,
6
+ * streaming deltas (message_update, tool_execution_update) are coalesced
7
+ * into their corresponding start events rather than stored individually.
8
+ */
9
+ export class EventBuffer {
10
+ capacity;
11
+ buffer;
12
+ head = 0; // index of oldest entry
13
+ tail = 0; // index of next write position
14
+ count = 0;
15
+ _cursor = 0;
16
+ constructor(capacity) {
17
+ this.capacity = capacity;
18
+ this.buffer = new Array(capacity);
19
+ }
20
+ get currentCursor() {
21
+ return this._cursor;
22
+ }
23
+ /**
24
+ * Process an SDK event: assign cursor, map to PimoteSessionEvent, forward live, and buffer (coalesced).
25
+ */
26
+ onEvent(sdkEvent, sessionId, sendLive, getLastMessage) {
27
+ this._cursor++;
28
+ const cursor = this._cursor;
29
+ const pimoteEvent = this.mapEvent(sdkEvent, sessionId, cursor, getLastMessage);
30
+ sendLive(pimoteEvent);
31
+ this.coalesceAndBuffer(pimoteEvent);
32
+ }
33
+ /**
34
+ * Replay buffered events from a given cursor position.
35
+ * Returns null if fromCursor is too old (full resync needed).
36
+ * Returns empty array if client is caught up.
37
+ */
38
+ replay(fromCursor) {
39
+ if (fromCursor >= this._cursor) {
40
+ return [];
41
+ }
42
+ if (this.count === 0) {
43
+ // Buffer is empty but cursor has advanced — can't replay
44
+ return fromCursor < this._cursor ? null : [];
45
+ }
46
+ const oldestEntry = this.buffer[this.head];
47
+ if (!oldestEntry) {
48
+ return null;
49
+ }
50
+ // If fromCursor is older than the oldest buffered cursor - 1,
51
+ // we can't guarantee complete replay
52
+ if (fromCursor < oldestEntry.cursor - 1) {
53
+ return null;
54
+ }
55
+ const events = [];
56
+ let idx = this.head;
57
+ for (let i = 0; i < this.count; i++) {
58
+ const entry = this.buffer[idx];
59
+ if (entry && entry.cursor > fromCursor) {
60
+ events.push(entry.event);
61
+ }
62
+ idx = (idx + 1) % this.capacity;
63
+ }
64
+ return events;
65
+ }
66
+ // ---- Private helpers ----
67
+ mapEvent(sdkEvent, sessionId, cursor, getLastMessage) {
68
+ const base = { sessionId, cursor, timestamp: new Date().toISOString() };
69
+ switch (sdkEvent.type) {
70
+ case 'agent_start':
71
+ return { ...base, type: 'agent_start' };
72
+ case 'agent_end':
73
+ return { ...base, type: 'agent_end', ...(sdkEvent.error ? { error: sdkEvent.error } : {}) };
74
+ case 'turn_start':
75
+ return { ...base, type: 'turn_start' };
76
+ case 'turn_end':
77
+ return { ...base, type: 'turn_end' };
78
+ case 'message_start':
79
+ return { ...base, type: 'message_start', role: sdkEvent.role ?? 'assistant' };
80
+ case 'message_update': {
81
+ const ame = sdkEvent.assistantMessageEvent;
82
+ const contentIndex = ame?.contentIndex ?? 0;
83
+ // Determine content type from the sub-event
84
+ let contentType = 'text';
85
+ if (ame?.type?.startsWith('thinking_')) {
86
+ contentType = 'thinking';
87
+ }
88
+ else if (ame?.type?.startsWith('toolcall_')) {
89
+ contentType = 'tool_call';
90
+ }
91
+ // Determine subtype from the sub-event suffix
92
+ let subtype = 'delta';
93
+ if (ame?.type?.endsWith('_start')) {
94
+ subtype = 'start';
95
+ }
96
+ else if (ame?.type?.endsWith('_end')) {
97
+ subtype = 'end';
98
+ }
99
+ const delta = ame?.delta ?? '';
100
+ const result = {
101
+ ...base,
102
+ type: 'message_update',
103
+ contentIndex,
104
+ subtype,
105
+ content: {
106
+ type: contentType,
107
+ text: delta,
108
+ },
109
+ };
110
+ // Extract tool call metadata on toolcall_start from the partial message
111
+ if (contentType === 'tool_call' && subtype === 'start' && ame?.partial?.content) {
112
+ const block = ame.partial.content[contentIndex];
113
+ if (block && block.type === 'toolCall') {
114
+ result.toolCallId = block.id;
115
+ result.toolName = block.name;
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+ case 'message_end': {
121
+ // Some providers (e.g. OpenAI) send message_end with empty content — the actual
122
+ // message is only available in session.messages. Use getLastMessage() fallback.
123
+ let message = sdkEvent.message;
124
+ if ((!message || !message.content || (Array.isArray(message.content) && message.content.length === 0)) && getLastMessage) {
125
+ message = getLastMessage();
126
+ }
127
+ return {
128
+ ...base,
129
+ type: 'message_end',
130
+ message: message ? mapAgentMessage(message) : { role: 'assistant', content: [] },
131
+ };
132
+ }
133
+ case 'tool_execution_start':
134
+ return {
135
+ ...base,
136
+ type: 'tool_execution_start',
137
+ toolName: sdkEvent.toolName ?? '',
138
+ toolCallId: sdkEvent.toolCallId ?? '',
139
+ args: sdkEvent.args,
140
+ };
141
+ case 'tool_execution_update':
142
+ return {
143
+ ...base,
144
+ type: 'tool_execution_update',
145
+ toolCallId: sdkEvent.toolCallId ?? '',
146
+ content: typeof sdkEvent.partialResult === 'string' ? sdkEvent.partialResult : '',
147
+ };
148
+ case 'tool_execution_end':
149
+ return {
150
+ ...base,
151
+ type: 'tool_execution_end',
152
+ toolCallId: sdkEvent.toolCallId ?? '',
153
+ result: sdkEvent.result,
154
+ isError: sdkEvent.isError || undefined,
155
+ };
156
+ case 'auto_compaction_start':
157
+ return {
158
+ ...base,
159
+ type: 'auto_compaction_start',
160
+ reason: (sdkEvent.reason ?? 'threshold'),
161
+ };
162
+ case 'auto_compaction_end':
163
+ return {
164
+ ...base,
165
+ type: 'auto_compaction_end',
166
+ result: sdkEvent.result,
167
+ aborted: sdkEvent.aborted ?? false,
168
+ willRetry: sdkEvent.willRetry ?? false,
169
+ ...(sdkEvent.errorMessage ? { errorMessage: sdkEvent.errorMessage } : {}),
170
+ };
171
+ case 'auto_retry_start':
172
+ return {
173
+ ...base,
174
+ type: 'auto_retry_start',
175
+ attempt: sdkEvent.attempt ?? 0,
176
+ maxAttempts: sdkEvent.maxAttempts ?? 0,
177
+ delayMs: sdkEvent.delayMs ?? 0,
178
+ errorMessage: sdkEvent.errorMessage ?? '',
179
+ };
180
+ case 'auto_retry_end':
181
+ return {
182
+ ...base,
183
+ type: 'auto_retry_end',
184
+ success: sdkEvent.success ?? false,
185
+ attempt: sdkEvent.attempt ?? 0,
186
+ ...(sdkEvent.finalError ? { finalError: sdkEvent.finalError } : {}),
187
+ };
188
+ case 'extension_error':
189
+ return {
190
+ ...base,
191
+ type: 'extension_error',
192
+ error: sdkEvent.error ?? '',
193
+ ...(sdkEvent.extensionName ? { extensionName: sdkEvent.extensionName } : {}),
194
+ };
195
+ default:
196
+ // Unknown event type — pass through as agent_start (shouldn't happen)
197
+ return { ...base, type: 'agent_start' };
198
+ }
199
+ }
200
+ coalesceAndBuffer(event) {
201
+ switch (event.type) {
202
+ case 'message_update':
203
+ case 'tool_execution_update':
204
+ // Streaming deltas are forwarded live but not stored in the replay buffer.
205
+ // Only start/end bookends are buffered — reconnect replays the finalized state.
206
+ break;
207
+ default:
208
+ this.pushToBuffer(event);
209
+ break;
210
+ }
211
+ }
212
+ pushToBuffer(event) {
213
+ this.buffer[this.tail] = { cursor: event.cursor, event };
214
+ this.tail = (this.tail + 1) % this.capacity;
215
+ if (this.count < this.capacity) {
216
+ this.count++;
217
+ }
218
+ else {
219
+ // Overflow: oldest entry dropped, advance head
220
+ this.head = (this.head + 1) % this.capacity;
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,175 @@
1
+ import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
2
+ /**
3
+ * Creates an ExtensionUIContext implementation that bridges pi extension UI calls
4
+ * to WebSocket messages sent to the remote client.
5
+ *
6
+ * Dialog methods (select, confirm, input, editor) send a request event and wait
7
+ * for the client to respond. Fire-and-forget methods (notify, setStatus, etc.)
8
+ * send an event without waiting. TUI-only methods are no-ops.
9
+ *
10
+ * The bridge references the ManagedSlot directly — no closures over transient
11
+ * handler instances. When the handler changes (reconnect), the slot's connection
12
+ * is updated and the bridge automatically routes to the new connection.
13
+ * Pending UI promises survive reconnects and are replayed to the new client.
14
+ */
15
+ export function createExtensionUIBridge(slot, pushNotificationService) {
16
+ function notifyInteraction(method, fields) {
17
+ if (!pushNotificationService)
18
+ return;
19
+ const projectName = slot.folderPath.split('/').pop() ?? 'Unknown';
20
+ pushNotificationService
21
+ .notify({
22
+ projectName,
23
+ folderPath: slot.folderPath,
24
+ sessionId: slot.sessionState.id,
25
+ sessionName: slot.session?.sessionName,
26
+ reason: 'interaction',
27
+ interaction: {
28
+ method,
29
+ title: fields.title,
30
+ options: fields.options,
31
+ message: fields.message,
32
+ },
33
+ })
34
+ .catch((err) => console.warn('[ExtensionUIBridge] Push notification error:', err.message ?? err));
35
+ }
36
+ function sendRequest(requestId, fields) {
37
+ const event = {
38
+ type: 'extension_ui_request',
39
+ sessionId: slot.sessionState.id,
40
+ requestId,
41
+ ...fields,
42
+ };
43
+ sendSlotEvent(slot, event);
44
+ return event;
45
+ }
46
+ async function dialogWithTimeout(requestId, requestEvent, opts, fallback) {
47
+ const responsePromise = waitForSlotUiResponse(slot, requestId, requestEvent);
48
+ const racers = [responsePromise];
49
+ if (opts?.timeout) {
50
+ racers.push(new Promise((resolve) => setTimeout(() => resolve(fallback), opts.timeout)));
51
+ }
52
+ if (opts?.signal) {
53
+ if (opts.signal.aborted) {
54
+ // Remove the pending entry we just created — no one will respond to it
55
+ slot.sessionState.pendingUiResponses.delete(requestId);
56
+ return fallback;
57
+ }
58
+ racers.push(new Promise((resolve) => {
59
+ opts.signal.addEventListener('abort', () => resolve(fallback), { once: true });
60
+ }));
61
+ }
62
+ if (racers.length === 1)
63
+ return responsePromise;
64
+ const result = await Promise.race(racers);
65
+ // If timeout or abort won the race, the pending entry is stale — the server
66
+ // has already moved on. Remove it so it won't be replayed on reconnect.
67
+ if (slot.sessionState.pendingUiResponses.has(requestId)) {
68
+ slot.sessionState.pendingUiResponses.delete(requestId);
69
+ }
70
+ return result;
71
+ }
72
+ const ui = {
73
+ // ---- Dialog methods (send + wait for response) ----
74
+ async select(title, options, opts) {
75
+ const requestId = crypto.randomUUID();
76
+ const event = sendRequest(requestId, { method: 'select', title, options });
77
+ notifyInteraction('select', { title, options });
78
+ return dialogWithTimeout(requestId, event, opts, undefined);
79
+ },
80
+ async confirm(title, message, opts) {
81
+ const requestId = crypto.randomUUID();
82
+ const event = sendRequest(requestId, { method: 'confirm', title, message });
83
+ notifyInteraction('confirm', { title, message });
84
+ return dialogWithTimeout(requestId, event, opts, false);
85
+ },
86
+ async input(title, placeholder, opts) {
87
+ const requestId = crypto.randomUUID();
88
+ const event = sendRequest(requestId, { method: 'input', title, placeholder });
89
+ notifyInteraction('input', { title });
90
+ return dialogWithTimeout(requestId, event, opts, undefined);
91
+ },
92
+ async editor(title, prefill) {
93
+ const requestId = crypto.randomUUID();
94
+ const event = sendRequest(requestId, { method: 'editor', title, prefill });
95
+ notifyInteraction('editor', { title });
96
+ return waitForSlotUiResponse(slot, requestId, event);
97
+ },
98
+ // ---- Fire-and-forget methods ----
99
+ notify(message, type) {
100
+ const requestId = crypto.randomUUID();
101
+ sendRequest(requestId, { method: 'notify', message, notifyType: type });
102
+ },
103
+ setStatus(key, text) {
104
+ const requestId = crypto.randomUUID();
105
+ sendRequest(requestId, { method: 'setStatus', key, text });
106
+ },
107
+ setWidget(key, content, options) {
108
+ if (Array.isArray(content) || content === undefined) {
109
+ const requestId = crypto.randomUUID();
110
+ sendRequest(requestId, {
111
+ method: 'setWidget',
112
+ key,
113
+ lines: content,
114
+ placement: options?.placement,
115
+ });
116
+ }
117
+ // If content is a function (TUI component factory), no-op — can't bridge to web
118
+ },
119
+ setTitle(title) {
120
+ const requestId = crypto.randomUUID();
121
+ sendRequest(requestId, { method: 'setTitle', title });
122
+ },
123
+ setEditorText(text) {
124
+ const requestId = crypto.randomUUID();
125
+ sendRequest(requestId, { method: 'setEditorText', text });
126
+ },
127
+ // ---- No-op methods (TUI-only, can't bridge to web) ----
128
+ custom() {
129
+ return Promise.resolve(undefined);
130
+ },
131
+ setWorkingMessage() {
132
+ // no-op
133
+ },
134
+ setHiddenThinkingLabel() {
135
+ // no-op
136
+ },
137
+ setFooter() {
138
+ // no-op
139
+ },
140
+ setHeader() {
141
+ // no-op
142
+ },
143
+ setEditorComponent() {
144
+ // no-op
145
+ },
146
+ onTerminalInput() {
147
+ return () => { };
148
+ },
149
+ getEditorText() {
150
+ return '';
151
+ },
152
+ pasteToEditor() {
153
+ // no-op
154
+ },
155
+ get theme() {
156
+ return null;
157
+ },
158
+ getAllThemes() {
159
+ return [];
160
+ },
161
+ getTheme() {
162
+ return undefined;
163
+ },
164
+ setTheme() {
165
+ return { success: false, error: 'UI not available' };
166
+ },
167
+ getToolsExpanded() {
168
+ return false;
169
+ },
170
+ setToolsExpanded() {
171
+ // no-op
172
+ },
173
+ };
174
+ return ui;
175
+ }
@@ -0,0 +1,126 @@
1
+ import { readdir, stat, unlink } from 'node:fs/promises';
2
+ import { join, basename } from 'node:path';
3
+ import { SessionManager } from '@mariozechner/pi-coding-agent';
4
+ /** Project marker files/directories that identify a folder as a project. */
5
+ const PROJECT_MARKERS = ['.git', 'package.json'];
6
+ /**
7
+ * Scans configured root directories for project folders and lists their sessions.
8
+ */
9
+ export class FolderIndex {
10
+ roots;
11
+ constructor(roots) {
12
+ this.roots = roots;
13
+ }
14
+ /**
15
+ * Scan all roots one level deep for project directories.
16
+ * A subdirectory is a "project" if it contains .git or package.json.
17
+ */
18
+ async scan() {
19
+ const folders = [];
20
+ for (const root of this.roots) {
21
+ let entries;
22
+ try {
23
+ entries = await readdir(root);
24
+ }
25
+ catch {
26
+ console.warn(`[FolderIndex] Root directory not accessible, skipping: ${root}`);
27
+ continue;
28
+ }
29
+ for (const entry of entries) {
30
+ const fullPath = join(root, entry);
31
+ try {
32
+ const info = await stat(fullPath);
33
+ if (!info.isDirectory())
34
+ continue;
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ const isProject = await this.hasProjectMarker(fullPath);
40
+ if (!isProject)
41
+ continue;
42
+ folders.push({
43
+ path: fullPath,
44
+ name: basename(fullPath),
45
+ activeSessionCount: 0, // Will be enriched by session pool later
46
+ externalProcessCount: 0,
47
+ activeStatus: null,
48
+ });
49
+ }
50
+ }
51
+ return folders;
52
+ }
53
+ /**
54
+ * List raw pi session records for a given folder path.
55
+ */
56
+ async listSessionRecords(folderPath) {
57
+ try {
58
+ return await SessionManager.list(folderPath);
59
+ }
60
+ catch (err) {
61
+ console.warn(`[FolderIndex] Failed to list sessions for ${folderPath}:`, err);
62
+ return [];
63
+ }
64
+ }
65
+ /**
66
+ * List sessions for a given folder path.
67
+ * Calls the pi SDK's SessionManager.list() and maps results to the shared SessionInfo type.
68
+ */
69
+ async listSessions(folderPath) {
70
+ const piSessions = await this.listSessionRecords(folderPath);
71
+ return piSessions.map((s) => ({
72
+ id: s.id,
73
+ name: s.name,
74
+ created: s.created.toISOString(),
75
+ modified: s.modified.toISOString(),
76
+ messageCount: s.messageCount,
77
+ firstMessage: s.firstMessage || undefined,
78
+ }));
79
+ }
80
+ /**
81
+ * Resolve a session ID to its file path within a folder.
82
+ * Returns undefined if the session is not found.
83
+ */
84
+ async resolveSessionPath(folderPath, sessionId) {
85
+ const piSessions = await this.listSessionRecords(folderPath);
86
+ const match = piSessions.find((s) => s.id === sessionId);
87
+ return match?.path;
88
+ }
89
+ /**
90
+ * Persist a new display name for a session on disk.
91
+ * Returns true if renamed, false if the session was not found.
92
+ */
93
+ async renameSession(folderPath, sessionId, name) {
94
+ const sessionPath = await this.resolveSessionPath(folderPath, sessionId);
95
+ if (!sessionPath)
96
+ return false;
97
+ SessionManager.open(sessionPath).appendSessionInfo(name);
98
+ return true;
99
+ }
100
+ /**
101
+ * Delete a session file from disk.
102
+ * Returns true if deleted, false if the session was not found.
103
+ */
104
+ async deleteSession(folderPath, sessionId) {
105
+ const sessionPath = await this.resolveSessionPath(folderPath, sessionId);
106
+ if (!sessionPath)
107
+ return false;
108
+ await unlink(sessionPath);
109
+ return true;
110
+ }
111
+ /**
112
+ * Check if a directory contains any project markers.
113
+ */
114
+ async hasProjectMarker(dirPath) {
115
+ for (const marker of PROJECT_MARKERS) {
116
+ try {
117
+ await stat(join(dirPath, marker));
118
+ return true;
119
+ }
120
+ catch {
121
+ // Marker doesn't exist, try next
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+ }
@@ -0,0 +1,54 @@
1
+ import { resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { loadConfig, ensureVapidKeys } from './config.js';
4
+ import { createServer } from './server.js';
5
+ import { PimoteSessionManager } from './session-manager.js';
6
+ import { FolderIndex } from './folder-index.js';
7
+ import { PushNotificationService } from './push-notification.js';
8
+ import { FilePushSubscriptionStore, WebPushSender, migratePushSubscriptionStore } from './push-infrastructure.js';
9
+ import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH } from './paths.js';
10
+ import { FileSessionMetadataStore } from './session-metadata.js';
11
+ export async function main(options = {}) {
12
+ let config = await loadConfig();
13
+ config = await ensureVapidKeys(config);
14
+ // Allow explicit CLI override first, then PORT env var, then config
15
+ const port = options.portOverride ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : config.port);
16
+ const folderIndex = new FolderIndex(config.roots);
17
+ // Initialize push notification service
18
+ await migratePushSubscriptionStore(LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH);
19
+ const pushStore = new FilePushSubscriptionStore(PIMOTE_PUSH_SUBSCRIPTIONS_PATH);
20
+ const pushSender = new WebPushSender(config.vapidPublicKey, config.vapidPrivateKey, config.vapidEmail || 'pimote@localhost');
21
+ const pushNotificationService = new PushNotificationService(pushSender, pushStore);
22
+ await pushNotificationService.initialize();
23
+ const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
24
+ await sessionMetadataStore.initialize();
25
+ const sessionManager = new PimoteSessionManager(config, pushNotificationService);
26
+ const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore);
27
+ // Start idle session reaping with client connectivity check
28
+ sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
29
+ await server.start(port);
30
+ console.log(`[pimote] Server listening on http://localhost:${port}`);
31
+ console.log(`[pimote] WebSocket endpoint: ws://localhost:${port}/ws`);
32
+ console.log(`[pimote] Configured roots:`);
33
+ for (const root of config.roots) {
34
+ console.log(` - ${root}`);
35
+ }
36
+ // Graceful shutdown
37
+ const shutdown = async () => {
38
+ console.log('\n[pimote] Shutting down...');
39
+ await sessionManager.dispose();
40
+ await server.close();
41
+ process.exit(0);
42
+ };
43
+ process.on('SIGINT', shutdown);
44
+ process.on('SIGTERM', shutdown);
45
+ }
46
+ function isDirectRun() {
47
+ return process.argv[1] != null && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
48
+ }
49
+ if (isDirectRun()) {
50
+ main().catch((err) => {
51
+ console.error(err instanceof Error ? err.message : err);
52
+ process.exit(1);
53
+ });
54
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Convert raw pi SDK AgentMessage objects to PimoteAgentMessage format.
3
+ * Used both for bulk message retrieval (get_messages) and for live
4
+ * message_end events so the client always receives a consistent shape.
5
+ */
6
+ export function mapAgentMessages(messages) {
7
+ return messages.map(mapAgentMessage);
8
+ }
9
+ export function mapAgentMessage(msg) {
10
+ const role = msg.role ?? 'unknown';
11
+ const content = [];
12
+ if (typeof msg.content === 'string') {
13
+ content.push({ type: 'text', text: msg.content });
14
+ }
15
+ else if (Array.isArray(msg.content)) {
16
+ for (const item of msg.content) {
17
+ switch (item.type) {
18
+ case 'text':
19
+ content.push({ type: 'text', text: item.text ?? '' });
20
+ break;
21
+ case 'thinking':
22
+ content.push({ type: 'thinking', text: item.thinking ?? '' });
23
+ break;
24
+ case 'toolCall':
25
+ content.push({
26
+ type: 'tool_call',
27
+ toolCallId: item.id,
28
+ toolName: item.name,
29
+ args: item.arguments,
30
+ });
31
+ break;
32
+ case 'image':
33
+ // Images in user messages — map as text placeholder
34
+ content.push({ type: 'text', text: '[image]' });
35
+ break;
36
+ default:
37
+ // Unknown content type — pass through as text
38
+ content.push({ type: 'text', text: item.text ?? JSON.stringify(item) });
39
+ break;
40
+ }
41
+ }
42
+ }
43
+ else if (msg.content !== undefined && msg.content !== null) {
44
+ // Unexpected content type — log and convert to text
45
+ console.warn('[message-mapper] Unexpected content type:', typeof msg.content, 'role:', role);
46
+ console.warn('[message-mapper] Content value:', msg.content);
47
+ content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
48
+ }
49
+ // Log empty content for debugging
50
+ if (content.length === 0) {
51
+ console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
52
+ }
53
+ // Handle custom messages — preserve customType and display flag for the client
54
+ if (role === 'custom') {
55
+ return { role, content, customType: msg.customType, display: msg.display ?? true };
56
+ }
57
+ // Handle tool result messages
58
+ if (role === 'toolResult') {
59
+ const resultContent = [];
60
+ if (Array.isArray(msg.content)) {
61
+ for (const item of msg.content) {
62
+ if (item.type === 'text') {
63
+ resultContent.push({ type: 'text', text: item.text ?? '' });
64
+ }
65
+ }
66
+ }
67
+ return {
68
+ role,
69
+ content: [
70
+ {
71
+ type: 'tool_result',
72
+ toolCallId: msg.toolCallId,
73
+ toolName: msg.toolName,
74
+ result: resultContent.length > 0 ? resultContent[0].text : undefined,
75
+ },
76
+ ],
77
+ };
78
+ }
79
+ return { role, content };
80
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Process a panel bus message and update the panel state map.
3
+ * - 'cards' messages replace the card list for that namespace.
4
+ * - 'clear' messages remove the namespace entirely.
5
+ */
6
+ export function applyPanelMessage(panelState, message) {
7
+ if (message.type === 'cards') {
8
+ panelState.set(message.namespace, message.cards);
9
+ }
10
+ else if (message.type === 'clear') {
11
+ panelState.delete(message.namespace);
12
+ }
13
+ }
14
+ /**
15
+ * Merge all namespaces into a flat card list for sending to the client.
16
+ * Order: namespaces in insertion order, cards within each namespace in array order.
17
+ * Skips namespaces with empty card arrays.
18
+ * Card IDs are prefixed with the namespace to prevent collisions across extensions.
19
+ */
20
+ export function getMergedPanelCards(panelState) {
21
+ const result = [];
22
+ for (const [namespace, cards] of panelState.entries()) {
23
+ for (const card of cards) {
24
+ result.push({ ...card, id: `${namespace}:${card.id}` });
25
+ }
26
+ }
27
+ return result;
28
+ }