@pimote/pimote 0.3.1 → 0.4.1

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 (46) hide show
  1. package/README.md +35 -24
  2. package/client/build/_app/immutable/assets/0.DzEJOwCY.css +2 -0
  3. package/client/build/_app/immutable/assets/2.BaqEkCa-.css +1 -0
  4. package/client/build/_app/immutable/chunks/{CklMSqcv.js → B-E0ZvqP.js} +1 -1
  5. package/client/build/_app/immutable/chunks/B8_nBK84.js +1 -0
  6. package/client/build/_app/immutable/chunks/BXTn4iTX.js +5 -0
  7. package/client/build/_app/immutable/chunks/_NcRSVb1.js +1 -0
  8. package/client/build/_app/immutable/chunks/wFL3btjl.js +1 -0
  9. package/client/build/_app/immutable/entry/{app.B-HFVtpC.js → app.B5rNDlzR.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.Ge2L0aip.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DncQszfo.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.CmxFYjRm.js → 1.Bm4mW4cF.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.CIMIcssN.js +54 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +3 -4
  17. package/server/dist/config.js +0 -1
  18. package/server/dist/extension-ui-bridge.js +17 -0
  19. package/server/dist/folder-index.js +1 -1
  20. package/server/dist/index.js +55 -23
  21. package/server/dist/message-mapper.js +1 -0
  22. package/server/dist/paths.js +2 -0
  23. package/server/dist/server.js +9 -1
  24. package/server/dist/session-manager.js +21 -7
  25. package/server/dist/static-host/gc.js +42 -0
  26. package/server/dist/static-host/http-handler.js +170 -0
  27. package/server/dist/static-host/index.js +121 -0
  28. package/server/dist/static-host/prompt.js +19 -0
  29. package/server/dist/static-host/registry.js +58 -0
  30. package/server/dist/static-host/store.js +44 -0
  31. package/server/dist/static-host/tools.js +120 -0
  32. package/server/dist/voice/index.js +31 -6
  33. package/server/dist/voice/speechmux-client.js +20 -3
  34. package/server/dist/voice-orchestrator-boot.js +14 -50
  35. package/server/dist/voice-orchestrator.js +10 -22
  36. package/shared/dist/protocol.d.ts +24 -1
  37. package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
  38. package/client/build/_app/immutable/assets/2.DwPXxSa-.css +0 -1
  39. package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +0 -1
  40. package/client/build/_app/immutable/chunks/CO_BwWGt.js +0 -1
  41. package/client/build/_app/immutable/chunks/D1INvMB9.js +0 -1
  42. package/client/build/_app/immutable/chunks/D1vhgXpq.js +0 -5
  43. package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +0 -1
  44. package/client/build/_app/immutable/nodes/0.CepAO4xf.js +0 -10
  45. package/client/build/_app/immutable/nodes/2.DAtqfmki.js +0 -54
  46. package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
@@ -69,7 +69,6 @@ function parseVoiceConfig(v) {
69
69
  return undefined;
70
70
  const o = v;
71
71
  return {
72
- speechmuxBinary: typeof o.speechmuxBinary === 'string' ? o.speechmuxBinary : undefined,
73
72
  speechmuxSignalUrl: typeof o.speechmuxSignalUrl === 'string' ? o.speechmuxSignalUrl : undefined,
74
73
  speechmuxLlmWsUrl: typeof o.speechmuxLlmWsUrl === 'string' ? o.speechmuxLlmWsUrl : undefined,
75
74
  };
@@ -144,9 +144,26 @@ export function createExtensionUIBridge(slot, pushNotificationService, options)
144
144
  setWorkingMessage() {
145
145
  // no-op
146
146
  },
147
+ setWorkingVisible() {
148
+ // TODO(pimote-bridge): forward to client so extensions can hide/show the working indicator row.
149
+ // Web client renders its own streaming UI; no-op is safe but loses extension control.
150
+ },
151
+ setWorkingIndicator() {
152
+ // TODO(pimote-bridge): forward custom working-indicator frames/colors to client renderer.
153
+ // Web client renders its own spinner; no-op is safe but loses extension control.
154
+ },
147
155
  setHiddenThinkingLabel() {
148
156
  // no-op
149
157
  },
158
+ addAutocompleteProvider() {
159
+ // TODO(pimote-bridge): wire extension autocomplete factory into the client autocomplete stack.
160
+ // Today the client only has built-in slash/path completion; extension providers are dropped.
161
+ },
162
+ getEditorComponent() {
163
+ // TODO(pimote-bridge): decide whether to surface a stub EditorFactory for chained extension editors.
164
+ // Web client has no TUI editor component to wrap, so undefined is the honest answer.
165
+ return undefined;
166
+ },
150
167
  setFooter() {
151
168
  // no-op
152
169
  },
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat, unlink } from 'node:fs/promises';
2
2
  import { join, basename } from 'node:path';
3
- import { SessionManager } from '@mariozechner/pi-coding-agent';
3
+ import { SessionManager } from '@earendil-works/pi-coding-agent';
4
4
  /** Project marker files/directories that identify a folder as a project. */
5
5
  const PROJECT_MARKERS = ['.git', 'package.json'];
6
6
  /**
@@ -6,9 +6,10 @@ import { PimoteSessionManager } from './session-manager.js';
6
6
  import { FolderIndex } from './folder-index.js';
7
7
  import { PushNotificationService } from './push-notification.js';
8
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';
9
+ import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH, PIMOTE_STATIC_HOST_DIR } from './paths.js';
10
10
  import { FileSessionMetadataStore } from './session-metadata.js';
11
11
  import { buildVoiceOrchestrator } from './voice-orchestrator-boot.js';
12
+ import { InMemoryStaticHostRegistry, FileStaticHostStore, gcStaticHostStore, createStaticHostExtension } from './static-host/index.js';
12
13
  export async function main(options = {}) {
13
14
  let config = await loadConfig();
14
15
  config = await ensureVapidKeys(config);
@@ -23,7 +24,33 @@ export async function main(options = {}) {
23
24
  await pushNotificationService.initialize();
24
25
  const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
25
26
  await sessionMetadataStore.initialize();
26
- const sessionManager = new PimoteSessionManager(config, pushNotificationService);
27
+ // Static-host bootstrap: GC orphan persistence files, then construct the
28
+ // registry/store/factory singletons shared by the session manager and the
29
+ // HTTP route handler. The registry is process-lifetime; sessions register
30
+ // and unregister against it as they load and shut down.
31
+ let validSessionIds = new Set();
32
+ try {
33
+ const folders = await folderIndex.scan();
34
+ for (const folder of folders) {
35
+ const records = await folderIndex.listSessionRecords(folder.path);
36
+ for (const rec of records)
37
+ validSessionIds.add(rec.id);
38
+ }
39
+ }
40
+ catch (err) {
41
+ // Critical: do NOT run GC with an empty allow-list — that would delete
42
+ // every persisted bundle on a transient I/O hiccup at boot. Skip the
43
+ // sweep entirely and let the next clean boot reclaim orphans.
44
+ console.warn('[pimote] static-host GC: failed to enumerate sessions, skipping sweep this boot', err);
45
+ validSessionIds = null;
46
+ }
47
+ if (validSessionIds) {
48
+ await gcStaticHostStore({ storeDir: PIMOTE_STATIC_HOST_DIR, validSessionIds });
49
+ }
50
+ const staticHostRegistry = new InMemoryStaticHostRegistry();
51
+ const staticHostStore = new FileStaticHostStore(PIMOTE_STATIC_HOST_DIR);
52
+ const staticHostFactory = createStaticHostExtension({ registry: staticHostRegistry, store: staticHostStore });
53
+ const sessionManager = new PimoteSessionManager(config, pushNotificationService, { staticHostFactory });
27
54
  // Build the voice orchestrator before createServer so each WsHandler can be
28
55
  // handed a reference. The orchestrator needs a client-registry lookup, but
29
56
  // the real registry is created inside createServer below — so we hand it a
@@ -37,27 +64,32 @@ export async function main(options = {}) {
37
64
  get: (clientId) => clientRegistryRef.current.get(clientId),
38
65
  },
39
66
  });
40
- const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot.orchestrator);
67
+ if (!voiceBoot) {
68
+ console.log('[voice] dormant: voice config absent (set voice.speechmuxSignalUrl and voice.speechmuxLlmWsUrl to enable)');
69
+ }
70
+ const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot?.orchestrator, staticHostRegistry);
41
71
  clientRegistryRef.current = server.clientRegistry;
42
- // Suppress push notifications for sessions currently owned by a voice call.
43
- // The user is on the line — we don't need to ping their phone for idle
44
- // signals or extension UI prompts. Pushes resume automatically once the
45
- // call ends and `isCallActive` flips back to false.
46
- pushNotificationService.setSuppressionPredicate((sessionId) => voiceBoot.orchestrator.isCallActive(sessionId));
47
- // Tear down orchestrator bookkeeping when a session is being closed (idle
48
- // reap, explicit close). Emits call_ended{server_ended} to the owner.
49
- sessionManager.onBeforeSessionClose = async (sessionId) => {
50
- if (!voiceBoot.orchestrator.isCallActive(sessionId))
51
- return;
52
- const slot = sessionManager.getSlot(sessionId);
53
- const ownerClientId = slot?.connection?.connectedClientId;
54
- await voiceBoot.orchestrator.endCall({ sessionId, reason: 'server_ended' });
55
- if (ownerClientId) {
56
- const handler = server.clientRegistry.get(ownerClientId);
57
- handler?.sendCallEndedEvent(sessionId, 'server_ended');
58
- }
59
- };
60
- await voiceBoot.orchestrator.start();
72
+ if (voiceBoot) {
73
+ const orchestrator = voiceBoot.orchestrator;
74
+ // Suppress push notifications for sessions currently owned by a voice call.
75
+ // The user is on the line — we don't need to ping their phone for idle
76
+ // signals or extension UI prompts. Pushes resume automatically once the
77
+ // call ends and `isCallActive` flips back to false.
78
+ pushNotificationService.setSuppressionPredicate((sessionId) => orchestrator.isCallActive(sessionId));
79
+ // Tear down orchestrator bookkeeping when a session is being closed (idle
80
+ // reap, explicit close). Emits call_ended{server_ended} to the owner.
81
+ sessionManager.onBeforeSessionClose = async (sessionId) => {
82
+ if (!orchestrator.isCallActive(sessionId))
83
+ return;
84
+ const slot = sessionManager.getSlot(sessionId);
85
+ const ownerClientId = slot?.connection?.connectedClientId;
86
+ await orchestrator.endCall({ sessionId, reason: 'server_ended' });
87
+ if (ownerClientId) {
88
+ const handler = server.clientRegistry.get(ownerClientId);
89
+ handler?.sendCallEndedEvent(sessionId, 'server_ended');
90
+ }
91
+ };
92
+ }
61
93
  // Start idle session reaping with client connectivity check
62
94
  sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
63
95
  await server.start(port);
@@ -70,7 +102,7 @@ export async function main(options = {}) {
70
102
  // Graceful shutdown
71
103
  const shutdown = async () => {
72
104
  console.log('\n[pimote] Shutting down...');
73
- await voiceBoot.shutdown();
105
+ await voiceBoot?.shutdown();
74
106
  await sessionManager.dispose();
75
107
  await server.close();
76
108
  process.exit(0);
@@ -171,5 +171,6 @@ export function mapAgentMessage(msg) {
171
171
  content,
172
172
  ...(msg.id ? { entryId: msg.id } : {}),
173
173
  ...(aborted ? { aborted: true } : {}),
174
+ ...(role === 'assistant' && typeof msg.errorMessage === 'string' ? { errorMessage: msg.errorMessage } : {}),
174
175
  };
175
176
  }
@@ -11,4 +11,6 @@ export const PIMOTE_STATE_DIR = join(getXdgStateHome(), 'pimote');
11
11
  export const PIMOTE_CONFIG_PATH = join(PIMOTE_CONFIG_DIR, 'config.json');
12
12
  export const PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_STATE_DIR, 'push-subscriptions.json');
13
13
  export const PIMOTE_SESSION_METADATA_PATH = join(PIMOTE_STATE_DIR, 'session-metadata.json');
14
+ /** Directory holding per-session static-host persistence files (`<sessionId>.json`). */
15
+ export const PIMOTE_STATIC_HOST_DIR = join(PIMOTE_STATE_DIR, 'static-host');
14
16
  export const LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_CONFIG_DIR, 'push-subscriptions.json');
