@pimote/pimote 0.5.1 → 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.
- package/README.md +4 -1
- package/client/build/_app/immutable/assets/0.-er3OUWm.css +2 -0
- package/client/build/_app/immutable/assets/2.BtlPyuHL.css +1 -0
- package/client/build/_app/immutable/chunks/ATalJV7d.js +3 -0
- package/client/build/_app/immutable/chunks/{eHWBE-tD.js → B1ItOytB.js} +2 -2
- package/client/build/_app/immutable/chunks/BiEvVL3P.js +1 -0
- package/client/build/_app/immutable/chunks/D8SptH3Y.js +1 -0
- package/client/build/_app/immutable/chunks/S8e8sMop.js +1 -0
- package/client/build/_app/immutable/chunks/b9CWRTHL.js +1 -0
- package/client/build/_app/immutable/entry/{app.Di2WQBl6.js → app.agj-hcVA.js} +2 -2
- package/client/build/_app/immutable/entry/start.NVZAE6Px.js +1 -0
- package/client/build/_app/immutable/nodes/0.DweM6Pbc.js +10 -0
- package/client/build/_app/immutable/nodes/{1.DKkktqMe.js → 1.owr_UHNy.js} +1 -1
- package/client/build/_app/immutable/nodes/2.CQNU1AJj.js +55 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +2 -2
- package/server/dist/config.js +5 -2
- package/server/dist/event-buffer.js +10 -1
- package/server/dist/extension-ui-bridge.js +26 -10
- package/server/dist/file-references.js +123 -0
- package/server/dist/git-branch.js +12 -9
- package/server/dist/login-orchestrator.js +105 -0
- package/server/dist/push-infrastructure.js +13 -2
- package/server/dist/push-notification.js +18 -11
- package/server/dist/server.js +25 -2
- package/server/dist/session-cost.js +26 -2
- package/server/dist/session-manager.js +116 -7
- package/server/dist/static-host/gc.js +13 -0
- package/server/dist/static-host/http-handler.js +27 -1
- package/server/dist/static-host/index.js +24 -12
- package/server/dist/static-host/store.js +10 -1
- package/server/dist/static-host/tools.js +5 -1
- package/server/dist/voice/fsm/reducer.js +14 -2
- package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
- package/server/dist/voice/fsm/reducers/streaming.js +39 -3
- package/server/dist/voice/fsm/reducers/walkback.js +13 -10
- package/server/dist/voice/fsm/state.js +1 -1
- package/server/dist/voice/index.js +97 -41
- package/server/dist/voice/walk-back.js +94 -26
- package/server/dist/voice-orchestrator-boot.js +22 -5
- package/server/dist/voice-orchestrator.js +38 -1
- package/server/dist/ws-handler.js +194 -64
- package/shared/dist/protocol.d.ts +97 -2
- package/client/build/_app/immutable/assets/0.KP1suSk9.css +0 -2
- package/client/build/_app/immutable/assets/2.BaqEkCa-.css +0 -1
- package/client/build/_app/immutable/chunks/0-bXzYW9.js +0 -1
- package/client/build/_app/immutable/chunks/BgJ-X-tf.js +0 -3
- package/client/build/_app/immutable/chunks/CnTTbAN2.js +0 -1
- package/client/build/_app/immutable/chunks/RbcwTVzu.js +0 -1
- package/client/build/_app/immutable/chunks/TV35yyBT.js +0 -1
- package/client/build/_app/immutable/chunks/gZLAQ0sf.js +0 -1
- package/client/build/_app/immutable/entry/start.ClOWBB7j.js +0 -1
- package/client/build/_app/immutable/nodes/0.DJUqUGM7.js +0 -10
- package/client/build/_app/immutable/nodes/2.BTjJ9cu5.js +0 -54
|
@@ -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 {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
13
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
11
14
|
cwd,
|
|
12
15
|
env,
|
|
13
16
|
encoding: 'utf-8',
|
|
14
17
|
timeout: 2000,
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
console.warn('[PushNotificationService] Failed to send notification:',
|
|
63
|
+
else {
|
|
64
|
+
console.warn('[PushNotificationService] Failed to send notification:', result.reason?.message ?? result.reason);
|
|
60
65
|
}
|
|
61
|
-
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
}
|
package/server/dist/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
+
* (duck-typed) view of the session entries.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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) ----
|
|
@@ -71,7 +72,13 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
71
72
|
state.idleSince = null;
|
|
72
73
|
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
73
74
|
}
|
|
74
|
-
else if (event.type === 'agent_end' && state.status !== 'idle') {
|
|
75
|
+
else if (event.type === 'agent_end' && !event.willRetry && state.status !== 'idle') {
|
|
76
|
+
// `willRetry` agent_end is not a real end — the SDK detected a retryable
|
|
77
|
+
// error and will re-run the prompt after backoff (a fresh agent_start
|
|
78
|
+
// follows). Treating it as idle here would fire a spurious completion
|
|
79
|
+
// push notification and start the idle-reap clock for a session that is
|
|
80
|
+
// still working. Skip it; the terminal (willRetry=false) agent_end will
|
|
81
|
+
// drive the real idle transition.
|
|
75
82
|
state.status = 'idle';
|
|
76
83
|
state.idleSince = Date.now();
|
|
77
84
|
state.needsAttention = true;
|
|
@@ -137,12 +144,33 @@ function scheduleSlotPanelPush(state, sessionId, sendEvent) {
|
|
|
137
144
|
sendEvent({ type: 'panel_update', sessionId, cards });
|
|
138
145
|
}, 200);
|
|
139
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
|
+
}
|
|
140
163
|
export class PimoteSessionManager {
|
|
141
164
|
config;
|
|
142
165
|
pushNotificationService;
|
|
143
166
|
authStorage;
|
|
144
167
|
modelRegistry;
|
|
168
|
+
loginOrchestrator;
|
|
145
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();
|
|
146
174
|
idleCheckHandle = null;
|
|
147
175
|
gitBranchCheckHandle = null;
|
|
148
176
|
lastKnownGitBranchBySession = new Map();
|
|
@@ -153,12 +181,18 @@ export class PimoteSessionManager {
|
|
|
153
181
|
* reap, explicit close). Consumers use this to drop external bookkeeping
|
|
154
182
|
* (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
|
|
155
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;
|
|
156
189
|
staticHostFactory;
|
|
157
190
|
constructor(config, pushNotificationService, options = {}) {
|
|
158
191
|
this.config = config;
|
|
159
192
|
this.pushNotificationService = pushNotificationService;
|
|
160
193
|
this.authStorage = AuthStorage.create();
|
|
161
194
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
195
|
+
this.loginOrchestrator = new LoginOrchestrator(this.authStorage, this.modelRegistry);
|
|
162
196
|
this.staticHostFactory = options.staticHostFactory;
|
|
163
197
|
}
|
|
164
198
|
/**
|
|
@@ -185,7 +219,37 @@ export class PimoteSessionManager {
|
|
|
185
219
|
defaultWorkerModel: worker,
|
|
186
220
|
});
|
|
187
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
|
+
*/
|
|
188
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) {
|
|
189
253
|
const eventBusRef = { current: null };
|
|
190
254
|
const sharedAuthStorage = this.authStorage;
|
|
191
255
|
const sharedModelRegistry = this.modelRegistry;
|
|
@@ -265,7 +329,7 @@ export class PimoteSessionManager {
|
|
|
265
329
|
};
|
|
266
330
|
slotRef.slot = slot;
|
|
267
331
|
this.sessions.set(sessionId, slot);
|
|
268
|
-
this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
|
|
332
|
+
this.lastKnownGitBranchBySession.set(sessionId, await getGitBranch(effectiveFolderPath));
|
|
269
333
|
return sessionId;
|
|
270
334
|
}
|
|
271
335
|
handleAgentEnd(sessionId, slot) {
|
|
@@ -347,6 +411,45 @@ export class PimoteSessionManager {
|
|
|
347
411
|
this.lastKnownGitBranchBySession.delete(oldId);
|
|
348
412
|
this.lastKnownGitBranchBySession.set(newId, lastKnown);
|
|
349
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
|
+
}
|
|
350
453
|
/** Rebuild a slot's SessionState after session replacement.
|
|
351
454
|
* Tears down the old state and creates a new one from the current runtime.session.
|
|
352
455
|
* Also refreshes slot.folderPath from the new session's header cwd, since fork-from
|
|
@@ -372,6 +475,10 @@ export class PimoteSessionManager {
|
|
|
372
475
|
getSession(sessionId) {
|
|
373
476
|
return this.sessions.get(sessionId);
|
|
374
477
|
}
|
|
478
|
+
/** The shared, server-wide login orchestrator (login is global, not session-scoped). */
|
|
479
|
+
getLoginOrchestrator() {
|
|
480
|
+
return this.loginOrchestrator;
|
|
481
|
+
}
|
|
375
482
|
getAllSessions() {
|
|
376
483
|
return Array.from(this.sessions.values());
|
|
377
484
|
}
|
|
@@ -396,16 +503,18 @@ export class PimoteSessionManager {
|
|
|
396
503
|
}
|
|
397
504
|
}, 60_000);
|
|
398
505
|
this.gitBranchCheckHandle = setInterval(() => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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);
|
|
403
512
|
const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
|
|
404
513
|
if (next !== prev) {
|
|
405
514
|
this.lastKnownGitBranchBySession.set(sessionId, next);
|
|
406
515
|
this.onGitBranchChange?.(sessionId, slot.folderPath);
|
|
407
516
|
}
|
|
408
|
-
}
|
|
517
|
+
}));
|
|
409
518
|
}, 3000);
|
|
410
519
|
}
|
|
411
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);
|