@pimote/pimote 0.6.0 → 0.8.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 (51) hide show
  1. package/README.md +4 -1
  2. package/client/build/_app/immutable/assets/0.DmHGeVyH.css +2 -0
  3. package/client/build/_app/immutable/assets/{2.bfMycywk.css → 2.BtlPyuHL.css} +1 -1
  4. package/client/build/_app/immutable/chunks/BjlKVpoO.js +1 -0
  5. package/client/build/_app/immutable/chunks/Blm_TLGW.js +1 -0
  6. package/client/build/_app/immutable/chunks/COcpV1OD.js +1 -0
  7. package/client/build/_app/immutable/chunks/DMWd5mk8.js +1 -0
  8. package/client/build/_app/immutable/chunks/{DNqQZw5U.js → Daen0SYI.js} +2 -2
  9. package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.DW3BNxC_.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.DUfrZpFg.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DzBRsuZ_.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.y-VB1JIj.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.Bz9KycIe.js +55 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +2 -2
  17. package/server/dist/config.js +5 -2
  18. package/server/dist/event-buffer.js +9 -0
  19. package/server/dist/extension-ui-bridge.js +26 -10
  20. package/server/dist/file-references.js +123 -0
  21. package/server/dist/git-branch.js +12 -9
  22. package/server/dist/login-orchestrator.js +114 -0
  23. package/server/dist/push-infrastructure.js +13 -2
  24. package/server/dist/push-notification.js +18 -11
  25. package/server/dist/server.js +25 -2
  26. package/server/dist/session-cost.js +26 -2
  27. package/server/dist/session-manager.js +109 -6
  28. package/server/dist/static-host/gc.js +13 -0
  29. package/server/dist/static-host/http-handler.js +27 -1
  30. package/server/dist/static-host/index.js +24 -12
  31. package/server/dist/static-host/store.js +10 -1
  32. package/server/dist/static-host/tools.js +5 -1
  33. package/server/dist/voice/fsm/reducer.js +14 -2
  34. package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
  35. package/server/dist/voice/fsm/reducers/streaming.js +39 -3
  36. package/server/dist/voice/fsm/reducers/walkback.js +13 -10
  37. package/server/dist/voice/fsm/state.js +1 -1
  38. package/server/dist/voice/index.js +97 -41
  39. package/server/dist/voice/walk-back.js +94 -26
  40. package/server/dist/voice-orchestrator-boot.js +22 -5
  41. package/server/dist/voice-orchestrator.js +38 -1
  42. package/server/dist/ws-handler.js +195 -63
  43. package/shared/dist/protocol.d.ts +99 -2
  44. package/client/build/_app/immutable/assets/0.Dh2gYJ1J.css +0 -2
  45. package/client/build/_app/immutable/chunks/Czpnrh9t.js +0 -1
  46. package/client/build/_app/immutable/chunks/D1mCuOEu.js +0 -1
  47. package/client/build/_app/immutable/chunks/DHiuV2ft.js +0 -1
  48. package/client/build/_app/immutable/chunks/DegHYiTr.js +0 -1
  49. package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
  50. package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
  51. package/client/build/_app/immutable/nodes/2.CZjPJM-S.js +0 -55
@@ -12,12 +12,12 @@
12
12
  <link rel="icon" type="image/png" sizes="192x192" href="/pwa/icon-192.png" />
13
13
  <link rel="icon" type="image/png" sizes="512x512" href="/pwa/icon-512.png" />
14
14
  <link rel="apple-touch-icon" href="/pwa/icon-192.png" />
15
- <link href="/_app/immutable/entry/start.BNnDRfmt.js" rel="modulepreload">
16
- <link href="/_app/immutable/chunks/DHiuV2ft.js" rel="modulepreload">
17
- <link href="/_app/immutable/chunks/DegHYiTr.js" rel="modulepreload">
15
+ <link href="/_app/immutable/entry/start.DUfrZpFg.js" rel="modulepreload">
16
+ <link href="/_app/immutable/chunks/DMWd5mk8.js" rel="modulepreload">
17
+ <link href="/_app/immutable/chunks/COcpV1OD.js" rel="modulepreload">
18
18
  <link href="/_app/immutable/chunks/B8lQCytv.js" rel="modulepreload">
19
19
  <link href="/_app/immutable/chunks/5FogVG_p.js" rel="modulepreload">