@@ -4,6 +4,7 @@ import { join, extname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { WebSocketServer } from 'ws';
6
6
  import { WsHandler } from './ws-handler.js';
7
+ import { serveStaticHostRoute } from './static-host/index.js';
7
8
  import crypto from 'node:crypto';
8
9
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
9
10
  const CLIENT_DIR = process.env.CLIENT_DIR || join(__dirname, '..', '..', 'client', 'build');
@@ -79,7 +80,7 @@ async function serveFallback(res) {
79
80
  res.end(JSON.stringify({ error: 'not found' }));
80
81
  }
81
82
  }
82
- export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
83
+ export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator, staticHostRegistry) {
83
84
  const clientVersion = await loadClientVersion();
84
85
  if (clientVersion) {
85
86
  console.log(`[pimote] Client build version: ${clientVersion}`);
@@ -106,6 +107,13 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
106
107
  if (served)
107
108
  return;
108
109
  }
110
+ // 3b. Static-host route — /s/<slug>/* serves agent-registered bundles.
111
+ // Unknown slugs return 404 from the handler and do NOT fall through to the SPA.
112
+ if (req.method === 'GET' || req.method === 'HEAD') {
113
+ const handled = await serveStaticHostRoute(req, res, staticHostRegistry);
114
+ if (handled)
115
+ return;
116
+ }
109
117
  // 4. SPA fallback — serve index.html for unmatched GET routes
110
118
  if (req.method === 'GET') {
111
119
  await serveFallback(res);
@@ -1,4 +1,4 @@
1
- import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
1
+ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@earendil-works/pi-coding-agent';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
4
  import { getGitBranch } from './git-branch.js';
@@ -119,7 +119,13 @@ function setupSlotPanelListeners(eventBus, state, sessionId, sendEvent) {
119
119
  applyPanelMessage(state.panelState, data);
120
120
  scheduleSlotPanelPush(state, sessionId, sendEvent);
121
121
  });
122
- return [unsub1, unsub2];
122
+ const unsub3 = eventBus.on('pimote:navigate', (data) => {
123
+ const url = data?.url;
124
+ if (typeof url !== 'string' || url.length === 0)
125
+ return;
126
+ sendEvent({ type: 'pimote_navigate', sessionId, url });
127
+ });
128
+ return [unsub1, unsub2, unsub3];
123
129
  }
