@pimote/pimote 0.6.0 → 0.7.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 (50) hide show
  1. package/README.md +4 -1
  2. package/client/build/_app/immutable/assets/0.-er3OUWm.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/{DNqQZw5U.js → B1ItOytB.js} +2 -2
  5. package/client/build/_app/immutable/chunks/BiEvVL3P.js +1 -0
  6. package/client/build/_app/immutable/chunks/D8SptH3Y.js +1 -0
  7. package/client/build/_app/immutable/chunks/S8e8sMop.js +1 -0
  8. package/client/build/_app/immutable/chunks/{DHiuV2ft.js → b9CWRTHL.js} +1 -1
  9. package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.agj-hcVA.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.NVZAE6Px.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DweM6Pbc.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.owr_UHNy.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.CQNU1AJj.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 +105 -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 +190 -63
  43. package/shared/dist/protocol.d.ts +91 -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/DegHYiTr.js +0 -1
  48. package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
  49. package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
  50. package/client/build/_app/immutable/nodes/2.CZjPJM-S.js +0 -55
@@ -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,105 @@
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
+ * Run a login flow for `providerId`, driving the transport. Resolves when the
44
+ * flow ends; emits a terminal `done` step itself (success or failure). Throws
45
+ * LoginBusyError if a flow is already in progress.
46
+ */
47
+ async runLogin(providerId, transport) {
48
+ // Synchronous in-flight guard (before the first await) so a concurrent
49
+ // runLogin issued in the same tick rejects while the first is in flight.
50
+ if (this.busy) {
51
+ throw new LoginBusyError();
52
+ }
53
+ this.busy = true;
54
+ const providerName = this.authStorage.getOAuthProviders().find((p) => p.id === providerId)?.name ?? providerId;
55
+ const nextRequestId = () => `login-${++this.requestCounter}`;
56
+ const callbacks = {
57
+ onAuth: (info) => {
58
+ transport.emit({ kind: 'auth', url: info.url, instructions: info.instructions });
59
+ },
60
+ onDeviceCode: (info) => {
61
+ transport.emit({
62
+ kind: 'device_code',
63
+ userCode: info.userCode,
64
+ verificationUri: info.verificationUri,
65
+ expiresInSeconds: info.expiresInSeconds,
66
+ });
67
+ },
68
+ onProgress: (message) => {
69
+ transport.emit({ kind: 'progress', message });
70
+ },
71
+ onPrompt: (prompt) => transport.requestInput({
72
+ requestId: nextRequestId(),
73
+ message: prompt.message,
74
+ placeholder: prompt.placeholder,
75
+ allowEmpty: prompt.allowEmpty,
76
+ }),
77
+ onManualCodeInput: () => transport.requestInput({
78
+ requestId: nextRequestId(),
79
+ message: 'Paste the authorization code',
80
+ }),
81
+ onSelect: (prompt) => transport.requestSelect({
82
+ requestId: nextRequestId(),
83
+ message: prompt.message,
84
+ options: prompt.options,
85
+ }),
86
+ signal: transport.signal,
87
+ };
88
+ try {
89
+ await this.authStorage.login(providerId, callbacks);
90
+ this.modelRegistry.refresh();
91
+ transport.emit({ kind: 'done', success: true, providerName });
92
+ }
93
+ catch (err) {
94
+ transport.emit({
95
+ kind: 'done',
96
+ success: false,
97
+ providerName,
98
+ error: err instanceof Error ? err.message : String(err),
99
+ });
100
+ }
101
+ finally {
102
+ this.busy = false;
103
+ }
104
+ }
105
+ }
@@ -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.
@@ -2,6 +2,7 @@ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessi
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';
5
+ import { LoginOrchestrator } from './login-orchestrator.js';
5
6
  import { createVoiceExtension } from './voice/index.js';
6
7
  import { autoDrainOnAbort } from './auto-drain-on-abort.js';
7
8
  // ---- Slot-based helpers (operate on ManagedSlot) ----
@@ -143,12 +144,33 @@ function scheduleSlotPanelPush(state, sessionId, sendEvent) {
143
144
  sendEvent({ type: 'panel_update', sessionId, cards });
144
145
  }, 200);
145
146
  }