20
- <link href="/_app/immutable/entry/app.DZYoujEP.js" rel="modulepreload">
20
+ <link href="/_app/immutable/entry/app.DW3BNxC_.js" rel="modulepreload">
21
21
  <link href="/_app/immutable/chunks/CHncfsjL.js" rel="modulepreload">
22
22
  <link href="/_app/immutable/chunks/D1hYfEew.js" rel="modulepreload">
23
23
 
@@ -26,15 +26,15 @@
26
26
  <div style="display: contents">
27
27
  <script>
28
28
  {
29
- __sveltekit_8yi2la = {
29
+ __sveltekit_113ntip = {
30
30
  base: ""
31
31
  };
32
32
 
33
33
  const element = document.currentScript.parentElement;
34
34
 
35
35
  Promise.all([
36
- import("/_app/immutable/entry/start.BNnDRfmt.js"),
37
- import("/_app/immutable/entry/app.DZYoujEP.js")
36
+ import("/_app/immutable/entry/start.DUfrZpFg.js"),
37
+ import("/_app/immutable/entry/app.DW3BNxC_.js")
38
38
  ]).then(([kit, app]) => {
39
39
  kit.start(app, element);
40
40
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimote/pimote",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Web client and embedded server for pi with multi-session browser access, streaming, and extension UI support",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,7 +73,7 @@
73
73
  "typescript-eslint": "^8.58.0"
74
74
  },
75
75
  "dependencies": {
76
- "@earendil-works/pi-coding-agent": "^0.76.0",
76
+ "@earendil-works/pi-coding-agent": "^0.79.1",
77
77
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
78
78
  "@streamparser/json": "^0.0.22",
79
79
  "patch-package": "^8.0.1",
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  import { PIMOTE_CONFIG_PATH } from './paths.js';
4
4
  export const CONFIG_PATH = PIMOTE_CONFIG_PATH;
@@ -93,6 +93,9 @@ export async function ensureVapidKeys(config) {
93
93
  existing.vapidPublicKey = keys.publicKey;
94
94
  existing.vapidPrivateKey = keys.privateKey;
95
95
  await mkdir(dirname(CONFIG_PATH), { recursive: true });
96
- await writeFile(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
96
+ // Atomic write (tmp + rename) so a crash mid-write can't corrupt the config.
97
+ const tmpPath = CONFIG_PATH + '.tmp';
98
+ await writeFile(tmpPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
99
+ await rename(tmpPath, CONFIG_PATH);
97
100
  return config;
98
101
  }
@@ -192,6 +192,15 @@ export class EventBuffer {
192
192
  error: sdkEvent.error ?? '',
193
193
  ...(sdkEvent.extensionName ? { extensionName: sdkEvent.extensionName } : {}),
194
194
  };
195
+ case 'tree_navigation_start':
196
+ return {
197
+ ...base,
198
+ type: 'tree_navigation_start',
199
+ targetId: sdkEvent.targetId ?? '',
200
+ summarizing: sdkEvent.summarizing ?? false,
201
+ };
202
+ case 'tree_navigation_end':
203
+ return { ...base, type: 'tree_navigation_end' };
195
204
  default:
196
205
  // Unknown event type — pass through as agent_start (shouldn't happen)
197
206
  return { ...base, type: 'agent_start' };
@@ -51,28 +51,44 @@ export function createExtensionUIBridge(slot, pushNotificationService, options)
51
51
  async function dialogWithTimeout(requestId, requestEvent, opts, fallback) {
52
52
  const responsePromise = waitForSlotUiResponse(slot, requestId, requestEvent);
53
53
  const racers = [responsePromise];
54
+ let timeoutHandle;
55
+ let abortListener;
56
+ const signal = opts?.signal;
54
57
  if (opts?.timeout) {
55
- racers.push(new Promise((resolve) => setTimeout(() => resolve(fallback), opts.timeout)));
58
+ racers.push(new Promise((resolve) => {
59
+ timeoutHandle = setTimeout(() => resolve(fallback), opts.timeout);
60
+ }));
56
61
  }
57
- if (opts?.signal) {
58
- if (opts.signal.aborted) {
62
+ if (signal) {
63
+ if (signal.aborted) {
59
64
  // Remove the pending entry we just created — no one will respond to it
60
65
  slot.sessionState.pendingUiResponses.delete(requestId);
61
66
  return fallback;
62
67
  }
63
68
  racers.push(new Promise((resolve) => {
64
- opts.signal.addEventListener('abort', () => resolve(fallback), { once: true });
69
+ abortListener = () => resolve(fallback);
70
+ signal.addEventListener('abort', abortListener, { once: true });
65
71
  }));
66
72
  }
67
73
  if (racers.length === 1)
68
74
  return responsePromise;
69
- const result = await Promise.race(racers);
70
- // If timeout or abort won the race, the pending entry is stale — the server
71
- // has already moved on. Remove it so it won't be replayed on reconnect.
72
- if (slot.sessionState.pendingUiResponses.has(requestId)) {
73
- slot.sessionState.pendingUiResponses.delete(requestId);
75
+ try {
76
+ const result = await Promise.race(racers);
77
+ // If timeout or abort won the race, the pending entry is stale — the server
78
+ // has already moved on. Remove it so it won't be replayed on reconnect.
79
+ if (slot.sessionState.pendingUiResponses.has(requestId)) {
80
+ slot.sessionState.pendingUiResponses.delete(requestId);
81
+ }
82
+ return result;
83
+ }
84
+ finally {
85
+ // Clear the timeout timer (keeps the event loop from staying alive / firing
86
+ // pointlessly) and detach the abort listener if the response won the race.
87
+ if (timeoutHandle !== undefined)
88
+ clearTimeout(timeoutHandle);
89
+ if (signal && abortListener)
90
+ signal.removeEventListener('abort', abortListener);
74
91
  }
75
- return result;
76
92
  }
77
93
  const ui = {
78
94
  // ---- Dialog methods (send + wait for response) ----
@@ -0,0 +1,123 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
5
+ /**
6
+ * Candidate executable names for `fd`. Debian/Ubuntu ship the binary as
7
+ * `fdfind` to avoid a name clash, so both are tried.
8
+ */
9
+ const FD_BINARY_NAMES = ['fd', 'fdfind'];
10
+ /**
11
+ * Common install directories to probe in addition to `PATH`. The server process
12
+ * is often launched with a minimal `PATH` that omits these, so an `fd` installed
13
+ * via cargo, Homebrew, or pi's own bin dir would otherwise be invisible.
14
+ */
15
+ function fdSearchDirs() {
16
+ const home = homedir();
17
+ const pathDirs = (process.env.PATH ?? '').split(delimiter).filter((d) => d.length > 0);
18
+ const extraDirs = [join(home, '.pi', 'agent', 'bin'), join(home, '.cargo', 'bin'), join(home, '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/opt/homebrew/bin'];
19
+ return [...pathDirs, ...extraDirs];
20
+ }
21
+ /**
22
+ * Resolve an `fd` executable to an absolute path, probing `PATH` and common
23
+ * install dirs for both `fd` and `fdfind`. Returns `null` when none is found.
24
+ */
25
+ export function resolveFdPath(dirs = fdSearchDirs()) {
26
+ for (const dir of dirs) {
27
+ for (const name of FD_BINARY_NAMES) {
28
+ const candidate = join(dir, name);
29
+ try {
30
+ accessSync(candidate, constants.X_OK);
31
+ return candidate;
32
+ }
33
+ catch {
34
+ // Not here; keep probing.
35
+ }
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** Cap on the number of fd results requested. */
41
+ const MAX_RESULTS = 50;
42
+ /**
43
+ * Compute `@`-file-path autocomplete suggestions for the given prefix, resolved
44
+ * against `cwd`. Returns `{ items: [], fdAvailable: false }` when `fd` is
45
+ * missing so the caller can emit a one-time warning.
46
+ */
47
+ export async function completeFileRefs(input) {
48
+ const { cwd, prefix } = input;
49
+ const fdPath = input.fdPath ?? resolveFdPath() ?? 'fd';
50
+ const runFd = input.runFd ?? defaultRunFd;
51
+ const { quoted, scope, query } = parsePrefix(prefix);
52
+ const baseDir = resolveBaseDir(scope, cwd);
53
+ const args = ['--type', 'f', '--type', 'd', '--hidden', '--follow', '--exclude', '.git', '--max-results', String(MAX_RESULTS), '--base-directory', baseDir];
54
+ if (query !== '') {
55
+ // `--` ends fd's option parsing so a query starting with `-` is treated as a
56
+ // positional pattern, not a flag (matches the TUI's walkDirectoryWithFd).
57
+ args.push('--', query);
58
+ }
59
+ const invocation = { fdPath, baseDir, query, args };
60
+ const { available, lines } = await runFd(invocation);
61
+ if (!available) {
62
+ return { items: [], fdAvailable: false };
63
+ }
64
+ const items = lines.map((line) => mapLineToItem(line, scope, quoted));
65
+ return { items, fdAvailable: true };
66
+ }
67
+ /**
68
+ * Parse the raw `@`-token into the quoting flag, the typed directory scope (the
69
+ * text up to and including the last `/`), and the fd query pattern (the trailing
70
+ * segment after the last `/`).
71
+ */
72
+ function parsePrefix(prefix) {
73
+ let rest = prefix.startsWith('@') ? prefix.slice(1) : prefix;
74
+ let quoted = false;
75
+ if (rest.startsWith('"')) {
76
+ quoted = true;
77
+ rest = rest.slice(1);
78
+ }
79
+ const lastSlash = rest.lastIndexOf('/');
80
+ if (lastSlash === -1) {
81
+ return { quoted, scope: '', query: rest };
82
+ }
83
+ return { quoted, scope: rest.slice(0, lastSlash + 1), query: rest.slice(lastSlash + 1) };
84
+ }
85
+ /** Resolve the typed scope string to an absolute search root against `cwd`. */
86
+ function resolveBaseDir(scope, cwd) {
87
+ if (scope === '') {
88
+ return cwd;
89
+ }
90
+ if (scope.startsWith('~/')) {
91
+ return resolve(homedir(), scope.slice(2));
92
+ }
93
+ if (isAbsolute(scope)) {
94
+ return resolve(scope);
95
+ }
96
+ return resolve(cwd, scope);
97
+ }
98
+ /** Map one fd output line to an inserted-token autocomplete item. */
99
+ function mapLineToItem(line, scope, quoted) {
100
+ const path = scope + line;
101
+ const needsQuote = quoted || path.includes(' ');
102
+ const value = needsQuote ? `@"${path}"` : `@${path}`;
103
+ return { value, label: line };
104
+ }
105
+ /**
106
+ * Default fd runner: spawns the real `fd` binary. On spawn `ENOENT` reports
107
+ * `available: false`; on any other failure degrades to `available: true` with no
108
+ * lines; on success splits stdout into non-empty lines.
109
+ */
110
+ const defaultRunFd = (invocation) => new Promise((resolvePromise) => {
111
+ execFile(invocation.fdPath, invocation.args, (error, stdout) => {
112
+ if (error) {
113
+ if (error.code === 'ENOENT') {
114
+ resolvePromise({ available: false, lines: [] });
115
+ return;
116
+ }
117
+ resolvePromise({ available: true, lines: [] });
118
+ return;
119
+ }
120
+ const lines = stdout.split('\n').filter((l) => l.length > 0);
121
+ resolvePromise({ available: true, lines });
122
+ });
123
+ });
@@ -1,19 +1,22 @@
1
- import { execFileSync } from 'node:child_process';
2
- /** Resolve the current git branch for a directory. Returns null if not a git repo or detached. */
3
- export function getGitBranch(cwd) {
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** Resolve the current git branch for a directory. Returns null if not a git repo or detached.
5
+ * Async (non-blocking): callers must not run this on the event loop synchronously. */
6
+ export async function getGitBranch(cwd) {
4
7
  // Guard against inherited Git env vars forcing resolution to another repo.
5
8
  const env = { ...process.env };
6
9
  delete env.GIT_DIR;
7
10
  delete env.GIT_WORK_TREE;
8
- const runGit = (args) => {
11
+ const runGit = async (args) => {
9
12
  try {
10
- const value = execFileSync('git', args, {
13
+ const { stdout } = await execFileAsync('git', args, {
11
14
  cwd,
12
15
  env,
13
16
  encoding: 'utf-8',
14
17
  timeout: 2000,
15
- stdio: ['ignore', 'pipe', 'ignore'],
16
- }).trim();
18
+ });
19
+ const value = stdout.trim();
17
20
  return value || null;
18
21
  }
19
22
  catch {
@@ -21,11 +24,11 @@ export function getGitBranch(cwd) {
21
24
  }
22
25
  };
23
26
  // Best signal for the checked-out branch (works with linked worktrees).
24
- const current = runGit(['branch', '--show-current']);
27
+ const current = await runGit(['branch', '--show-current']);
25
28
  if (current)
26
29
  return current;
27
30
  // Fallback for older Git versions / unusual setups.
28
- const abbrevRef = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
31
+ const abbrevRef = await runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
29
32
  if (!abbrevRef || abbrevRef === 'HEAD')
30
33
  return null;
31
34
  return abbrevRef;
@@ -0,0 +1,114 @@
1
+ // LoginOrchestrator — server singleton driving interactive OAuth provider login.
2
+ //
3
+ // See docs/plans/provider-login.md → "Login Orchestrator". Owns references to
4
+ // the shared pi-SDK AuthStorage + ModelRegistry. Responsibilities:
5
+ // - list OAuth providers with logged-in status
6
+ // - run a single login flow at a time (in-flight guard → "busy")
7
+ // - translate a connection-bound transport into pi's OAuthLoginCallbacks
8
+ // - on success call modelRegistry.refresh()
9
+ //
10
+ // Pure-ish and unit-testable: the AuthStorage / ModelRegistry dependencies are
11
+ // expressed as the narrow structural seams below (LoginAuthStorage /
12
+ // LoginModelRegistry), which the real pi-SDK classes satisfy. Tests inject
13
+ // in-memory fakes plus a fake LoginTransport.
14
+ /** Thrown by runLogin when a flow is already in progress. */
15
+ export class LoginBusyError extends Error {
16
+ constructor() {
17
+ super('A login flow is already in progress');
18
+ this.name = 'LoginBusyError';
19
+ }
20
+ }
21
+ export class LoginOrchestrator {
22
+ authStorage;
23
+ modelRegistry;
24
+ busy = false;
25
+ requestCounter = 0;
26
+ constructor(authStorage, modelRegistry) {
27
+ this.authStorage = authStorage;
28
+ this.modelRegistry = modelRegistry;
29
+ }
30
+ /** List OAuth providers with logged-in status (from getOAuthProviders + getAuthStatus). */
31
+ listProviders() {
32
+ return this.authStorage.getOAuthProviders().map((p) => ({
33
+ id: p.id,
34
+ name: p.name,
35
+ loggedIn: this.authStorage.getAuthStatus(p.id).configured,
36
+ }));
37
+ }
38
+ /** Whether a login flow is currently running. */
39
+ isBusy() {
40
+ return this.busy;
41
+ }
42
+ /**
43
+ * Log out from `providerId`: clear its stored credential and refresh the
44
+ * model registry so the provider's models drop out of selection. Synchronous
45
+ * on the AuthStorage side; independent of the single-flight login guard.
46
+ */
47
+ logout(providerId) {
48
+ this.authStorage.logout(providerId);
49
+ this.modelRegistry.refresh();
50
+ }
51
+ /**
52
+ * Run a login flow for `providerId`, driving the transport. Resolves when the
53
+ * flow ends; emits a terminal `done` step itself (success or failure). Throws
54
+ * LoginBusyError if a flow is already in progress.
55
+ */
56
+ async runLogin(providerId, transport) {
57
+ // Synchronous in-flight guard (before the first await) so a concurrent
58
+ // runLogin issued in the same tick rejects while the first is in flight.
59
+ if (this.busy) {
60
+ throw new LoginBusyError();
61
+ }
62
+ this.busy = true;
63
+ const providerName = this.authStorage.getOAuthProviders().find((p) => p.id === providerId)?.name ?? providerId;
64
+ const nextRequestId = () => `login-${++this.requestCounter}`;
65
+ const callbacks = {
66
+ onAuth: (info) => {
67
+ transport.emit({ kind: 'auth', url: info.url, instructions: info.instructions });
68
+ },
69
+ onDeviceCode: (info) => {
70
+ transport.emit({
71
+ kind: 'device_code',
72
+ userCode: info.userCode,
73
+ verificationUri: info.verificationUri,
74
+ expiresInSeconds: info.expiresInSeconds,
75
+ });
76
+ },
77
+ onProgress: (message) => {
78
+ transport.emit({ kind: 'progress', message });
79
+ },
80
+ onPrompt: (prompt) => transport.requestInput({
81
+ requestId: nextRequestId(),
82
+ message: prompt.message,
83
+ placeholder: prompt.placeholder,
84
+ allowEmpty: prompt.allowEmpty,
85
+ }),
86
+ onManualCodeInput: () => transport.requestInput({
87
+ requestId: nextRequestId(),
88
+ message: 'Paste the authorization code',
89
+ }),
90
+ onSelect: (prompt) => transport.requestSelect({
91
+ requestId: nextRequestId(),
92
+ message: prompt.message,
93
+ options: prompt.options,
94
+ }),
95
+ signal: transport.signal,
96
+ };
97
+ try {
98
+ await this.authStorage.login(providerId, callbacks);
99
+ this.modelRegistry.refresh();
100
+ transport.emit({ kind: 'done', success: true, providerName });
101
+ }
102
+ catch (err) {
103
+ transport.emit({
104
+ kind: 'done',
105
+ success: false,
106
+ providerName,
107
+ error: err instanceof Error ? err.message : String(err),
108
+ });
109
+ }
110
+ finally {
111
+ this.busy = false;
112
+ }
113
+ }
114
+ }
@@ -67,7 +67,18 @@ export class WebPushSender {
67
67
  webpush.setVapidDetails('mailto:' + vapidEmail, vapidPublicKey, vapidPrivateKey);
68
68
  }
69
69
  async sendNotification(subscription, payload) {
70
- const response = await webpush.sendNotification(subscription, payload);
71
- return { statusCode: response.statusCode };
70
+ try {
71
+ const response = await webpush.sendNotification(subscription, payload);
72
+ return { statusCode: response.statusCode };
73
+ }
74
+ catch (err) {
75
+ // web-push rejects with a WebPushError for any non-2xx status (including
76
+ // 404/410 for dead subscriptions). Surface the status code so callers can
77
+ // prune expired subscriptions; rethrow anything that isn't an HTTP error.
78
+ if (err instanceof webpush.WebPushError) {
79
+ return { statusCode: err.statusCode };
80
+ }
81
+ throw err;
82
+ }
72
83
  }
73
84
  }
@@ -46,21 +46,28 @@ export class PushNotificationService {
46
46
  if (this.suppressionPredicate?.(payload.sessionId)) {
47
47
  return;
48
48
  }
49
- const expiredEndpoints = [];
50
49
  const payloadStr = JSON.stringify(payload);
51
- for (const sub of this.subscriptions) {
52
- try {
53
- const result = await this.sender.sendNotification(sub, payloadStr);
54
- if (result.statusCode === 410) {
55
- expiredEndpoints.push(sub.endpoint);
50
+ // Snapshot deliberately: `this.subscriptions` is reassigned by add/remove,
51
+ // and we hold this list across awaits. Fan out in parallel rather than
52
+ // serializing behind the slowest endpoint.
53
+ const subs = this.subscriptions;
54
+ const results = await Promise.allSettled(subs.map((sub) => this.sender.sendNotification(sub, payloadStr)));
55
+ const expired = new Set();
56
+ results.forEach((result, i) => {
57
+ if (result.status === 'fulfilled') {
58
+ // 404 Not Found and 410 Gone both mean the subscription is dead.
59
+ if (result.value.statusCode === 404 || result.value.statusCode === 410) {
60
+ expired.add(subs[i].endpoint);
56
61
  }
57
62
  }
58
- catch (err) {
59
- console.warn('[PushNotificationService] Failed to send notification:', err.message ?? err);
63
+ else {
64
+ console.warn('[PushNotificationService] Failed to send notification:', result.reason?.message ?? result.reason);
60
65
  }
61
- }
62
- if (expiredEndpoints.length > 0) {
63
- this.subscriptions = this.subscriptions.filter((s) => !expiredEndpoints.includes(s.endpoint));
66
+ });
67
+ if (expired.size > 0) {
68
+ // Prune against the CURRENT array (which may have changed during the
69
+ // awaits), removing only the endpoints we just found dead.
70
+ this.subscriptions = this.subscriptions.filter((s) => !expired.has(s.endpoint));
64
71
  await this.store.save(this.subscriptions);
65
72
  }
66
73
  }
@@ -132,10 +132,26 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
132
132
  sessionManager.onGitBranchChange = (sessionId, folderPath) => {
133
133
  WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
134
134
  };
135
+ // A re-key collision is about to evict the slot holding this ID. Tell its
136
+ // owning client its session went away before the runtime is disposed.
137
+ sessionManager.onSlotEvicted = (sessionId) => {
138
+ const ownerClientId = sessionManager.getSlot(sessionId)?.connection?.connectedClientId;
139
+ if (ownerClientId)
140
+ clientRegistry.get(ownerClientId)?.sendDisplacedEvent(sessionId);
141
+ };
135
142
  const wss = new WebSocketServer({ noServer: true });
136
143
  const clientRegistry = new Map();
137
144
  httpServer.on('upgrade', (req, socket, head) => {
138
- const url = new URL(req.url ?? '', `http://${req.headers.host}`);
145
+ // Only pathname/searchParams are read, so a fixed base is sufficient — and
146
+ // avoids an uncaught `new URL` throw when a malformed Host header arrives.
147
+ let url;
148
+ try {
149
+ url = new URL(req.url ?? '/', 'http://localhost');
150
+ }
151
+ catch {
152
+ socket.destroy();
153
+ return;
154
+ }
139
155
  if (url.pathname === '/ws') {
140
156
  wss.handleUpgrade(req, socket, head, (ws) => {
141
157
  wss.emit('connection', ws, req);
@@ -146,7 +162,14 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
146
162
  }
147
163
  });
148
164
  wss.on('connection', (ws, req) => {
149
- const url = new URL(req.url ?? '', `http://${req.headers.host}`);
165
+ let url;
166
+ try {
167
+ url = new URL(req.url ?? '/', 'http://localhost');
168
+ }
169
+ catch {
170
+ ws.close();
171
+ return;
172
+ }
150
173
  const clientId = url.searchParams.get('clientId') ?? crypto.randomUUID();
151
174
  console.log(`[pimote] WebSocket client connected (clientId=${clientId})`);
152
175
  // Version check — if the client's build version doesn't match the server's,
@@ -1,9 +1,33 @@
1
1
  /**
2
2
  * Pure cost summation helper for the per-session lifetime dollar cost surfaced
3
3
  * in the pimote StatusBar. No I/O, no SDK session coupling beyond a structural
4
- * (duck-typed) view of the session branch entries.
4
+ * (duck-typed) view of the session entries.
5
5
  *
6
- * See docs/plans/cost-accumulation.md.
6
+ * Callers pass the session manager's full entry log (getEntries()), so the
7
+ * figure spans ALL branches in the session file — not just the current leaf's
8
+ * branch. Because it is a pure fold recomputed on every get_session_meta call,
9
+ * it is correct across live session switches and reload-from-disk without any
10
+ * stateful accumulator: the session manager rehydrates every entry from the
11
+ * JSONL on load, and the sum is derived fresh from those entries.
12
+ *
13
+ * What this figure CAPTURES:
14
+ * - Every assistant turn across every branch, counted exactly once (branching
15
+ * is append-only, so prior turns are never duplicated).
16
+ * - Tool-call token cost: a tool result is billed as input on the FOLLOWING
17
+ * assistant turn, so it is already inside that turn's usage.cost.total. The
18
+ * toolResult/user entries themselves carry no usage and are correctly skipped.
19
+ * - Cache-aware pricing: usage.cost.total is pi's pre-computed dollar amount
20
+ * that already prices input/output/cacheRead/cacheWrite at their own model
21
+ * rates. We sum total, so we inherit cache pricing rather than re-deriving it.
22
+ *
23
+ * What this figure EXCLUDES (cannot be recovered from the session file):
24
+ * - Compaction and branch-summary LLM calls. Those are billed API calls, but
25
+ * CompactionEntry / BranchSummaryEntry carry no usage/cost field, so their
26
+ * spend is invisible here.
27
+ * - Any assistant turn whose provider did not populate usage.cost.total (e.g. a
28
+ * model with no pricing metadata): that turn contributes 0 (silent undercount).
29
+ *
30
+ * See docs/plans/cost-accumulation.md and docs/reviews/codebase-audit.md.
7
31
  */
8
32
  /**
9
33
  * Sum usage.cost.total over assistant message entries in the branch.