pi-tau-web-server 1.0.8
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 +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Tau standalone server.
|
|
4
|
+
*
|
|
5
|
+
* Serves the Tau web UI and manages backend-owned `pi --mode rpc` child
|
|
6
|
+
* sessions. Browser connections are views only; explicit live-session DELETE or
|
|
7
|
+
* server shutdown terminates child Pi processes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const http = require('node:http');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const { spawn, execFile } = require('node:child_process');
|
|
15
|
+
const readline = require('node:readline');
|
|
16
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
17
|
+
|
|
18
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
19
|
+
import type { Stats, Dirent } from 'node:fs';
|
|
20
|
+
import type { Socket } from 'node:net';
|
|
21
|
+
import type { WebSocket as WsType } from 'ws';
|
|
22
|
+
import type { JsonRecord, RpcCommand, RpcResponse, StatusError } from './types.js';
|
|
23
|
+
import { ARGS, AUTH_CONFIGURED, HOST, MIME_TYPES, PI_AGENT_DIR, PORT, SESSIONS_DIR, STATIC_DIR, TAU_SETTINGS, expandHome, loadTauSettings, parseArgs, saveTauSetting } from './config.js';
|
|
24
|
+
import { SESSION_COOKIE_NAME, SESSION_REFRESH_THRESHOLD_SECONDS, buildSessionCookie, issueSessionToken, parseCookies, verifySessionToken } from './auth.js';
|
|
25
|
+
import { getAvailableModels, modelLabel, normalizeModel, parseModelSpecToModel, parsePiListModels, _clearModelListCacheForTest, _setExecFileForTest } from './model-utils.js';
|
|
26
|
+
import { LiveSessionManager, PiRpcSession, isGenericSessionName, liveManager, makeId, _setSpawnPiForTest } from './sessions.js';
|
|
27
|
+
import { NAVIGATE_COMMAND, NAVIGATION_MARKER_TYPE, flattenTree, isTreeNavigationInProgress, leafDescendsFrom, navigateTree, pathFromRoot, selectNavigationTarget } from './tree.js';
|
|
28
|
+
|
|
29
|
+
type TauWs = WsType & { isAlive?: boolean };
|
|
30
|
+
|
|
31
|
+
let authEnabled = AUTH_CONFIGURED && TAU_SETTINGS.authEnabled !== false;
|
|
32
|
+
|
|
33
|
+
function checkBasicAuth(req: IncomingMessage) {
|
|
34
|
+
if (!authEnabled) return true;
|
|
35
|
+
const header = req.headers.authorization;
|
|
36
|
+
if (!header || !header.startsWith('Basic ')) return false;
|
|
37
|
+
const decoded = Buffer.from(header.slice(6), 'base64').toString();
|
|
38
|
+
const colon = decoded.indexOf(':');
|
|
39
|
+
if (colon === -1) return false;
|
|
40
|
+
return decoded.slice(0, colon) === TAU_SETTINGS.user && decoded.slice(colon + 1) === TAU_SETTINGS.pass;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type AuthResult = { ok: boolean; via: 'disabled' | 'basic' | 'cookie' | 'none'; expiresAt?: number };
|
|
44
|
+
|
|
45
|
+
// A wrong Basic header does not short-circuit: a valid session cookie still
|
|
46
|
+
// wins, so a browser resending a stale cached Basic header cannot lock the
|
|
47
|
+
// user out of an otherwise live session.
|
|
48
|
+
function checkAuth(req: IncomingMessage): AuthResult {
|
|
49
|
+
if (!authEnabled) return { ok: true, via: 'disabled' };
|
|
50
|
+
if (checkBasicAuth(req)) return { ok: true, via: 'basic' };
|
|
51
|
+
const token = parseCookies(req.headers.cookie)[SESSION_COOKIE_NAME];
|
|
52
|
+
if (token) {
|
|
53
|
+
const verdict = verifySessionToken(token);
|
|
54
|
+
if (verdict.valid) return { ok: true, via: 'cookie', expiresAt: verdict.expiresAt };
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, via: 'none' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isForwardedHttps(req: IncomingMessage): boolean {
|
|
60
|
+
return String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Mint when the request carries no valid session cookie (covers the page load
|
|
64
|
+
// right after the native prompt) and refresh one nearing expiry; never mint
|
|
65
|
+
// while auth is disabled so toggling it back on grants nothing to bystanders.
|
|
66
|
+
// Desktop browsers resend the Basic header on every request, so Basic-authed
|
|
67
|
+
// requests usually also carry a fresh cookie — don't re-mint on every response.
|
|
68
|
+
function maybeSetSessionCookie(req: IncomingMessage, res: ServerResponse, auth: AuthResult) {
|
|
69
|
+
if (!authEnabled || !auth.ok || auth.via === 'disabled') return;
|
|
70
|
+
let expiresAt = auth.expiresAt;
|
|
71
|
+
if (expiresAt === undefined) {
|
|
72
|
+
const token = parseCookies(req.headers.cookie)[SESSION_COOKIE_NAME];
|
|
73
|
+
if (token) {
|
|
74
|
+
const verdict = verifySessionToken(token);
|
|
75
|
+
if (verdict.valid) expiresAt = verdict.expiresAt;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (expiresAt !== undefined && expiresAt - Math.floor(Date.now() / 1000) >= SESSION_REFRESH_THRESHOLD_SECONDS) return;
|
|
79
|
+
res.setHeader('Set-Cookie', buildSessionCookie(issueSessionToken(), { secure: isForwardedHttps(req) }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function errorMessage(e: unknown): string {
|
|
83
|
+
if (e instanceof Error) return e.message;
|
|
84
|
+
return String(e);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function errorStatus(e: unknown): number {
|
|
88
|
+
if (e && typeof e === 'object' && 'status' in e) {
|
|
89
|
+
const status = (e as { status?: unknown }).status;
|
|
90
|
+
if (typeof status === 'number' && status) return status;
|
|
91
|
+
}
|
|
92
|
+
return 400;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sendAuthRequired(res: ServerResponse, req?: IncomingMessage) {
|
|
96
|
+
// Clear a presented-but-invalid session cookie so the browser stops
|
|
97
|
+
// resending it and falls back to the Basic prompt.
|
|
98
|
+
if (req && parseCookies(req.headers.cookie)[SESSION_COOKIE_NAME]) {
|
|
99
|
+
res.setHeader('Set-Cookie', buildSessionCookie('', { secure: isForwardedHttps(req), clear: true }));
|
|
100
|
+
}
|
|
101
|
+
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Tau"', 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function json(res: ServerResponse, status: number, data: unknown, extraHeaders: Record<string, string> = {}) {
|
|
106
|
+
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
107
|
+
res.end(JSON.stringify(data));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readBody(req: IncomingMessage): Promise<RpcCommand> {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let body = '';
|
|
113
|
+
req.on('data', (chunk: Buffer) => {
|
|
114
|
+
body += chunk.toString();
|
|
115
|
+
if (body.length > 20 * 1024 * 1024) reject(new Error('Request body too large'));
|
|
116
|
+
});
|
|
117
|
+
req.on('end', () => {
|
|
118
|
+
try { resolve(body ? JSON.parse(body) as RpcCommand : {}); } catch (e) { reject(e); }
|
|
119
|
+
});
|
|
120
|
+
req.on('error', reject);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let lanUrl = '';
|
|
125
|
+
let tailscaleUrl = '';
|
|
126
|
+
|
|
127
|
+
function resolveSessionFile(filePath: string) {
|
|
128
|
+
if (!filePath || typeof filePath !== 'string') throw new Error('filePath required');
|
|
129
|
+
const resolved = path.resolve(filePath);
|
|
130
|
+
const root = path.resolve(SESSIONS_DIR);
|
|
131
|
+
if (!resolved.startsWith(root + path.sep) || !resolved.endsWith('.jsonl')) {
|
|
132
|
+
throw new Error('Invalid session file');
|
|
133
|
+
}
|
|
134
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new Error('Session not found');
|
|
135
|
+
return resolved;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendSessionName(filePath: string, name: string) {
|
|
139
|
+
const resolved = resolveSessionFile(filePath);
|
|
140
|
+
fs.appendFileSync(resolved, JSON.stringify({ type: 'session_info', name, timestamp: new Date().toISOString() }) + '\n');
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function updateLiveSessionName(session: PiRpcSession | null | undefined, name: string) {
|
|
145
|
+
if (!session) return;
|
|
146
|
+
session.sessionName = name;
|
|
147
|
+
session.titleSet = true;
|
|
148
|
+
liveManager.broadcast({ type: 'event', sessionId: session.id, event: { type: 'session_name', name } });
|
|
149
|
+
liveManager.broadcastUpdated(session.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isWithinPath(root: string, target: string) {
|
|
153
|
+
const rel = path.relative(path.resolve(root), path.resolve(target));
|
|
154
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveLiveSessionPath(session: PiRpcSession | null | undefined, requestedPath?: string | null) {
|
|
158
|
+
if (!session) {
|
|
159
|
+
const err = new Error('Live session not found') as StatusError;
|
|
160
|
+
err.status = 404;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
const root = fs.realpathSync(path.resolve(session.cwd));
|
|
164
|
+
const candidate = path.resolve(expandHome(requestedPath || session.cwd));
|
|
165
|
+
let resolved = candidate;
|
|
166
|
+
try { resolved = fs.realpathSync(candidate); } catch {}
|
|
167
|
+
if (!isWithinPath(root, resolved)) {
|
|
168
|
+
const err = new Error('Path is outside the active session directory') as StatusError;
|
|
169
|
+
err.status = 403;
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
return resolved;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function openUrl(url: string): Promise<void> {
|
|
176
|
+
if (!/^https?:\/\//i.test(url)) return Promise.reject(new Error('Invalid URL'));
|
|
177
|
+
if (process.platform === 'win32') {
|
|
178
|
+
spawn('explorer.exe', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
}
|
|
181
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
182
|
+
return new Promise<void>((resolve, reject) => {
|
|
183
|
+
execFile(opener, [url], (err: NodeJS.ErrnoException | null) => err ? reject(err) : resolve());
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Fire-and-forget: refresh session.model/thinkingLevel from pi's get_state.
|
|
188
|
+
// Used after prompt/steer/follow_up acks so extension-driven model/thinking
|
|
189
|
+
// changes (e.g. pi-session-model's /session-model) propagate to tau and all
|
|
190
|
+
// clients. Silently skips on failure — the next user input or snapshot resyncs.
|
|
191
|
+
async function refreshSessionModel(session: PiRpcSession | null | undefined) {
|
|
192
|
+
if (!session || session.terminating) return;
|
|
193
|
+
const resp = await session.send({ type: 'get_state' }, { timeoutMs: 5000 });
|
|
194
|
+
const data = (resp && (resp.data || resp.result || resp)) as RpcCommand;
|
|
195
|
+
if (!data) return;
|
|
196
|
+
if (data.model !== undefined) session.model = normalizeModel(data.model);
|
|
197
|
+
if (data.thinkingLevel) session.thinkingLevel = String(data.thinkingLevel);
|
|
198
|
+
liveManager.broadcastUpdated(session.id);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function handleRpcCommand(command: RpcCommand): Promise<RpcResponse> {
|
|
202
|
+
const id = command.id;
|
|
203
|
+
const cmd = command.type;
|
|
204
|
+
const success = (data?: unknown): RpcResponse => ({ type: 'response', command: cmd, success: true, id, ...(data !== undefined ? { data } : {}) });
|
|
205
|
+
const error = (message: string): RpcResponse => ({ type: 'response', command: cmd, success: false, error: message, id });
|
|
206
|
+
|
|
207
|
+
// Backend-local commands do not require a live Pi child.
|
|
208
|
+
if (cmd === 'get_auth') return success({ configured: AUTH_CONFIGURED, enabled: authEnabled });
|
|
209
|
+
if (cmd === 'set_auth') {
|
|
210
|
+
if (!AUTH_CONFIGURED) return error('No credentials configured. Set tau.user and tau.pass in settings.json');
|
|
211
|
+
const wasEnabled = authEnabled;
|
|
212
|
+
authEnabled = !!command.enabled;
|
|
213
|
+
saveTauSetting('authEnabled', authEnabled);
|
|
214
|
+
liveManager.broadcast({ type: 'event', event: { type: 'auth_changed', enabled: authEnabled } });
|
|
215
|
+
if (!wasEnabled && authEnabled) {
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
for (const client of Array.from(liveManager.clients)) {
|
|
218
|
+
try { client.close(4001, 'Authentication enabled'); } catch {}
|
|
219
|
+
}
|
|
220
|
+
}, 25);
|
|
221
|
+
timer.unref?.();
|
|
222
|
+
}
|
|
223
|
+
return success({ enabled: authEnabled });
|
|
224
|
+
}
|
|
225
|
+
if (cmd === 'get_available_models') return success({ models: await getAvailableModels() });
|
|
226
|
+
if (cmd === 'set_session_name') {
|
|
227
|
+
const name = (command.name || '').trim();
|
|
228
|
+
if (!name) return error('Name cannot be empty');
|
|
229
|
+
const session = command.sessionId ? liveManager.get(command.sessionId) : null;
|
|
230
|
+
let resolvedFile = null;
|
|
231
|
+
const targetFile = command.filePath || session?.sessionFile;
|
|
232
|
+
if (targetFile) {
|
|
233
|
+
try { resolvedFile = appendSessionName(targetFile, name); } catch (e) { return error(errorMessage(e)); }
|
|
234
|
+
}
|
|
235
|
+
const matchingLive = resolvedFile
|
|
236
|
+
? Array.from(liveManager.sessions.values()).find((s) => s.sessionFile && path.resolve(s.sessionFile) === resolvedFile)
|
|
237
|
+
: null;
|
|
238
|
+
if (session) updateLiveSessionName(session, name);
|
|
239
|
+
else if (matchingLive) updateLiveSessionName(matchingLive, name);
|
|
240
|
+
else if (!resolvedFile) return error('sessionId or filePath required');
|
|
241
|
+
return success({ name });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const session = command.sessionId ? liveManager.get(command.sessionId) : null;
|
|
245
|
+
// Backend-local: move the session-tree leaf to an earlier entry (pi's
|
|
246
|
+
// native RPC protocol has no leaf-move command, so tau drives the bundled
|
|
247
|
+
// /tau-tree-navigate extension command in-process — see src/server/tree.ts).
|
|
248
|
+
if (cmd === 'navigate_tree') {
|
|
249
|
+
if (!session) return error('Live session not found');
|
|
250
|
+
const entryId = typeof command.entryId === 'string' ? command.entryId.trim() : '';
|
|
251
|
+
if (!entryId) return error('entryId required');
|
|
252
|
+
try {
|
|
253
|
+
const result = await navigateTree(session, entryId);
|
|
254
|
+
return success({ editorText: result.editorText });
|
|
255
|
+
} catch (e) { return error(errorMessage(e)); }
|
|
256
|
+
}
|
|
257
|
+
if (cmd === 'export_html') {
|
|
258
|
+
try {
|
|
259
|
+
if (command.sessionId && !session) throw new Error('Live session not found');
|
|
260
|
+
const sf = command.filePath ? resolveSessionFile(command.filePath) : session?.sessionFile;
|
|
261
|
+
if (!sf) throw new Error('No session file to export yet');
|
|
262
|
+
const args = ['--export', sf];
|
|
263
|
+
if (command.outputPath) args.push(resolveExportOutputPath(command.outputPath, sf));
|
|
264
|
+
const output = await new Promise<string>((resolve, reject) => {
|
|
265
|
+
execFile('pi', args, { cwd: session?.cwd || path.dirname(sf), timeout: 30000, encoding: 'utf8' }, (err: NodeJS.ErrnoException | null, stdout: string, stderr: string) => {
|
|
266
|
+
if (err) reject(new Error(stderr || err.message)); else resolve(stdout);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
let result = output.trim().split('\n').pop() || sf.replace(/\.jsonl$/, '.html');
|
|
270
|
+
result = path.resolve(expandHome(result));
|
|
271
|
+
if (!fs.existsSync(result)) result = sf.replace(/\.jsonl$/, '.html');
|
|
272
|
+
return success({ path: result });
|
|
273
|
+
} catch (e) { return error(errorMessage(e)); }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!session) return error('No active Tau session. Create or select an in-page Tau tab first.');
|
|
277
|
+
|
|
278
|
+
if (cmd === 'get_state') {
|
|
279
|
+
return success({
|
|
280
|
+
model: session.model,
|
|
281
|
+
thinkingLevel: session.thinkingLevel,
|
|
282
|
+
isStreaming: session.isStreaming,
|
|
283
|
+
sessionFile: session.sessionFile,
|
|
284
|
+
sessionName: session.sessionName,
|
|
285
|
+
autoCompactionEnabled: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (cmd === 'get_messages') return success({ entries: session.entries });
|
|
289
|
+
if (cmd === 'live_session_snapshot_request') return { type: 'live_session_snapshot', sessionId: session.id, ...session.snapshot() };
|
|
290
|
+
if (cmd === 'set_auto_compaction') return success({ enabled: !!command.enabled });
|
|
291
|
+
|
|
292
|
+
const native = new Set(['prompt', 'steer', 'follow_up', 'abort', 'compact', 'set_model', 'cycle_model', 'set_thinking_level', 'cycle_thinking_level', 'get_session_stats', 'get_tree', 'get_entries', 'extension_ui_response']);
|
|
293
|
+
if (!native.has(cmd ?? '')) return error(`Unknown command: ${cmd}`);
|
|
294
|
+
|
|
295
|
+
// While navigate_tree is moving the child's leaf (pi's stdin loop does not
|
|
296
|
+
// serialize a new prompt behind a running extension command), a prompt
|
|
297
|
+
// racing into that window could land its entries on the branch being
|
|
298
|
+
// abandoned. Refuse it up front so the user's turn is never dropped or
|
|
299
|
+
// misplaced without them knowing.
|
|
300
|
+
if ((cmd === 'prompt' || cmd === 'steer' || cmd === 'follow_up') && isTreeNavigationInProgress(session.id)) {
|
|
301
|
+
return error('A session-tree navigation is in progress for this session; wait a moment and send again');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// `set_thinking_level` is forwarded to pi but pi's response carries no
|
|
305
|
+
// level/thinkingLevel field, so updateStateFromResponse would never update
|
|
306
|
+
// session.thinkingLevel — yet touch(true)->broadcastUpdated would echo the
|
|
307
|
+
// stale level to all clients (reverting a client's just-set optimistic
|
|
308
|
+
// level). Record the level optimistically here and restore on pi failure.
|
|
309
|
+
const isSetThinkingLevel = cmd === 'set_thinking_level';
|
|
310
|
+
let prevThinkingLevel: string | null = null;
|
|
311
|
+
if (isSetThinkingLevel) {
|
|
312
|
+
prevThinkingLevel = session.thinkingLevel;
|
|
313
|
+
if (command.level) session.thinkingLevel = command.level;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const resp = await session.send(command, { timeoutMs: cmd === 'prompt' ? 10000 : 60000 });
|
|
318
|
+
if (isSetThinkingLevel && resp.success === false && prevThinkingLevel !== null) {
|
|
319
|
+
session.thinkingLevel = prevThinkingLevel;
|
|
320
|
+
}
|
|
321
|
+
// Extension-driven model/thinking changes (e.g. the pi-session-model
|
|
322
|
+
// `/session-model` slash command) call pi.setModel/pi.setThinkingLevel
|
|
323
|
+
// inside a prompt/steer/follow_up. Those acks carry no model data and emit
|
|
324
|
+
// no runtime stream event, so tau would stay stale. Fire-and-forget a
|
|
325
|
+
// get_state refresh so tau and all clients learn the new model/level. Do
|
|
326
|
+
// NOT block this HTTP response — return the original ack first.
|
|
327
|
+
if (resp.success !== false && (cmd === 'prompt' || cmd === 'steer' || cmd === 'follow_up')) {
|
|
328
|
+
refreshSessionModel(session).catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
return { ...resp, success: resp.success !== false };
|
|
331
|
+
} catch (e) {
|
|
332
|
+
if (isSetThinkingLevel && prevThinkingLevel !== null) session.thinkingLevel = prevThinkingLevel;
|
|
333
|
+
// Some commands are ack-less fire-and-forget in practice; keep UX moving
|
|
334
|
+
// only when the write succeeded and the child simply did not acknowledge.
|
|
335
|
+
const isAckTimeout = /^RPC command timed out:/.test(errorMessage(e));
|
|
336
|
+
if (isAckTimeout && (cmd === 'prompt' || cmd === 'abort' || cmd === 'extension_ui_response')) return success();
|
|
337
|
+
return error(errorMessage(e));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function serveStaticFile(req: IncomingMessage, res: ServerResponse) {
|
|
342
|
+
let urlPath = req.url || '/';
|
|
343
|
+
const auth = checkAuth(req);
|
|
344
|
+
if (authEnabled && !urlPath.startsWith('/api/health') && !auth.ok) return sendAuthRequired(res, req);
|
|
345
|
+
maybeSetSessionCookie(req, res, auth);
|
|
346
|
+
if (urlPath.startsWith('/api/')) return handleApiRoute(req, res, urlPath);
|
|
347
|
+
urlPath = urlPath.split('?')[0];
|
|
348
|
+
if (urlPath === '/') urlPath = '/index.html';
|
|
349
|
+
let decodedPath;
|
|
350
|
+
try {
|
|
351
|
+
decodedPath = decodeURIComponent(urlPath);
|
|
352
|
+
} catch {
|
|
353
|
+
res.writeHead(400);
|
|
354
|
+
res.end('Bad Request');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const staticRoot = path.resolve(STATIC_DIR);
|
|
358
|
+
const filePath = path.resolve(path.join(staticRoot, decodedPath));
|
|
359
|
+
if (filePath !== staticRoot && !filePath.startsWith(staticRoot + path.sep)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
360
|
+
fs.stat(filePath, (err: NodeJS.ErrnoException | null, stats: Stats) => {
|
|
361
|
+
if (err || !stats.isFile()) { res.writeHead(404); res.end('Not Found'); return; }
|
|
362
|
+
res.writeHead(200, { 'Content-Type': (MIME_TYPES as Record<string, string>)[path.extname(filePath).toLowerCase()] || 'application/octet-stream' });
|
|
363
|
+
fs.createReadStream(filePath).pipe(res);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isAllowedApiOrigin(req: IncomingMessage) {
|
|
368
|
+
const origin = req.headers.origin;
|
|
369
|
+
if (!origin) return true;
|
|
370
|
+
try {
|
|
371
|
+
return new URL(origin).host === req.headers.host;
|
|
372
|
+
} catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function setCorsForAllowedOrigin(req: IncomingMessage, res: ServerResponse) {
|
|
378
|
+
const origin = req.headers.origin;
|
|
379
|
+
if (!origin) return true;
|
|
380
|
+
if (!isAllowedApiOrigin(req)) return false;
|
|
381
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
382
|
+
res.setHeader('Vary', 'Origin');
|
|
383
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
384
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function handleApiRoute(req: IncomingMessage, res: ServerResponse, urlPath: string) {
|
|
389
|
+
const originAllowed = setCorsForAllowedOrigin(req, res);
|
|
390
|
+
if (req.method === 'OPTIONS') {
|
|
391
|
+
if (!originAllowed) return json(res, 403, { error: 'Origin not allowed' });
|
|
392
|
+
res.writeHead(200);
|
|
393
|
+
res.end();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!originAllowed) return json(res, 403, { error: 'Origin not allowed' });
|
|
397
|
+
|
|
398
|
+
const parsed = new URL(`http://localhost${req.url}`);
|
|
399
|
+
const cleanPath = parsed.pathname;
|
|
400
|
+
|
|
401
|
+
if (cleanPath === '/api/health') return json(res, 200, { status: 'ok', role: 'rpc-session-manager', liveSessionCount: liveManager.sessions.size, lanUrl, tailscaleUrl: tailscaleUrl || undefined, platform: process.platform });
|
|
402
|
+
if (cleanPath === '/api/live-sessions' && req.method === 'GET') return json(res, 200, { sessions: liveManager.list() });
|
|
403
|
+
if (cleanPath === '/api/live-sessions' && req.method === 'POST') {
|
|
404
|
+
readBody(req).then(async (body) => {
|
|
405
|
+
if (!body.cwd) return json(res, 400, { error: 'cwd required' });
|
|
406
|
+
try {
|
|
407
|
+
const session = await liveManager.create({ cwd: body.cwd, model: body.model || '' });
|
|
408
|
+
json(res, 200, { session: session.metadata() });
|
|
409
|
+
} catch (e) { json(res, 400, { error: errorMessage(e) }); }
|
|
410
|
+
}).catch((e) => json(res, 400, { error: errorMessage(e) }));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (cleanPath === '/api/live-sessions/resume' && req.method === 'POST') {
|
|
414
|
+
readBody(req).then(async (body) => {
|
|
415
|
+
if (!body.filePath || typeof body.filePath !== 'string') return json(res, 400, { error: 'filePath required' });
|
|
416
|
+
let resolvedFile: string;
|
|
417
|
+
try { resolvedFile = resolveSessionFile(body.filePath); } catch (e) { return json(res, 400, { error: errorMessage(e) }); }
|
|
418
|
+
const existing = liveManager.findBySessionFile(resolvedFile);
|
|
419
|
+
if (existing) return json(res, 200, { session: existing.metadata(), reused: true });
|
|
420
|
+
let cwd: string | null = normalizeSessionCwd(body.cwd);
|
|
421
|
+
if (!cwd) cwd = readSessionHeaderCwd(resolvedFile);
|
|
422
|
+
if (!cwd || !fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
423
|
+
return json(res, 400, { error: 'Cannot resume session because its project directory no longer exists' });
|
|
424
|
+
}
|
|
425
|
+
const entries = readSessionEntries(resolvedFile) as JsonRecord[];
|
|
426
|
+
const sessionName = deriveSessionNameFromEntries(entries);
|
|
427
|
+
const reusedPending = liveManager.hasPendingResume(resolvedFile);
|
|
428
|
+
try {
|
|
429
|
+
const session = await liveManager.resume({ sessionFile: resolvedFile, cwd, model: body.model || '', entries, sessionName });
|
|
430
|
+
json(res, 200, { session: session.metadata(), ...(reusedPending ? { reused: true } : {}) });
|
|
431
|
+
} catch (e) { json(res, 400, { error: errorMessage(e) }); }
|
|
432
|
+
}).catch((e) => json(res, 400, { error: errorMessage(e) }));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const liveMatch = cleanPath.match(/^\/api\/live-sessions\/([^/]+)(?:\/snapshot)?$/);
|
|
436
|
+
if (liveMatch) {
|
|
437
|
+
let id;
|
|
438
|
+
try {
|
|
439
|
+
id = decodeURIComponent(liveMatch[1]);
|
|
440
|
+
} catch {
|
|
441
|
+
return json(res, 400, { error: 'Malformed live session id' });
|
|
442
|
+
}
|
|
443
|
+
const session = liveManager.get(id);
|
|
444
|
+
if (!session) return json(res, 404, { error: 'Live session not found' });
|
|
445
|
+
const isSnapshotRoute = cleanPath.endsWith('/snapshot');
|
|
446
|
+
if (isSnapshotRoute && req.method === 'GET') return json(res, 200, session.snapshot());
|
|
447
|
+
if (!isSnapshotRoute && req.method === 'DELETE') { liveManager.delete(id, 'closed_by_user').then(() => json(res, 200, { success: true })); return; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (cleanPath === '/api/projects' && req.method === 'GET') return serveProjectsList(res);
|
|
451
|
+
if (cleanPath === '/api/sessions' && req.method === 'GET') return serveSessionsList(res);
|
|
452
|
+
if (cleanPath.startsWith('/api/search') && req.method === 'GET') return serveSearch(res, parsed.searchParams.get('q') || '');
|
|
453
|
+
if ((cleanPath === '/api/files') && req.method === 'GET') {
|
|
454
|
+
const explicitPath = parsed.searchParams.get('path');
|
|
455
|
+
const sessionId = parsed.searchParams.get('sessionId');
|
|
456
|
+
if (!sessionId) return json(res, 400, { error: 'No live session selected' });
|
|
457
|
+
const session = liveManager.get(sessionId);
|
|
458
|
+
if (!session) return json(res, 404, { error: 'Live session not found' });
|
|
459
|
+
try {
|
|
460
|
+
const dirPath = resolveLiveSessionPath(session, explicitPath || session.cwd);
|
|
461
|
+
return serveFileList(res, dirPath);
|
|
462
|
+
} catch (e) { return json(res, errorStatus(e), { error: errorMessage(e) }); }
|
|
463
|
+
}
|
|
464
|
+
if (cleanPath === '/api/file/preview' && req.method === 'GET') {
|
|
465
|
+
const sessionId = parsed.searchParams.get('sessionId');
|
|
466
|
+
if (!sessionId) return json(res, 400, { error: 'No live session selected' });
|
|
467
|
+
const session = liveManager.get(sessionId);
|
|
468
|
+
if (!session) return json(res, 404, { error: 'Live session not found' });
|
|
469
|
+
try {
|
|
470
|
+
const filePath = resolveLiveSessionPath(session, parsed.searchParams.get('path'));
|
|
471
|
+
return serveFilePreview(res, filePath);
|
|
472
|
+
} catch (e) { return json(res, errorStatus(e), { error: errorMessage(e) }); }
|
|
473
|
+
}
|
|
474
|
+
if (cleanPath === '/api/open' && req.method === 'POST') {
|
|
475
|
+
readBody(req).then((body) => {
|
|
476
|
+
try {
|
|
477
|
+
const filePath = resolveOpenPath(body);
|
|
478
|
+
return openNative(filePath)
|
|
479
|
+
.then(() => json(res, 200, { ok: true }))
|
|
480
|
+
.catch((e) => json(res, 500, { error: errorMessage(e) }));
|
|
481
|
+
} catch (e) {
|
|
482
|
+
return json(res, errorStatus(e), { error: errorMessage(e) });
|
|
483
|
+
}
|
|
484
|
+
}).catch((e) => json(res, 400, { error: errorMessage(e) }));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (cleanPath === '/api/rpc' && req.method === 'POST') {
|
|
488
|
+
readBody(req).then((body) => handleRpcCommand(body).then((resp) => json(res, 200, resp))).catch((e) => json(res, 400, { error: errorMessage(e) }));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (cleanPath === '/api/sessions/delete' && req.method === 'POST') {
|
|
492
|
+
readBody(req).then((body) => {
|
|
493
|
+
if (!body.filePath || typeof body.filePath !== 'string') return json(res, 400, { error: 'filePath required' });
|
|
494
|
+
const sessionFile = resolveSessionFile(body.filePath);
|
|
495
|
+
fs.unlinkSync(sessionFile);
|
|
496
|
+
json(res, 200, { success: true });
|
|
497
|
+
}).catch((e) => json(res, 400, { error: errorMessage(e) }));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const sessionMatch = cleanPath.match(/^\/api\/sessions\/([^/]+)\/([^/]+)$/);
|
|
501
|
+
if (sessionMatch && req.method === 'GET') return serveSessionFile(res, sessionMatch[1], sessionMatch[2]);
|
|
502
|
+
|
|
503
|
+
json(res, 404, { error: 'Not found' });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function liveFilesSet() {
|
|
507
|
+
return new Set(liveManager.list().map((s) => s.sessionFile).filter(Boolean));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function normalizeSessionCwd(cwd: unknown) {
|
|
511
|
+
return typeof cwd === 'string' && cwd.trim() ? path.resolve(expandHome(cwd)) : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function readSessionEntries(filePath: string): unknown[] {
|
|
515
|
+
const entries: unknown[] = [];
|
|
516
|
+
try {
|
|
517
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
518
|
+
for (const line of text.split(/\r?\n/)) {
|
|
519
|
+
if (!line.trim()) continue;
|
|
520
|
+
try { entries.push(JSON.parse(line)); } catch { /* skip malformed lines */ }
|
|
521
|
+
}
|
|
522
|
+
} catch { /* file may not exist yet */ }
|
|
523
|
+
return entries;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function deriveSessionNameFromEntries(entries: JsonRecord[]) {
|
|
527
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
528
|
+
const e = entries[i] as { type?: string; name?: unknown };
|
|
529
|
+
const name = typeof e?.name === 'string' ? e.name.trim() : '';
|
|
530
|
+
if (e?.type === 'session_info' && name && !isGenericSessionName(name)) return name;
|
|
531
|
+
}
|
|
532
|
+
for (const entry of entries) {
|
|
533
|
+
const e = entry as { type?: string; message?: { role?: string; content?: unknown } };
|
|
534
|
+
if (e?.type !== 'message' || e.message?.role !== 'user') continue;
|
|
535
|
+
const title = titleFromMessageContent(e.message.content);
|
|
536
|
+
if (title) return title;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function titleFromMessageContent(content: unknown) {
|
|
542
|
+
let text = '';
|
|
543
|
+
if (typeof content === 'string') text = content;
|
|
544
|
+
else if (Array.isArray(content)) {
|
|
545
|
+
text = content
|
|
546
|
+
.filter((b): b is { type?: unknown; text?: unknown } => !!b && typeof b === 'object')
|
|
547
|
+
.filter((b) => b.type === 'text')
|
|
548
|
+
.map((b) => typeof b.text === 'string' ? b.text : '')
|
|
549
|
+
.join('\n');
|
|
550
|
+
}
|
|
551
|
+
let title = text.replace(/^(ok |okay |so |actually |hey |please |can you |could you |i want(ed)? to |i wanna |let'?s )/i, '').replace(/\n.*/s, '').trim();
|
|
552
|
+
if (!title) return null;
|
|
553
|
+
const sentenceEnd = title.search(/[.!?]\s/);
|
|
554
|
+
if (sentenceEnd > 10 && sentenceEnd < 80) title = title.slice(0, sentenceEnd);
|
|
555
|
+
if (title.length > 60) title = title.slice(0, 57).replace(/\s+\S*$/, '') + '…';
|
|
556
|
+
title = title.charAt(0).toUpperCase() + title.slice(1);
|
|
557
|
+
return title || null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function readSessionHeaderCwd(filePath: string) {
|
|
561
|
+
let fd: number | null = null;
|
|
562
|
+
try {
|
|
563
|
+
fd = fs.openSync(filePath, 'r');
|
|
564
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
565
|
+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
566
|
+
const text = buffer.toString('utf8', 0, bytesRead);
|
|
567
|
+
for (const line of text.split(/\r?\n/)) {
|
|
568
|
+
if (!line.trim()) continue;
|
|
569
|
+
const entry = JSON.parse(line);
|
|
570
|
+
if (entry?.type === 'session') return normalizeSessionCwd(entry.cwd);
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
} finally {
|
|
575
|
+
if (fd !== null) {
|
|
576
|
+
try { fs.closeSync(fd); } catch {}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function serveProjectsList(res: ServerResponse) {
|
|
583
|
+
const projectsDir = TAU_SETTINGS.projectsDir;
|
|
584
|
+
if (!projectsDir || !fs.existsSync(projectsDir)) return json(res, 200, { projects: [], ...(projectsDir ? { error: 'Directory not found' } : {}) });
|
|
585
|
+
try {
|
|
586
|
+
const projectsRoot = path.resolve(projectsDir);
|
|
587
|
+
const sessionInfo = new Map<string, { count: number; lastActive: number }>();
|
|
588
|
+
if (fs.existsSync(SESSIONS_DIR)) {
|
|
589
|
+
for (const dir of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
590
|
+
if (!dir.isDirectory()) continue;
|
|
591
|
+
const files = fs.readdirSync(path.join(SESSIONS_DIR, dir.name)).filter((f: string) => f.endsWith('.jsonl'));
|
|
592
|
+
let sessionCwd: string | null = null;
|
|
593
|
+
let lastActive = 0;
|
|
594
|
+
for (const f of files) {
|
|
595
|
+
const filePath = path.join(SESSIONS_DIR, dir.name, f);
|
|
596
|
+
try {
|
|
597
|
+
lastActive = Math.max(lastActive, fs.statSync(filePath).mtimeMs);
|
|
598
|
+
if (!sessionCwd) sessionCwd = readSessionHeaderCwd(filePath);
|
|
599
|
+
} catch {}
|
|
600
|
+
}
|
|
601
|
+
const projectPath = sessionCwd;
|
|
602
|
+
if (!projectPath) continue;
|
|
603
|
+
if (!isWithinPath(projectsRoot, projectPath)) continue;
|
|
604
|
+
sessionInfo.set(projectPath, { count: files.length, lastActive });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const liveCwds = new Set(liveManager.list().map((s) => s.cwd));
|
|
608
|
+
const projects = fs.readdirSync(projectsRoot, { withFileTypes: true })
|
|
609
|
+
.filter((e: Dirent) => e.isDirectory() && !e.name.startsWith('.'))
|
|
610
|
+
.map((e: Dirent) => {
|
|
611
|
+
const fullPath = path.join(projectsRoot, e.name);
|
|
612
|
+
const info = sessionInfo.get(fullPath) || { count: 0, lastActive: 0 };
|
|
613
|
+
return { name: e.name, path: fullPath, sessionCount: info.count, lastActive: info.lastActive || null, active: liveCwds.has(fullPath) };
|
|
614
|
+
});
|
|
615
|
+
json(res, 200, { projects });
|
|
616
|
+
} catch (e) { json(res, 500, { error: errorMessage(e) }); }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function serveSessionsList(res: ServerResponse) {
|
|
620
|
+
try {
|
|
621
|
+
if (!fs.existsSync(SESSIONS_DIR)) return json(res, 200, { projects: [] });
|
|
622
|
+
const projectsByPath = new Map<string, { path: string; dirName: string; sessions: Array<Record<string, unknown>> }>();
|
|
623
|
+
const liveFiles = liveFilesSet();
|
|
624
|
+
for (const dir of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
625
|
+
if (!dir.isDirectory()) continue;
|
|
626
|
+
const projectDir = path.join(SESSIONS_DIR, dir.name);
|
|
627
|
+
for (const file of fs.readdirSync(projectDir).filter((f: string) => f.endsWith('.jsonl'))) {
|
|
628
|
+
try {
|
|
629
|
+
const filePath = path.join(projectDir, file);
|
|
630
|
+
const parsed = await parseSessionFile(filePath);
|
|
631
|
+
if (parsed) {
|
|
632
|
+
const projectPath = parsed.cwd || ''; // Intentionally leave missing header cwd empty; backward compatibility for legacy/incomplete sessions without cwd is not required.
|
|
633
|
+
let project = projectsByPath.get(projectPath);
|
|
634
|
+
if (!project) {
|
|
635
|
+
project = { path: projectPath, dirName: dir.name, sessions: [] };
|
|
636
|
+
projectsByPath.set(projectPath, project);
|
|
637
|
+
}
|
|
638
|
+
project.sessions.push({ ...parsed, file, filePath, mtime: fs.statSync(filePath).mtimeMs, live: liveFiles.has(filePath) });
|
|
639
|
+
}
|
|
640
|
+
} catch {}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const projects = Array.from(projectsByPath.values());
|
|
644
|
+
for (const project of projects) project.sessions.sort((a, b) => Number(b.mtime || 0) - Number(a.mtime || 0));
|
|
645
|
+
projects.sort((a, b) => Number(b.sessions[0]?.mtime || 0) - Number(a.sessions[0]?.mtime || 0));
|
|
646
|
+
json(res, 200, { projects });
|
|
647
|
+
} catch (e) { json(res, 500, { error: errorMessage(e) }); }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function parseSessionFile(filePath: string) {
|
|
651
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
652
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
653
|
+
let header = null, firstMessage = null, sessionName = null, userMessageCount = 0, lineCount = 0;
|
|
654
|
+
for await (const line of rl) {
|
|
655
|
+
if (!line.trim()) continue;
|
|
656
|
+
lineCount++;
|
|
657
|
+
try {
|
|
658
|
+
const entry = JSON.parse(line);
|
|
659
|
+
if (entry.type === 'session') header = entry;
|
|
660
|
+
else if (entry.type === 'session_info' && entry.name) sessionName = entry.name;
|
|
661
|
+
else if (entry.type === 'message' && entry.message?.role === 'user') {
|
|
662
|
+
userMessageCount++;
|
|
663
|
+
if (!firstMessage) {
|
|
664
|
+
const c = entry.message.content;
|
|
665
|
+
firstMessage = typeof c === 'string' ? c.slice(0, 120) : (Array.isArray(c) ? (c.find((b) => b.type === 'text')?.text || '').slice(0, 120) : null);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} catch {}
|
|
669
|
+
}
|
|
670
|
+
rl.close(); stream.destroy();
|
|
671
|
+
if (!header?.id || (userMessageCount <= 1 && lineCount <= 8)) return null;
|
|
672
|
+
return { id: header.id, timestamp: header.timestamp || '', name: sessionName, firstMessage, cwd: normalizeSessionCwd(header.cwd) };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function serveSessionFile(res: ServerResponse, dirName: string, file: string) {
|
|
676
|
+
const filePath = path.join(SESSIONS_DIR, dirName, file);
|
|
677
|
+
if (!fs.existsSync(filePath)) return json(res, 404, { error: 'Session not found' });
|
|
678
|
+
const entries: unknown[] = [];
|
|
679
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
680
|
+
let buffer = '';
|
|
681
|
+
stream.on('data', (chunk: string) => {
|
|
682
|
+
buffer += chunk;
|
|
683
|
+
const lines = buffer.split('\n'); buffer = lines.pop() || '';
|
|
684
|
+
for (const line of lines) if (line.trim()) { try { entries.push(JSON.parse(line)); } catch {} }
|
|
685
|
+
});
|
|
686
|
+
stream.on('end', () => { if (buffer.trim()) { try { entries.push(JSON.parse(buffer)); } catch {} } json(res, 200, { entries }); });
|
|
687
|
+
stream.on('error', (e: Error) => json(res, 500, { error: e.message }));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const IGNORED_NAMES = new Set(['node_modules', '.git', '__pycache__', '.DS_Store', '.Trash', '.next', '.nuxt', 'dist', 'build', '.cache', '.turbo', 'venv', '.venv', 'env', '.env.local', '.pi', 'coverage', '.nyc_output', '.parcel-cache']);
|
|
691
|
+
function serveFileList(res: ServerResponse, dirPath: string) {
|
|
692
|
+
try {
|
|
693
|
+
dirPath = path.resolve(expandHome(dirPath));
|
|
694
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return json(res, 400, { error: 'Not a directory' });
|
|
695
|
+
const items = [];
|
|
696
|
+
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
697
|
+
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
|
698
|
+
if (IGNORED_NAMES.has(entry.name)) continue;
|
|
699
|
+
try {
|
|
700
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
701
|
+
const stat = fs.statSync(fullPath);
|
|
702
|
+
items.push({ name: entry.name, path: fullPath, isDirectory: entry.isDirectory(), size: entry.isDirectory() ? null : stat.size, mtime: stat.mtimeMs });
|
|
703
|
+
} catch {}
|
|
704
|
+
}
|
|
705
|
+
items.sort((a, b) => a.isDirectory !== b.isDirectory ? (a.isDirectory ? -1 : 1) : a.name.localeCompare(b.name));
|
|
706
|
+
json(res, 200, { path: dirPath, items });
|
|
707
|
+
} catch (e) { json(res, 500, { error: errorMessage(e) }); }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function serveFilePreview(res: ServerResponse, filePath: string) {
|
|
711
|
+
if (!filePath) return json(res, 400, { error: 'path required' });
|
|
712
|
+
const mimes: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon' };
|
|
713
|
+
const mime = mimes[path.extname(filePath).toLowerCase().slice(1)];
|
|
714
|
+
if (!mime) return json(res, 415, { error: 'Not a previewable image' });
|
|
715
|
+
try {
|
|
716
|
+
if (!fs.statSync(filePath).isFile()) throw new Error('Not a file');
|
|
717
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'max-age=60' });
|
|
718
|
+
fs.createReadStream(filePath).pipe(res);
|
|
719
|
+
} catch (e) { json(res, 404, { error: errorMessage(e) }); }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function resolveExportedSessionPath(filePath: string) {
|
|
723
|
+
const resolved = path.resolve(expandHome(filePath || ''));
|
|
724
|
+
const root = path.resolve(SESSIONS_DIR);
|
|
725
|
+
if (!resolved.startsWith(root + path.sep) || path.extname(resolved).toLowerCase() !== '.html') {
|
|
726
|
+
const err = new Error('Can only open exported session HTML without a live session') as StatusError;
|
|
727
|
+
err.status = 403;
|
|
728
|
+
throw err;
|
|
729
|
+
}
|
|
730
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
731
|
+
const err = new Error('File not found') as StatusError;
|
|
732
|
+
err.status = 404;
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
return resolved;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function resolveExportOutputPath(outputPath: string, sessionFile: string) {
|
|
739
|
+
if (!outputPath || typeof outputPath !== 'string') throw new Error('outputPath required');
|
|
740
|
+
const sessionDir = path.dirname(path.resolve(sessionFile));
|
|
741
|
+
const sessionDirReal = fs.realpathSync(sessionDir);
|
|
742
|
+
const expanded = expandHome(outputPath);
|
|
743
|
+
const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(sessionDir, expanded);
|
|
744
|
+
if (!isWithinPath(sessionDir, resolved) || path.extname(resolved).toLowerCase() !== '.html') {
|
|
745
|
+
const err = new Error('Export outputPath must be an .html file in the session directory') as StatusError;
|
|
746
|
+
err.status = 403;
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
const parentDir = path.dirname(resolved);
|
|
750
|
+
let parentReal;
|
|
751
|
+
try { parentReal = fs.realpathSync(parentDir); } catch {
|
|
752
|
+
const err = new Error('Export output directory not found') as StatusError;
|
|
753
|
+
err.status = 404;
|
|
754
|
+
throw err;
|
|
755
|
+
}
|
|
756
|
+
if (!isWithinPath(sessionDirReal, parentReal) || (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink())) {
|
|
757
|
+
const err = new Error('Export outputPath must stay inside the session directory') as StatusError;
|
|
758
|
+
err.status = 403;
|
|
759
|
+
throw err;
|
|
760
|
+
}
|
|
761
|
+
return resolved;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function resolveOpenPath(body: RpcCommand) {
|
|
765
|
+
if (!body?.filePath || typeof body.filePath !== 'string') throw new Error('filePath required');
|
|
766
|
+
if (body.sessionId) {
|
|
767
|
+
const session = liveManager.get(body.sessionId);
|
|
768
|
+
const resolved = resolveLiveSessionPath(session, body.filePath);
|
|
769
|
+
if (!fs.existsSync(resolved)) {
|
|
770
|
+
const err = new Error('File not found') as StatusError;
|
|
771
|
+
err.status = 404;
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
return resolved;
|
|
775
|
+
}
|
|
776
|
+
return resolveExportedSessionPath(body.filePath);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function openNative(fp: string) {
|
|
780
|
+
if (!fp || typeof fp !== 'string') throw new Error('filePath required');
|
|
781
|
+
const resolved = path.resolve(expandHome(fp));
|
|
782
|
+
if (!fs.existsSync(resolved)) throw new Error('File not found');
|
|
783
|
+
if (process.platform === 'win32') {
|
|
784
|
+
spawn('explorer.exe', [resolved], { detached: true, stdio: 'ignore' }).unref();
|
|
785
|
+
} else if (process.platform === 'darwin') {
|
|
786
|
+
execFile('open', [resolved], () => {});
|
|
787
|
+
} else {
|
|
788
|
+
execFile('xdg-open', [resolved], () => {});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function serveSearch(res: ServerResponse, query: string) {
|
|
793
|
+
try {
|
|
794
|
+
if (!query || query.length < 2 || !fs.existsSync(SESSIONS_DIR)) return json(res, 200, { results: [] });
|
|
795
|
+
const q = query.toLowerCase();
|
|
796
|
+
const results = [];
|
|
797
|
+
for (const dir of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
798
|
+
if (!dir.isDirectory() || results.length >= 30) continue;
|
|
799
|
+
const projectDir = path.join(SESSIONS_DIR, dir.name);
|
|
800
|
+
for (const file of fs.readdirSync(projectDir).filter((f: string) => f.endsWith('.jsonl'))) {
|
|
801
|
+
if (results.length >= 30) break;
|
|
802
|
+
const filePath = path.join(projectDir, file);
|
|
803
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
804
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
805
|
+
let sessionId = '', sessionName = '', sessionTimestamp = '', firstMessage = '';
|
|
806
|
+
let sessionCwd: string | null = null;
|
|
807
|
+
const matches = [];
|
|
808
|
+
for await (const line of rl) {
|
|
809
|
+
if (!line.trim()) continue;
|
|
810
|
+
try {
|
|
811
|
+
const entry = JSON.parse(line);
|
|
812
|
+
if (entry.type === 'session') { sessionId = entry.id; sessionTimestamp = entry.timestamp || ''; sessionCwd = normalizeSessionCwd(entry.cwd); }
|
|
813
|
+
if (entry.type === 'session_info' && entry.name) sessionName = entry.name;
|
|
814
|
+
if (entry.type === 'message') {
|
|
815
|
+
const c = entry.message?.content;
|
|
816
|
+
const text = typeof c === 'string' ? c : (Array.isArray(c) ? c.filter((b) => b.type === 'text').map((b) => b.text).join(' ') : '');
|
|
817
|
+
if (!firstMessage && entry.message?.role === 'user' && text) firstMessage = text.slice(0, 120);
|
|
818
|
+
const idx = text.toLowerCase().indexOf(q);
|
|
819
|
+
if (idx >= 0) {
|
|
820
|
+
matches.push({ role: entry.message?.role || 'unknown', snippet: `${idx > 0 ? '…' : ''}${text.slice(Math.max(0, idx - 60), Math.min(text.length, idx + q.length + 60)).replace(/\n/g, ' ')}${idx + q.length + 60 < text.length ? '…' : ''}` });
|
|
821
|
+
if (matches.length >= 3) break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
} catch {}
|
|
825
|
+
}
|
|
826
|
+
rl.close(); stream.destroy();
|
|
827
|
+
if (matches.length) results.push({ filePath, project: sessionCwd || '', sessionId, sessionName, sessionTimestamp, firstMessage, matches });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
json(res, 200, { results });
|
|
831
|
+
} catch (e) { json(res, 500, { error: errorMessage(e) }); }
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function computeUrls(port: number) {
|
|
835
|
+
const isLoopback = HOST === '127.0.0.1' || HOST === '::1' || HOST === 'localhost';
|
|
836
|
+
let localIp = 'localhost';
|
|
837
|
+
let tailscaleIp = '';
|
|
838
|
+
if (!isLoopback) {
|
|
839
|
+
const nets = os.networkInterfaces();
|
|
840
|
+
for (const name of ['en0', 'en1', 'wlan0', 'eth0']) {
|
|
841
|
+
for (const net of nets[name] || []) if (net.family === 'IPv4' && !net.internal) { localIp = net.address; break; }
|
|
842
|
+
if (localIp !== 'localhost') break;
|
|
843
|
+
}
|
|
844
|
+
if (localIp === 'localhost') {
|
|
845
|
+
outer: for (const name of Object.keys(nets)) {
|
|
846
|
+
if (/^(bridge|utun|lo)/.test(name)) continue;
|
|
847
|
+
for (const net of nets[name] || []) if (net.family === 'IPv4' && !net.internal) { localIp = net.address; break outer; }
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
for (const name of Object.keys(nets)) for (const net of nets[name] || []) if (net.family === 'IPv4' && !net.internal && net.address.startsWith('100.')) tailscaleIp = net.address;
|
|
851
|
+
}
|
|
852
|
+
lanUrl = `http://${localIp}:${port}`;
|
|
853
|
+
tailscaleUrl = tailscaleIp ? `http://${tailscaleIp}:${port}` : '';
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const server = http.createServer(serveStaticFile);
|
|
857
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
858
|
+
|
|
859
|
+
server.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
860
|
+
if (!isAllowedApiOrigin(request)) {
|
|
861
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
862
|
+
socket.destroy();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (authEnabled && !checkAuth(request).ok) {
|
|
866
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Tau"\r\n\r\n');
|
|
867
|
+
socket.destroy();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (request.url === '/ws') wss.handleUpgrade(request, socket, head, (ws: TauWs) => wss.emit('connection', ws, request));
|
|
871
|
+
else socket.destroy();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
wss.on('connection', (ws: TauWs) => {
|
|
875
|
+
liveManager.addClient(ws);
|
|
876
|
+
ws.isAlive = true;
|
|
877
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
878
|
+
ws.send(JSON.stringify({ type: 'state', liveSessions: liveManager.list() }));
|
|
879
|
+
ws.on('message', async (data: Buffer) => {
|
|
880
|
+
try {
|
|
881
|
+
const command = JSON.parse(data.toString());
|
|
882
|
+
const resp = await handleRpcCommand(command);
|
|
883
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(resp));
|
|
884
|
+
} catch (e) {
|
|
885
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', message: errorMessage(e) }));
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
ws.on('close', () => liveManager.removeClient(ws));
|
|
889
|
+
ws.on('error', () => liveManager.removeClient(ws));
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
setInterval(() => {
|
|
893
|
+
for (const client of liveManager.clients) {
|
|
894
|
+
if (client.readyState !== WebSocket.OPEN) { liveManager.removeClient(client); continue; }
|
|
895
|
+
if (!client.isAlive) { try { client.terminate(); } catch {} liveManager.removeClient(client); continue; }
|
|
896
|
+
client.isAlive = false;
|
|
897
|
+
try { client.ping(); } catch {}
|
|
898
|
+
}
|
|
899
|
+
}, 20000).unref();
|
|
900
|
+
|
|
901
|
+
function listen(port: number, attemptsLeft = 10) {
|
|
902
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
903
|
+
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
904
|
+
console.log(`[Tau] Port ${port} in use, trying ${port + 1}...`);
|
|
905
|
+
server.removeAllListeners('error');
|
|
906
|
+
listen(port + 1, attemptsLeft - 1);
|
|
907
|
+
} else {
|
|
908
|
+
console.error(`[Tau] Failed to start: ${err.message}`);
|
|
909
|
+
process.exit(1);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
server.listen(port, HOST, () => {
|
|
913
|
+
computeUrls(port);
|
|
914
|
+
console.log(`[Tau] Server running on ${lanUrl}${tailscaleUrl ? ` • Tailscale: ${tailscaleUrl}` : ''}`);
|
|
915
|
+
console.log(`[Tau] Static assets: ${STATIC_DIR}`);
|
|
916
|
+
if (ARGS.open) openUrl(lanUrl).catch(() => {});
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
let shuttingDown = false;
|
|
921
|
+
async function shutdown(signal: string) {
|
|
922
|
+
if (shuttingDown) return;
|
|
923
|
+
shuttingDown = true;
|
|
924
|
+
console.log(`\n[Tau] Shutting down (${signal}); terminating ${liveManager.sessions.size} Pi session(s)...`);
|
|
925
|
+
try { wss.close(); } catch {}
|
|
926
|
+
await liveManager.shutdown();
|
|
927
|
+
server.close(() => process.exit(0));
|
|
928
|
+
setTimeout(() => process.exit(0), 2500).unref();
|
|
929
|
+
}
|
|
930
|
+
function startCli() {
|
|
931
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
932
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
933
|
+
process.on('exit', () => {
|
|
934
|
+
for (const session of liveManager.sessions.values()) {
|
|
935
|
+
try { session.child?.kill('SIGTERM'); } catch {}
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
process.on('uncaughtException', (err) => { console.error(err); shutdown('uncaughtException'); });
|
|
939
|
+
process.on('unhandledRejection', (err) => { console.error(err); });
|
|
940
|
+
listen(PORT);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Test-only helper to reset module-level auth state between cases.
|
|
944
|
+
function _setAuthForTest(enabled: boolean) { authEnabled = !!enabled; }
|
|
945
|
+
|
|
946
|
+
// Test-only helpers for the session-cookie flow: mutate the live credentials
|
|
947
|
+
// (tokens embed a credential fingerprint) and mint tokens at chosen expiries.
|
|
948
|
+
function _setCredentialsForTest(user: string, pass: string) { TAU_SETTINGS.user = user; TAU_SETTINGS.pass = pass; }
|
|
949
|
+
function _issueSessionTokenForTest(expiresAtSeconds?: number) { return issueSessionToken(expiresAtSeconds); }
|
|
950
|
+
|
|
951
|
+
// Test-only hook to substitute the `pi` spawn so LiveSessionManager.create()
|
|
952
|
+
// can be exercised without launching a real Pi process.
|
|
953
|
+
|
|
954
|
+
module.exports = {
|
|
955
|
+
parseArgs,
|
|
956
|
+
expandHome,
|
|
957
|
+
loadTauSettings,
|
|
958
|
+
modelLabel,
|
|
959
|
+
normalizeModel,
|
|
960
|
+
parseModelSpecToModel,
|
|
961
|
+
parsePiListModels,
|
|
962
|
+
getAvailableModels,
|
|
963
|
+
makeId,
|
|
964
|
+
PiRpcSession,
|
|
965
|
+
LiveSessionManager,
|
|
966
|
+
liveManager,
|
|
967
|
+
resolveSessionFile,
|
|
968
|
+
appendSessionName,
|
|
969
|
+
updateLiveSessionName,
|
|
970
|
+
isWithinPath,
|
|
971
|
+
resolveLiveSessionPath,
|
|
972
|
+
resolveExportOutputPath,
|
|
973
|
+
resolveExportedSessionPath,
|
|
974
|
+
resolveOpenPath,
|
|
975
|
+
openUrl,
|
|
976
|
+
handleRpcCommand,
|
|
977
|
+
NAVIGATE_COMMAND,
|
|
978
|
+
NAVIGATION_MARKER_TYPE,
|
|
979
|
+
flattenTree,
|
|
980
|
+
isTreeNavigationInProgress,
|
|
981
|
+
leafDescendsFrom,
|
|
982
|
+
navigateTree,
|
|
983
|
+
pathFromRoot,
|
|
984
|
+
selectNavigationTarget,
|
|
985
|
+
isAllowedApiOrigin,
|
|
986
|
+
setCorsForAllowedOrigin,
|
|
987
|
+
handleApiRoute,
|
|
988
|
+
serveStaticFile,
|
|
989
|
+
server,
|
|
990
|
+
wss,
|
|
991
|
+
computeUrls,
|
|
992
|
+
listen,
|
|
993
|
+
startCli,
|
|
994
|
+
SESSIONS_DIR,
|
|
995
|
+
PI_AGENT_DIR,
|
|
996
|
+
checkAuth,
|
|
997
|
+
SESSION_COOKIE_NAME,
|
|
998
|
+
_setAuthForTest,
|
|
999
|
+
_setCredentialsForTest,
|
|
1000
|
+
_issueSessionTokenForTest,
|
|
1001
|
+
_setSpawnPiForTest,
|
|
1002
|
+
_setExecFileForTest,
|
|
1003
|
+
_clearModelListCacheForTest,
|
|
1004
|
+
};
|