147
+ /**
148
+ * Coalesce concurrent async operations keyed by `key`: while one is in flight,
149
+ * callers passing the same key share its promise instead of starting a second
150
+ * run. The map entry is cleared once the operation settles, so a later call
151
+ * with the same key runs fresh.
152
+ */
153
+ export async function singleFlight(map, key, run) {
154
+ const inflight = map.get(key);
155
+ if (inflight)
156
+ return inflight;
157
+ const p = run().finally(() => {
158
+ map.delete(key);
159
+ });
160
+ map.set(key, p);
161
+ return p;
162
+ }
146
163
  export class PimoteSessionManager {
147
164
  config;
148
165
  pushNotificationService;
149
166
  authStorage;
150
167
  modelRegistry;
168
+ loginOrchestrator;
151
169
  sessions = new Map();
170
+ /** In-flight `openSession` promises keyed by session file path, so two
171
+ * concurrent opens of the same on-disk session share one runtime instead
172
+ * of building (and leaking) a second one over the same file. */
173
+ inFlightOpens = new Map();
152
174
  idleCheckHandle = null;
153
175
  gitBranchCheckHandle = null;
154
176
  lastKnownGitBranchBySession = new Map();
@@ -159,12 +181,18 @@ export class PimoteSessionManager {
159
181
  * reap, explicit close). Consumers use this to drop external bookkeeping
160
182
  * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
161
183
  onBeforeSessionClose;
184
+ /** Fired when a re-key collision evicts the slot currently holding the target
185
+ * session ID, BEFORE that slot is closed. Consumers notify the evicted slot's
186
+ * owning client (e.g. a `session_closed`/displaced event) while it is still
187
+ * addressable via getSlot. */
188
+ onSlotEvicted;
162
189
  staticHostFactory;
163
190
  constructor(config, pushNotificationService, options = {}) {
164
191
  this.config = config;
165
192
  this.pushNotificationService = pushNotificationService;
166
193
  this.authStorage = AuthStorage.create();
167
194
  this.modelRegistry = ModelRegistry.create(this.authStorage);
195
+ this.loginOrchestrator = new LoginOrchestrator(this.authStorage, this.modelRegistry);
168
196
  this.staticHostFactory = options.staticHostFactory;
169
197
  }
170
198
  /**
@@ -191,7 +219,37 @@ export class PimoteSessionManager {
191
219
  defaultWorkerModel: worker,
192
220
  });
193
221
  }
222
+ /**
223
+ * Open (or reopen) a session, returning its id.
224
+ *
225
+ * For an existing on-disk session (`sessionFilePath` provided), this guards
226
+ * against ever binding a SECOND pi runtime to the same session file: it
227
+ * returns the already-open session's id when it's live in memory, and
228
+ * coalesces concurrent opens of the same file into a single runtime. Without
229
+ * this, a reconnect double-fire or two devices opening the same session race
230
+ * between the (miss) existence check and the eventual `sessions.set`, spawn
231
+ * two runtimes appending to one file (corrupting history), and leak the
232
+ * first. New sessions (no file) create a fresh file each time, so they need
233
+ * no coalescing.
234
+ */
194
235
  async openSession(folderPath, sessionFilePath) {
236
+ if (!sessionFilePath) {
237
+ return this.doOpenSession(folderPath);
238
+ }
239
+ const alreadyOpenId = this.findSlotIdBySessionFile(sessionFilePath);
240
+ if (alreadyOpenId)
241
+ return alreadyOpenId;
242
+ return singleFlight(this.inFlightOpens, sessionFilePath, () => this.doOpenSession(folderPath, sessionFilePath));
243
+ }
244
+ /** Find the id of an open slot bound to the given session file, if any. */
245
+ findSlotIdBySessionFile(sessionFilePath) {
246
+ for (const [sid, slot] of this.sessions) {
247
+ if (slot.session.sessionFile === sessionFilePath)
248
+ return sid;
249
+ }
250
+ return undefined;
251
+ }
252
+ async doOpenSession(folderPath, sessionFilePath) {
195
253
  const eventBusRef = { current: null };
196
254
  const sharedAuthStorage = this.authStorage;
197
255
  const sharedModelRegistry = this.modelRegistry;
@@ -271,7 +329,7 @@ export class PimoteSessionManager {
271
329
  };
272
330
  slotRef.slot = slot;
273
331
  this.sessions.set(sessionId, slot);
274
- this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
332
+ this.lastKnownGitBranchBySession.set(sessionId, await getGitBranch(effectiveFolderPath));
275
333
  return sessionId;
276
334
  }
277
335
  handleAgentEnd(sessionId, slot) {
@@ -353,6 +411,45 @@ export class PimoteSessionManager {
353
411
  this.lastKnownGitBranchBySession.delete(oldId);
354
412
  this.lastKnownGitBranchBySession.set(newId, lastKnown);
355
413
  }
414
+ /** Sync read of the cached git branch for a session. Refreshed every 3s by the
415
+ * branch-check poll and seeded on open, so hot paths (sidebar broadcasts,
416
+ * get_session_meta) read this instead of shelling out to git on the event loop.
417
+ * May be up to ~3s stale; branch changes are rare so this is invisible in practice. */
418
+ getLastKnownGitBranch(sessionId) {
419
+ return this.lastKnownGitBranchBySession.get(sessionId) ?? null;
420
+ }
421
+ /** The single "session was replaced" business operation. Reconciles the session
422
+ * map (rebuild state, evict any collision, re-key) ALWAYS — regardless of whether
423
+ * a client owns the slot — so a reset triggered with no live owner can never leave
424
+ * the map keyed under a stale ID. Then notifies whatever connection currently owns
425
+ * the slot (never the issuer). All reset entry points (newSession/fork/navigateTree/
426
+ * switchSession, via WS commands or extension command-context) funnel here. */
427
+ async applySessionReset(slot) {
428
+ const newId = slot.runtime.session.sessionId;
429
+ const oldId = slot.sessionState.id;
430
+ // navigateTree stays in the same file — same session ID, nothing to re-key.
431
+ if (newId === oldId) {
432
+ await slot.connection?.onSessionReset?.(slot, { kind: 'unchanged' });
433
+ return;
434
+ }
435
+ // Rebuild session state (tears down old, creates new from runtime.session).
436
+ // Refreshes slot.folderPath from the new session header cwd (fork-from can
437
+ // change cwd, e.g. the worktree extension), so read folderPath after this.
438
+ this.rebuildSessionState(slot);
439
+ // Collision: another live slot already holds newId. This happens when an
440
+ // extension calls ctx.switchSession(path) onto a session file already open in
441
+ // a different slot (the new ID is the target file's existing ID, not a fresh
442
+ // one like fork's). Without eviction, reKeySession would overwrite the
443
+ // occupant's map entry and orphan its runtime — two runtimes on one file.
444
+ // Treat it as a takeover: notify the occupant's owner, then dispose it.
445
+ const occupant = this.sessions.get(newId);
446
+ if (occupant && occupant !== slot) {
447
+ this.onSlotEvicted?.(newId);
448
+ await this.closeSession(newId);
449
+ }
450
+ this.reKeySession(slot, oldId, newId);
451
+ await slot.connection?.onSessionReset?.(slot, { kind: 'rekeyed', oldId, newId, folderPath: slot.folderPath });
452
+ }
356
453
  /** Rebuild a slot's SessionState after session replacement.
357
454
  * Tears down the old state and creates a new one from the current runtime.session.
358
455
  * Also refreshes slot.folderPath from the new session's header cwd, since fork-from
@@ -378,6 +475,10 @@ export class PimoteSessionManager {
378
475
  getSession(sessionId) {
379
476
  return this.sessions.get(sessionId);
380
477
  }
478
+ /** The shared, server-wide login orchestrator (login is global, not session-scoped). */
479
+ getLoginOrchestrator() {
480
+ return this.loginOrchestrator;
481
+ }
381
482
  getAllSessions() {
382
483
  return Array.from(this.sessions.values());
383
484
  }
@@ -402,16 +503,18 @@ export class PimoteSessionManager {
402
503
  }
403
504
  }, 60_000);