124
130
  /** Schedule a throttled panel push (~200ms) for a SessionState. */
125
131
  function scheduleSlotPanelPush(state, sessionId, sendEvent) {
@@ -147,19 +153,25 @@ export class PimoteSessionManager {
147
153
  * reap, explicit close). Consumers use this to drop external bookkeeping
148
154
  * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
149
155
  onBeforeSessionClose;
150
- constructor(config, pushNotificationService) {
156
+ staticHostFactory;
157
+ constructor(config, pushNotificationService, options = {}) {
151
158
  this.config = config;
152
159
  this.pushNotificationService = pushNotificationService;
153
160
  this.authStorage = AuthStorage.create();
154
161
  this.modelRegistry = ModelRegistry.create(this.authStorage);
162
+ this.staticHostFactory = options.staticHostFactory;
155
163
  }
156
164
  /**
157
165
  * Build the voice extension factory for this server's config, if possible.
158
- * Returns undefined (and logs a warning) when neither voice-specific model
159
- * refs nor fallback defaultProvider/defaultModel are configured — existing
160
- * non-voice deployments continue to work unchanged.
166
+ * Returns undefined when voice is not configured (no speechmux URLs) or
167
+ * when neither voice-specific model refs nor fallback defaultProvider/
168
+ * defaultModel are configured. Non-voice deployments continue to work
169
+ * unchanged — sessions simply don't load `@pimote/voice` at all.
161
170
  */
162
171
  buildVoiceExtensionFactory() {
172
+ if (!this.config.voice?.speechmuxSignalUrl || !this.config.voice?.speechmuxLlmWsUrl) {
173
+ return undefined;
174
+ }
163
175
  const interpreter = this.config.defaultInterpreterModel ??
164
176
  (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
165
177
  const worker = this.config.defaultWorkerModel ??
@@ -180,6 +192,8 @@ export class PimoteSessionManager {
180
192
  const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
181
193
  const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
182
194
  const voiceExtensionFactory = this.buildVoiceExtensionFactory();
195
+ const staticHostFactory = this.staticHostFactory;
196
+ const extensionFactories = [...(voiceExtensionFactory ? [voiceExtensionFactory] : []), ...(staticHostFactory ? [staticHostFactory] : [])];
183
197
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
184
198
  const eventBus = createEventBus();
185
199
  eventBusRef.current = eventBus;
@@ -190,7 +204,7 @@ export class PimoteSessionManager {
190
204
  modelRegistry: sharedModelRegistry,
191
205
  resourceLoaderOptions: {
192
206
  eventBus,
193
- ...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
207
+ ...(extensionFactories.length ? { extensionFactories } : {}),
194
208
  },
195
209
  });
196
210
  return {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Boot-time garbage collection of the per-session static-host persistence dir.
3
+ *
4
+ * Reads `storeDir`, deletes any `<sessionId>.json` whose sessionId is not in
5
+ * `validSessionIds`. The directory not existing is not an error (returns OK
6
+ * without doing anything). Has no knowledge of the registry, the extension,
7
+ * or the HTTP layer.
8
+ *
9
+ * Called from `server/src/index.ts` after `FolderIndex` initialisation and
10
+ * before `server.start()` so the HTTP route never sees a stale slug pointing
11
+ * at a folder that the agent has long since deleted.
12
+ */
13
+ import { readdir, unlink } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ export async function gcStaticHostStore(args) {
16
+ const { storeDir, validSessionIds } = args;
17
+ let entries;
18
+ try {
19
+ entries = await readdir(storeDir);
20
+ }
21
+ catch (err) {
22
+ if (err.code === 'ENOENT')
23
+ return;
24
+ throw err;
25
+ }
26
+ const suffix = '.json';
27
+ for (const name of entries) {
28
+ if (!name.endsWith(suffix))
29
+ continue;
30
+ const sessionId = name.slice(0, -suffix.length);
31
+ if (validSessionIds.has(sessionId))
32
+ continue;
33
+ try {
34
+ await unlink(join(storeDir, name));
35
+ }
36
+ catch (err) {
37
+ if (err.code === 'ENOENT')
38
+ continue;
39
+ throw err;
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,170 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const MIME_TYPES = {
5
+ '.html': 'text/html; charset=utf-8',
6
+ '.htm': 'text/html; charset=utf-8',
7
+ '.js': 'application/javascript; charset=utf-8',
8
+ '.mjs': 'application/javascript; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.json': 'application/json; charset=utf-8',
11
+ '.txt': 'text/plain; charset=utf-8',
12
+ '.md': 'text/markdown; charset=utf-8',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.ico': 'image/x-icon',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ '.ttf': 'font/ttf',
23
+ '.map': 'application/json; charset=utf-8',
24
+ };
25
+ // Match `/s/<slug>/<remainder>` — the trailing slash after the slug is
26
+ // REQUIRED. Without it, browsers resolve relative asset URLs against `/s/`
27
+ // instead of `/s/<slug>/` and every asset 404s. `/s/<slug>` (no slash) is
28
+ // handled separately below with a 301 redirect to `/s/<slug>/`.
29
+ const PREFIX_RE = /^\/s\/([a-z0-9-]+)\/(.*)$/;
30
+ const PREFIX_NO_SLASH_RE = /^\/s\/([a-z0-9-]+)$/;
31
+ function send404(res) {
32
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
33
+ res.end('Not Found');
34
+ }
35
+ /**
36
+ * HTTP handler for the `/s/<slug>/*` route prefix.
37
+ *
38
+ * Behaviour:
39
+ * - Path does not match `/s/<slug>/...` => returns `false` (caller falls
40
+ * through to the SPA fallback).
41
+ * - Slug not in the registry => 404, returns `true` (does NOT fall through).
42
+ * - Resolved path escapes the registered folder (e.g. `..` traversal) => 404.
43
+ * - Resolved path is a directory => append `index.html`.
44
+ * - File missing => 404. File present => 200, content-type by extension,
45
+ * streamed body, no-cache response headers.
46
+ *
47
+ * Returns `true` if the request was handled (any 2xx/4xx response written),
48
+ * `false` only on prefix mismatch.
49
+ */
50
+ export async function serveStaticHostRoute(req, res, registry) {
51
+ if (req.method !== 'GET' && req.method !== 'HEAD')
52
+ return false;
53
+ let pathname;
54
+ try {
55
+ pathname = new URL(req.url ?? '', 'http://x').pathname;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ // `/s/<slug>` (no trailing slash) — redirect to the canonical form so the
61
+ // browser resolves relative asset URLs against `/s/<slug>/`.
62
+ const noSlashMatch = PREFIX_NO_SLASH_RE.exec(pathname);
63
+ if (noSlashMatch) {
64
+ const [, slug] = noSlashMatch;
65
+ if (!registry.lookup(slug)) {
66
+ send404(res);
67
+ return true;
68
+ }
69
+ res.writeHead(301, { Location: `/s/${slug}/`, 'Cache-Control': 'no-cache, no-store, must-revalidate' });
70
+ res.end();
71
+ return true;
72
+ }
73
+ const m = PREFIX_RE.exec(pathname);
74
+ if (!m)
75
+ return false;
76
+ const [, slug, rawRemainder = ''] = m;
77
+ const reg = registry.lookup(slug);
78
+ if (!reg) {
79
+ send404(res);
80
+ return true;
81
+ }
82
+ let decoded;
83
+ try {
84
+ decoded = decodeURIComponent(rawRemainder);
85
+ }
86
+ catch {
87
+ send404(res);
88
+ return true;
89
+ }
90
+ // Defence-in-depth: reject any decoded path containing backslash, NUL,
91
+ // or `..` segments before resolving.
92
+ if (decoded.includes('\\') || decoded.includes('\u0000')) {
93
+ send404(res);
94
+ return true;
95
+ }
96
+ const segments = decoded.split('/');
97
+ if (segments.some((s) => s === '..')) {
98
+ send404(res);
99
+ return true;
100
+ }
101
+ const folderPath = reg.folderPath;
102
+ let resolved = path.resolve(folderPath, decoded);
103
+ if (resolved !== folderPath && !resolved.startsWith(folderPath + path.sep)) {
104
+ send404(res);
105
+ return true;
106
+ }
107
+ let st;
108
+ try {
109
+ st = await stat(resolved);
110
+ }
111
+ catch {
112
+ send404(res);
113
+ return true;
114
+ }
115
+ if (st.isDirectory()) {
116
+ resolved = path.join(resolved, 'index.html');
117
+ try {
118
+ st = await stat(resolved);
119
+ }
120
+ catch {
121
+ send404(res);
122
+ return true;
123
+ }
124
+ }
125
+ if (!st.isFile()) {
126
+ send404(res);
127
+ return true;
128
+ }
129
+ const ext = path.extname(resolved).toLowerCase();
130
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
131
+ res.writeHead(200, {
132
+ 'Content-Type': mime,
133
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
134
+ });
135
+ if (req.method === 'HEAD') {
136
+ res.end();
137
+ return true;
138
+ }
139
+ // Stream errors AFTER `writeHead(200)` cannot be turned into a 5xx — headers
140
+ // are already on the wire. Destroy the response and resolve cleanly so the
141
+ // rejection does not bubble out of the async request handler as an
142
+ // unhandled rejection.
143
+ await new Promise((resolvePromise) => {
144
+ const stream = createReadStream(resolved);
145
+ let settled = false;
146
+ const settle = () => {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ resolvePromise();
151
+ };
152
+ stream.on('error', (err) => {
153
+ console.warn('[static-host] stream error while serving', resolved, err);
154
+ try {
155
+ res.destroy();
156
+ }
157
+ catch {
158
+ // ignore
159
+ }
160
+ settle();
161
+ });
162
+ stream.on('end', settle);
163
+ res.on('close', () => {
164
+ stream.destroy();
165
+ settle();
166
+ });
167
+ stream.pipe(res);
168
+ });
169
+ return true;
170
+ }
@@ -0,0 +1,121 @@
1
+ import { Type } from 'typebox';
2
+ import { executeRegisterTool, executeRemoveTool } from './tools.js';
3
+ import { STATIC_HOST_TOOL_DESCRIPTION } from './prompt.js';
4
+ export { InMemoryStaticHostRegistry } from './registry.js';
5
+ export { FileStaticHostStore } from './store.js';
6
+ export { serveStaticHostRoute } from './http-handler.js';
7
+ export { gcStaticHostStore } from './gc.js';
8
+ /**
9
+ * Build the pi `ExtensionFactory` for the static-host extension.
10
+ *
11
+ * The returned factory is threaded into every pi session via
12
+ * `resourceLoaderOptions.extensionFactories`. It captures `registry` and
13
+ * `store` by closure and resolves the per-session `sessionId` lazily through
14
+ * the `ctx.sessionManager.getSessionId()` available on event handlers (the pi
15
+ * `ExtensionFactory` itself receives only `ExtensionAPI`, not the sessionId).
16
+ *
17
+ * Lifecycle for one session S:
18
+ * - First handler invocation (`session_start`): reads
19
+ * `${storeDir}/${S}.json` if present, replays each entry into the registry,
20
+ * emits a panel snapshot.
21
+ * - Tools (`pimote_static_host`, `pimote_static_host_remove`): update the
22
+ * in-memory list, atomically rewrite the file, update the registry,
23
+ * re-emit the panel snapshot.
24
+ * - `session_shutdown` => `registry.unregisterAllForSession(S)`. The file
25
+ * stays on disk for the next session load.
26
+ */
27
+ export function createStaticHostExtension(opts) {
28
+ const { registry, store } = opts;
29
+ function buildCardsFor(sessionId) {
30
+ return registry.listForSession(sessionId).map((entry) => {
31
+ const card = {
32
+ id: `static-host:${entry.slug}`,
33
+ header: {
34
+ title: entry.cardMetadata.title,
35
+ ...(entry.cardMetadata.tag !== undefined ? { tag: entry.cardMetadata.tag } : {}),
36
+ },
37
+ href: `/s/${entry.slug}/`,
38
+ ...(entry.cardMetadata.color !== undefined ? { color: entry.cardMetadata.color } : {}),
39
+ };
40
+ return card;
41
+ });
42
+ }
43
+ function emitPanelCards(pi, sessionId) {
44
+ const cards = buildCardsFor(sessionId);
45
+ pi.events.emit('pimote:panels', { type: 'cards', namespace: 'static-host', cards });
46
+ }
47
+ function emitNavigate(pi, url) {
48
+ pi.events.emit('pimote:navigate', { url });
49
+ }
50
+ function toolDeps(pi, sessionId) {
51
+ return {
52
+ registry,
53
+ store,
54
+ sessionId,
55
+ emitPanelCards: () => emitPanelCards(pi, sessionId),
56
+ emitNavigate: (url) => emitNavigate(pi, url),
57
+ };
58
+ }
59
+ return (pi) => {
60
+ pi.registerTool({
61
+ name: 'pimote_static_host',
62
+ label: 'Host static bundle',
63
+ description: STATIC_HOST_TOOL_DESCRIPTION,
64
+ parameters: Type.Object({
65
+ slug: Type.String({ description: 'Short URL slug, lowercase [a-z0-9-]+ with no leading/trailing dash.' }),
66
+ folder: Type.String({ description: 'Absolute path to the folder containing the bundle (must contain index.html).' }),
67
+ title: Type.String({ description: 'Title displayed on the panel card.' }),
68
+ tag: Type.Optional(Type.String({ description: 'Optional short tag shown next to the title.' })),
69
+ color: Type.Optional(Type.Union([Type.Literal('accent'), Type.Literal('success'), Type.Literal('warning'), Type.Literal('error'), Type.Literal('muted')], {
70
+ description: 'Optional card color.',
71
+ })),
72
+ }),
73
+ execute: async (_callId, input, _abort, _meta, ctx) => {
74
+ const sessionId = ctx.sessionManager.getSessionId();
75
+ const out = await executeRegisterTool(input, toolDeps(pi, sessionId));
76
+ return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
77
+ },
78
+ });
79
+ pi.registerTool({
80
+ name: 'pimote_static_host_remove',
81
+ label: 'Remove hosted bundle',
82
+ description: 'Unregister a previously hosted static bundle by slug.',
83
+ parameters: Type.Object({
84
+ slug: Type.String({ description: 'Slug of the bundle to remove.' }),
85
+ }),
86
+ execute: async (_callId, input, _abort, _meta, ctx) => {
87
+ const sessionId = ctx.sessionManager.getSessionId();
88
+ const out = await executeRemoveTool(input, toolDeps(pi, sessionId));
89
+ return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
90
+ },
91
+ });
92
+ pi.on('session_start', async (_ev, ctx) => {
93
+ const sessionId = ctx.sessionManager.getSessionId();
94
+ const file = await store.read(sessionId);
95
+ if (!file)
96
+ return;
97
+ for (const entry of file.entries) {
98
+ try {
99
+ registry.register({
100
+ slug: entry.slug,
101
+ folderPath: entry.folderPath,
102
+ sessionId,
103
+ cardMetadata: entry.cardMetadata,
104
+ });
105
+ }
106
+ catch (err) {
107
+ // Defensive: a slug conflict (e.g. two sessions persisted the same
108
+ // slug, or another session reloaded earlier this boot) must not
109
+ // abort the whole replay loop and leave the session partially
110
+ // loaded. Skip the conflicting entry and continue.
111
+ console.warn(`[static-host] session_start: skipping persisted entry ${entry.slug} for session ${sessionId}`, err);
112
+ }
113
+ }
114
+ emitPanelCards(pi, sessionId);
115
+ });
116
+ pi.on('session_shutdown', async (_ev, ctx) => {
117
+ const sessionId = ctx.sessionManager.getSessionId();
118
+ registry.unregisterAllForSession(sessionId);
119
+ });
120
+ };
121
+ }
@@ -0,0 +1,19 @@
1
+ // Description text attached to the `pimote_static_host` tool. The string
2
+ // is visible to the model in the system prompt's tool listing and must
3
+ // substantively cover the two primary use cases, the mandatory responsive-
4
+ // layout rule (with explicit mobile + desktop breakpoints), the no-secrets
5
+ // rule, and a brief workflow note. See `docs/plans/static-resources.md`.
6
+ export const STATIC_HOST_TOOL_DESCRIPTION = [
7
+ "Host a static HTML/asset bundle from a local folder so the user can view it in their browser. Returns a URL and creates a tappable card in the user's session pointing at the bundle.",
8
+ '',
9
+ '**When to use this:**',
10
+ '',
11
+ '1. **On-the-fly reports and visualisations.** Reach for this whenever you need to present information that would benefit from richer formatting than plain markdown — charts, comparison tables across many dimensions, syntax-highlighted code walkthroughs, before/after diffs, navigable trees of nested structures. A well-designed HTML report is much easier to read and navigate than a wall of markdown in the chat.',
12
+ '2. **Ad-hoc interactive tools.** Small single-purpose tools the user can play with for a little while — calculators, form-driven explorers, lightweight UIs that call external APIs via embedded JavaScript, in-memory data playgrounds, anything where interactivity adds value beyond static text. The bundle is real same-origin JS; it can fetch, do DOM things, persist to localStorage, whatever. Use this when the user needs a temporary tool tailored to the task at hand rather than a hand-rolled answer.',
13
+ '',
14
+ '**Mandatory: responsive, mobile-and-desktop layout.** Always design the bundle to work equally well on both desktop and mobile form factors. The user may view the same report on either device — even the same report on both. Use fluid layouts, relative units, and media queries. Mentally test your layout at ~360px wide and ~1440px wide before considering it done.',
15
+ '',
16
+ '**No secrets in the bundle.** Bundle files are served verbatim to the browser. Do not embed API keys, tokens, or any other credentials, and do not build bundles that depend on calling services that require them — there is no secret-management story for static-hosted bundles. Stick to public endpoints, in-memory state, and APIs that work without auth.',
17
+ '',
18
+ "**Workflow.** Generate the bundle (with at minimum an `index.html`) in a folder, then call this tool with the folder's absolute path, a short descriptive slug, and a title for the card. The user sees a card in their session; tapping it navigates them to the bundle, and browser-back returns them to the main pimote UI.",
19
+ ].join('\n');