404
505
  this.gitBranchCheckHandle = setInterval(() => {
405
- for (const [sessionId, slot] of this.sessions) {
406
- if (!slot.connection?.connectedClientId)
407
- continue;
408
- const next = getGitBranch(slot.folderPath);
506
+ // Snapshot connected sessions and refresh their branches in parallel. The
507
+ // lookups are async (execFile) so the poll never blocks the event loop;
508
+ // hot-path readers consume the cache via getLastKnownGitBranch.
509
+ const connected = [...this.sessions].filter(([, slot]) => slot.connection?.connectedClientId);
510
+ void Promise.all(connected.map(async ([sessionId, slot]) => {
511
+ const next = await getGitBranch(slot.folderPath);
409
512
  const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
410
513
  if (next !== prev) {
411
514
  this.lastKnownGitBranchBySession.set(sessionId, next);
412
515
  this.onGitBranchChange?.(sessionId, slot.folderPath);
413
516
  }
414
- }
517
+ }));
415
518
  }, 3000);
416
519
  }
417
520
  stopIdleCheck() {
@@ -25,6 +25,19 @@ export async function gcStaticHostStore(args) {
25
25
  }
26
26
  const suffix = '.json';
27
27
  for (const name of entries) {
28
+ // Orphan write tmp file (`<sessionId>.json.tmp`) left by a crash between
29
+ // writeFile and rename. GC runs at boot before any write, so a leftover
30
+ // .tmp is always stale — unlink unconditionally.
31
+ if (name.endsWith('.json.tmp')) {
32
+ try {
33
+ await unlink(join(storeDir, name));
34
+ }
35
+ catch (err) {
36
+ if (err.code !== 'ENOENT')
37
+ throw err;
38
+ }
39
+ continue;
40
+ }
28
41
  if (!name.endsWith(suffix))
29
42
  continue;
30
43
  const sessionId = name.slice(0, -suffix.length);