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.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
package/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # Pi Tau Web Server
2
+
3
+ **Browser workspace for [Pi](https://github.com/earendil-works/pi) — a standalone web server that manages multiple live Pi RPC sessions in parallel.**
4
+
5
+ Pi Tau Web Server is a fork of [deflating/tau](https://github.com/deflating/tau), forked at
6
+ [`5e2bce39`](https://github.com/deflating/tau/tree/5e2bce39) and rewritten from
7
+ a Pi extension that ran inside the Pi TUI into a standalone Node.js web server.
8
+ Instead of living inside a TUI session, Tau runs one backend process and spawns
9
+ headless `pi --mode rpc` child processes — one per in-page Tau tab — so you can
10
+ work with multiple Pi sessions side by side in your browser.
11
+
12
+ ## Key differences from `5e2bce39`
13
+
14
+ | Area | Upstream (`5e2bce39`) | This fork |
15
+ | --------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
16
+ | **Architecture** | Pi extension loaded inside the Pi TUI process; browser displayed a single running TUI session | Standalone Node.js server that spawns independent `pi --mode rpc` child processes |
17
+ | **Multiple sessions** | Not supported | In-page tabs each backed by their own Pi RPC subprocess; run N sessions in parallel |
18
+ | **Session lifecycle** | Tied to the Pi TUI session — close the TUI and the browser view died | Sessions are server-owned; closing/reloading the browser does not kill Pi children |
19
+ | **Pi communication** | In-process Pi extension API | Out-of-process JSON line-delimited RPC over stdin/stdout |
20
+ | **UI dialogs** | `ctx.ui.confirm/input/select` shown in TUI only; not forwarded to the browser | All forwarded to the browser — extensions that use these built-in UI interfaces work seamlessly in the browser |
21
+ | **Test coverage** | Javascript and No tests | Typescript + Full test suite |
22
+ | **Auto-start** | Extension auto-started inside Pi unless `TAU_DISABLED=1` | Always explicit — the user runs `pi-tau-web-server` when they want it |
23
+
24
+ ### Enhancements in this branch compared with `main`
25
+
26
+ This branch also includes various enhancements over `main`, including a session-tree and branching interface that mirrors Pi TUI's `/tree` command. Open **Session tree** from the header or command palette to inspect every branch and continue from any earlier point; selecting a user message restores it to the composer so it can be edited and submitted as a new branch.
27
+
28
+ ![Tau light mode](docs/images/main-page.jpg)
29
+
30
+ ![Tau dark mode](docs/images/main-page-dark.jpg)
31
+
32
+ ![New tab modal](docs/images/new-tab.jpg)
33
+
34
+ ![Commands](docs/images/commands.png)
35
+
36
+ ![Model completion](docs/images/model-completion.jpg)
37
+
38
+ ![Settings](docs/images/settings.png)
39
+
40
+ ![Mobile main page](docs/images/mobile-main-page.jpeg)
41
+
42
+ ![Mobile sidebar](docs/images/mobile-side-bar.jpeg)
43
+
44
+ ## What it does
45
+
46
+ - **Standalone server** — run `pi-tau-web-server`; it serves the web UI and manages Pi RPC child processes as subprocesses via `pi --mode rpc`
47
+ - **Multiple live sessions** — in-page Tau tabs (not browser tabs) each represent a live Pi RPC session. Create, switch, and close them from one browser page. Tau runs all of them in parallel.
48
+ - **Session persistence while the server runs** — closing or reloading the browser does not kill Pi child sessions; only closing an in-page Tau tab or shutting down the Tau server does
49
+ - **Works on any device** — open the same Tau server from your phone, tablet, or another monitor
50
+ - **Session history browser** — view saved Pi JSONL session files, search across all sessions by message content
51
+ - **WebSocket-based client-server architecture** — the browser connects to Tau over HTTP and WebSocket; Tau communicates with Pi processes over JSON line-delimited RPC over stdin/stdout
52
+
53
+ ## Install
54
+
55
+ Requires Node.js 22.19 or newer and [Pi](https://github.com/earendil-works/pi)
56
+ installed as the `pi` command on your `PATH`.
57
+
58
+ ```bash
59
+ npm install --global pi-tau-web-server
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ pi-tau-web-server
66
+ ```
67
+
68
+ Open the printed URL (default `http://localhost:3001`). Click `+` in the tab bar or sidebar to create an in-page Tau tab, choose or type a project directory, optionally enter a Pi `/model`-style model string, then chat.
69
+
70
+ ```bash
71
+ pi-tau-web-server --host 127.0.0.1 --port 3001 --open
72
+ TAU_PORT=3001 TAU_HOST=0.0.0.0 TAU_PROJECTS_DIR="$HOME/projects" pi-tau-web-server
73
+ ```
74
+
75
+ ## Containers
76
+
77
+ The container launcher runs one isolated Tau service per instance name. Docker
78
+ and Podman are supported; set `TAU_CONTAINER_RUNTIME` to select one explicitly.
79
+ The image is built automatically on the first run, or it can be built ahead of
80
+ time:
81
+
82
+ ```bash
83
+ container/tau-container build
84
+ ```
85
+
86
+ Start an instance with credentials in the environment:
87
+
88
+ ```bash
89
+ TAU_USER=alice TAU_PASS='choose-a-password' \
90
+ container/tau-container run personal-workspace --port 3001
91
+ ```
92
+
93
+ Missing credentials are prompted for, with the password input hidden. Start
94
+ additional isolated instances by giving each one a different name and host
95
+ port:
96
+
97
+ ```bash
98
+ container/tau-container run work-workspace --port 3002
99
+ container/tau-container status
100
+ container/tau-container logs work-workspace
101
+ container/tau-container stop work-workspace
102
+ ```
103
+
104
+ Each instance has an isolated `~/projects` directory inside the container,
105
+ available at `/state/home/projects`. It is mounted read-write from the
106
+ instance's `projects/` state directory.
107
+
108
+ On an instance's first launch, the launcher copies
109
+ `${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}` into that instance's writable state.
110
+ Symlinks are dereferenced on the host. The host `sessions/`, `npm/`, and `git/`
111
+ directories are excluded. Package declarations remain in `settings.json`, so Pi
112
+ installs missing npm/git packages when its first RPC session starts.
113
+
114
+ The copied configuration is never refreshed automatically. Changes made by Pi
115
+ or Tau therefore survive container recreation and remain isolated from other
116
+ instances. Projects and session files are also stored separately per instance.
117
+ By default, state is kept under:
118
+
119
+ ```text
120
+ ${XDG_DATA_HOME:-$HOME/.local/share}/pi-tau-web-server/instances/<instance>/
121
+ ```
122
+
123
+ The host-side projects directory is `<instance>/projects/` beneath that path.
124
+ Override the state root with `TAU_CONTAINER_STATE_DIR`.
125
+
126
+ Ports bind to all host interfaces by default. HTTP Basic Auth does not encrypt
127
+ credentials or traffic, so use a TLS reverse proxy for access from another
128
+ machine. Pass `--bind 127.0.0.1` to restrict an instance to local access.
129
+
130
+ ## Features
131
+
132
+ ### Chat
133
+
134
+ - Full markdown rendering with syntax-highlighted code blocks
135
+ - Streaming responses with typing indicator
136
+ - Image attachments (paste, drag & drop, or button)
137
+ - Copy any message with one click
138
+ - Inline diff viewer for edit tool calls
139
+ - Message queuing while the agent is working
140
+
141
+ ### Live Session Management
142
+
143
+ - Backend-owned live Pi RPC sessions, each backed by a `pi --mode rpc` subprocess
144
+ - JupyterLab-style in-page Tau tab strip — each tab is an independent Pi session
145
+ - Browser reload or reconnect restores live Tau tabs from the backend
146
+ - Multiple browser clients see the same live session list
147
+ - Historical sessions remain read-only
148
+ - **Session tree and branching** — inspect the active session's full tree and jump to any earlier entry, mirroring Pi TUI's `/tree` behavior; choose a user message to restore it to the composer and create a new branch
149
+
150
+ ### Model & Thinking
151
+
152
+ - Optional model string at session creation using Pi `/model` syntax (e.g. `openai/gpt-5.5:high`)
153
+ - Per-session model picker with fuzzy search
154
+ - Per-session thinking level controls
155
+ - Token usage percentage with context window visualiser
156
+ - Cost tracking per session
157
+
158
+ ### File Browser
159
+
160
+ - Right sidebar rooted at the active live session's working directory
161
+ - Navigate directories, open files natively
162
+ - Drag files onto the input to insert their path
163
+
164
+ ### Session Browser
165
+
166
+ - Sidebar with all saved Pi JSONL session files, grouped by project
167
+ - Full-text search across all historical sessions with highlighted snippets
168
+ - Rename sessions, export to HTML
169
+
170
+ ### Mobile Support
171
+
172
+ - Slide-over sidebar with swipe-from-edge gesture
173
+ - Slimmed header, model picker stays accessible
174
+ - Larger touch targets and font sizes optimized for mobile
175
+ - Auto-reconnect WebSocket on return from background
176
+ - PWA: installable as a standalone app on iOS, Android, and macOS
177
+
178
+ ### Themes
179
+
180
+ Six built-in themes: Dusk (clean neutral dark, default), Dawn (warm blue dark), Midnight (OLED black), Clean (Apple-style light with cyan-blue accents), Terracotta (warm light), Sage (warm olive-green). All with frosted glass header and input area.
181
+
182
+ ## Configuration
183
+
184
+ ### CLI flags
185
+
186
+ | Flag | Description |
187
+ | ------------------------------------- | -----------------------------------------------------------: |
188
+ | `--open` | Open the URL in the default browser on start |
189
+ | `--port` / `TAU_PORT` | Server port (default: `3001`) |
190
+ | `--host` / `TAU_HOST` | Bind address (default: `0.0.0.0`) |
191
+ | `--projects-dir` / `TAU_PROJECTS_DIR` | Directory scanned for project chips in the new-session modal |
192
+
193
+ ### Environment variables
194
+
195
+ | Variable | Default | Description |
196
+ | ------------------ | ----------: | -------------------------------------------------------: |
197
+ | `TAU_PORT` | `3001` | Server port |
198
+ | `TAU_HOST` | `0.0.0.0` | Bind address |
199
+ | `TAU_PROJECTS_DIR` | _(none)_ | Directory scanned for project chips in the new-tab modal |
200
+ | `TAU_STATIC_DIR` | _(bundled)_ | Override static files path |
201
+ | `TAU_USER` | _(none)_ | HTTP Basic Auth username |
202
+ | `TAU_PASS` | _(none)_ | HTTP Basic Auth password |
203
+ | `TAU_COOKIE_SECRET`| _(generated)_ | Secret that signs session cookies (optional) |
204
+
205
+ Tau also reads matching values from `~/.pi/agent/settings.json` under the `tau` key (`host`, `port`, `projectsDir`, `user`, `pass`, `authEnabled`, `cookieSecret`).
206
+
207
+ ### Authentication
208
+
209
+ Tau Web Server supports optional HTTP Basic Auth. Set credentials in `~/.pi/agent/settings.json` or via environment variables, then toggle "Require login" in Tau Settings.
210
+
211
+ ```json
212
+ {
213
+ "tau": {
214
+ "user": "pi",
215
+ "pass": "your-password"
216
+ }
217
+ }
218
+ ```
219
+
220
+ Both HTTP and WebSocket connections are gated when enabled. `/api/health` remains open for monitoring.
221
+
222
+ After the first successful Basic login, the server sets a signed `HttpOnly` session cookie and accepts it in place of the Authorization header. This keeps mobile browsers (notably iOS Safari and the installed PWA, which evict cached Basic credentials whenever you switch apps) from re-prompting for the password every time you return to the app. The cookie is scoped to the browser session — killing the browser ends it and the Basic prompt appears again — and the token inside it expires after 12 hours of inactivity, renewing itself while the app is in use. Changing the password invalidates every outstanding cookie on all devices, and deleting the auto-generated `cookieSecret` from `~/.pi/agent/settings.json` force-logs-out all devices at once.
223
+
224
+ ## How it works
225
+
226
+ ```
227
+ ┌─────────────┐ ┌──────────────────────────┐ ┌───────────────────────────┐
228
+ │ Browser │◄───►│ Tau Web Server │◄───►│ pi --mode rpc │
229
+ │ (Tau UI) │ │ HTTP + WebSocket + │ │ child session 1 │
230
+ │ │ │ LiveSessionManager │ ├───────────────────────────┤
231
+ │ │ │ (Node.js) │ │ pi --mode rpc │
232
+ │ │ │ │ │ child session 2 │
233
+ │ │ │ │ ├───────────────────────────┤
234
+ │ │ │ │ │ pi --mode rpc │
235
+ │ │ │ │ │ child session (N) │
236
+ └─────────────┘ └──────────────────────────┘ └───────────────────────────┘
237
+ ```
238
+
239
+ The browser connects to Tau Web Server over WebSocket (and HTTP for history and API calls). Tau manages a pool of `PiRpcSession` instances, each of which spawns a `pi --mode rpc` subprocess. Communication with Pi is over JSON line-delimited RPC via stdin/stdout. Closing an in-page Tau tab sends a DELETE request that terminates the corresponding Pi child. Shutting down Tau terminates all managed children.
240
+
241
+ ## Development
242
+
243
+ ### Prerequisites
244
+
245
+ - [Pi](https://github.com/earendil-works/pi) must be installed (`pi` on `PATH`)
246
+ - Node.js and npm
247
+
248
+ ### Setup
249
+
250
+ ```bash
251
+ git clone https://github.com/milanglacier/pi-tau-web-server.git
252
+ cd pi-tau-web-server
253
+ npm install
254
+ npm run build
255
+ npm link
256
+ pi-tau-web-server --projects-dir ~/code
257
+ ```
258
+
259
+ The project is written in TypeScript, with separate `tsconfig.json` files:
260
+
261
+ | Config | Targets |
262
+ | ---------------------- | --------------------------------------------: |
263
+ | `tsconfig.server.json` | Server-side code (`src/server/` → `bin/`) |
264
+ | `tsconfig.public.json` | Browser-side code (`src/public/` → `public/`) |
265
+ | `tsconfig.test.json` | Test files (`test/`) |
266
+
267
+ Compiled JS is not committed to git (see `.gitignore`). Always run `npm run build` (or `tsc -p <config>`) after editing TypeScript source.
268
+
269
+ Edit `public/` files and refresh the browser. Restart `pi-tau-web-server` after changing server code in `src/server/`.
270
+
271
+ ### Tests
272
+
273
+ ```bash
274
+ npm test
275
+ ```
276
+
277
+ The test suite uses Node.js built-in `node --test` and covers session-file path validation, the `PiRpcSession` state machine, `LiveSessionManager`, the `/api/rpc` shim, HTTP + WebSocket server surface (including same-origin/CORS hardening and malformed-URL hardening), and WebSocket auth gating.
278
+
279
+ Each test file points `PI_CODING_AGENT_DIR` at an isolated temp tree so real Pi settings and sessions are never touched. The `LiveSessionManager` tests replace the real `spawn` with a test stub.
280
+
281
+ ### Project structure
282
+
283
+ ```
284
+ ├── bin/ # Compiled server-side JS
285
+ ├── public/ # Compiled browser-side JS, HTML, CSS, icons
286
+ ├── src/
287
+ │ ├── server/ # Server TypeScript source
288
+ │ │ ├── server-main.ts # HTTP server, WebSocket, API routes
289
+ │ │ ├── sessions.ts # PiRpcSession + LiveSessionManager
290
+ │ │ ├── model-utils.ts # Model parsing and formatting
291
+ │ │ ├── config.ts # Settings, paths, CLI args
292
+ │ │ └── types.ts # Shared type definitions
293
+ │ └── public/ # Browser TypeScript source
294
+ │ ├── app.ts, app-main.ts, state.ts, themes.ts, ...
295
+ │ └── websocket-client.ts
296
+ ├── test/ # Node.js test files
297
+ ├── docs/ # Screenshots and documentation
298
+ └── extras/ # Extra utilities
299
+ ```
300
+
301
+ ## License
302
+
303
+ MIT
package/bin/auth.js ADDED
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /*
3
+ * Session-cookie auth on top of HTTP Basic.
4
+ *
5
+ * iOS Safari (and the installed PWA) evicts cached Basic credentials when the
6
+ * user switches apps, so Basic alone re-prompts on every return. After a
7
+ * successful Basic request the server mints a signed, stateless session token
8
+ * carried in a browser-session cookie (no Max-Age): it survives app switching
9
+ * but ends with the browser session, and the embedded expiry bounds its life
10
+ * even if the browser restores session cookies with restored tabs.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.SESSION_REFRESH_THRESHOLD_SECONDS = exports.SESSION_TTL_SECONDS = exports.SESSION_COOKIE_NAME = void 0;
14
+ exports.issueSessionToken = issueSessionToken;
15
+ exports.verifySessionToken = verifySessionToken;
16
+ exports.parseCookies = parseCookies;
17
+ exports.buildSessionCookie = buildSessionCookie;
18
+ const crypto = require('node:crypto');
19
+ const config_js_1 = require("./config.js");
20
+ exports.SESSION_COOKIE_NAME = 'tau_session';
21
+ exports.SESSION_TTL_SECONDS = 12 * 3600;
22
+ exports.SESSION_REFRESH_THRESHOLD_SECONDS = 6 * 3600;
23
+ let cachedSecret = '';
24
+ // Lazy so installs that never enable auth never write settings.json.
25
+ function getCookieSecret() {
26
+ if (cachedSecret)
27
+ return cachedSecret;
28
+ if (config_js_1.TAU_SETTINGS.cookieSecret) {
29
+ cachedSecret = config_js_1.TAU_SETTINGS.cookieSecret;
30
+ return cachedSecret;
31
+ }
32
+ cachedSecret = crypto.randomBytes(32).toString('hex');
33
+ if (!(0, config_js_1.saveTauSetting)('cookieSecret', cachedSecret)) {
34
+ console.warn('tau: could not persist tau.cookieSecret to settings.json; session cookies will not survive a server restart (every restart will log all devices out). Set TAU_COOKIE_SECRET to work around this.');
35
+ }
36
+ return cachedSecret;
37
+ }
38
+ // Recomputed per call so tokens die when the credentials change. The newline
39
+ // separator cannot appear in either field, unlike ':' which passwords allow.
40
+ function credentialFingerprint() {
41
+ return crypto.createHash('sha256').update(config_js_1.TAU_SETTINGS.user + '\n' + config_js_1.TAU_SETTINGS.pass).digest('hex');
42
+ }
43
+ function signToken(expiresAtSeconds) {
44
+ return crypto.createHmac('sha256', getCookieSecret())
45
+ .update('v1.' + expiresAtSeconds + '.' + credentialFingerprint())
46
+ .digest('base64url');
47
+ }
48
+ function issueSessionToken(expiresAtSeconds) {
49
+ const expires = expiresAtSeconds ?? Math.floor(Date.now() / 1000) + exports.SESSION_TTL_SECONDS;
50
+ return 'v1.' + expires + '.' + signToken(expires);
51
+ }
52
+ function verifySessionToken(token) {
53
+ const invalid = { valid: false, expiresAt: 0 };
54
+ try {
55
+ const parts = String(token).split('.');
56
+ if (parts.length !== 3 || parts[0] !== 'v1')
57
+ return invalid;
58
+ const expiresAt = parseInt(parts[1], 10);
59
+ if (!Number.isFinite(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000))
60
+ return invalid;
61
+ const expected = Buffer.from(signToken(expiresAt));
62
+ const actual = Buffer.from(parts[2]);
63
+ if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual))
64
+ return invalid;
65
+ return { valid: true, expiresAt };
66
+ }
67
+ catch {
68
+ return invalid;
69
+ }
70
+ }
71
+ // Manual parser because the WebSocket upgrade path has no framework.
72
+ function parseCookies(header) {
73
+ const out = {};
74
+ if (!header)
75
+ return out;
76
+ for (const part of header.split(';')) {
77
+ const eq = part.indexOf('=');
78
+ if (eq === -1)
79
+ continue;
80
+ const name = part.slice(0, eq).trim();
81
+ if (!name)
82
+ continue;
83
+ try {
84
+ out[name] = decodeURIComponent(part.slice(eq + 1).trim());
85
+ }
86
+ catch { }
87
+ }
88
+ return out;
89
+ }
90
+ // No Max-Age/Expires on purpose: a browser-session cookie is dropped when the
91
+ // browser session ends, so killing the browser re-prompts for Basic auth.
92
+ function buildSessionCookie(token, opts) {
93
+ if (opts.clear)
94
+ return `${exports.SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
95
+ return `${exports.SESSION_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax` + (opts.secure ? '; Secure' : '');
96
+ }
package/bin/config.js ADDED
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MIME_TYPES = exports.STATIC_DIR = exports.HOST = exports.PORT = exports.AUTH_CONFIGURED = exports.TAU_SETTINGS = exports.SESSIONS_DIR = exports.PI_AGENT_DIR = exports.USER_HOME = exports.ARGS = void 0;
4
+ exports.parseArgs = parseArgs;
5
+ exports.expandHome = expandHome;
6
+ exports.loadTauSettings = loadTauSettings;
7
+ exports.saveTauSetting = saveTauSetting;
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const os = require('node:os');
11
+ function parseArgs(argv) {
12
+ const out = {};
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const arg = argv[i];
15
+ if (!arg.startsWith('--'))
16
+ continue;
17
+ const key = arg.slice(2);
18
+ if (key === 'open') {
19
+ out.open = true;
20
+ continue;
21
+ }
22
+ const next = argv[i + 1];
23
+ if (next && !next.startsWith('--')) {
24
+ out[key] = next;
25
+ i++;
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ exports.ARGS = parseArgs(process.argv.slice(2));
31
+ exports.USER_HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
32
+ exports.PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(exports.USER_HOME, '.pi', 'agent');
33
+ exports.SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR || path.join(exports.PI_AGENT_DIR, 'sessions');
34
+ function expandHome(p) {
35
+ if (!p || typeof p !== 'string')
36
+ return p;
37
+ return p.startsWith('~') ? path.join(exports.USER_HOME, p.slice(1)) : p;
38
+ }
39
+ function loadTauSettings() {
40
+ let settings = {};
41
+ try {
42
+ const settingsPath = path.join(exports.PI_AGENT_DIR, 'settings.json');
43
+ settings = (JSON.parse(fs.readFileSync(settingsPath, 'utf8')).tau || {});
44
+ }
45
+ catch { }
46
+ return {
47
+ port: parseInt(String(exports.ARGS.port || process.env.TAU_PORT || settings.port || '3001'), 10),
48
+ host: exports.ARGS.host || process.env.TAU_HOST || settings.host || '0.0.0.0',
49
+ user: process.env.TAU_USER || settings.user || '',
50
+ pass: process.env.TAU_PASS || settings.pass || '',
51
+ authEnabled: settings.authEnabled,
52
+ cookieSecret: process.env.TAU_COOKIE_SECRET || settings.cookieSecret || '',
53
+ projectsDir: expandHome(exports.ARGS['projects-dir'] || process.env.TAU_PROJECTS_DIR || settings.projectsDir || ''),
54
+ };
55
+ }
56
+ exports.TAU_SETTINGS = loadTauSettings();
57
+ exports.AUTH_CONFIGURED = !!(exports.TAU_SETTINGS.user && exports.TAU_SETTINGS.pass);
58
+ exports.PORT = exports.TAU_SETTINGS.port;
59
+ exports.HOST = exports.TAU_SETTINGS.host;
60
+ exports.STATIC_DIR = process.env.TAU_STATIC_DIR || findPublicDir();
61
+ function findPublicDir() {
62
+ const candidates = [];
63
+ const add = (p) => candidates.push(path.resolve(p));
64
+ add(path.join(__dirname, '..', 'public'));
65
+ add(path.join(process.cwd(), 'public'));
66
+ try {
67
+ const pkgPath = require.resolve('pi-tau-web-server/package.json');
68
+ add(path.join(path.dirname(pkgPath), 'public'));
69
+ }
70
+ catch { }
71
+ add(path.join(process.cwd(), 'node_modules', 'pi-tau-web-server', 'public'));
72
+ return candidates.find((c) => fs.existsSync(path.join(c, 'index.html'))) || candidates[0];
73
+ }
74
+ exports.MIME_TYPES = {
75
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
76
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
77
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
78
+ '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff',
79
+ '.woff2': 'font/woff2',
80
+ };
81
+ function saveTauSetting(key, value) {
82
+ const settingsPath = path.join(exports.PI_AGENT_DIR, 'settings.json');
83
+ try {
84
+ let settings = {};
85
+ try {
86
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
87
+ }
88
+ catch { }
89
+ if (!settings.tau)
90
+ settings.tau = {};
91
+ settings.tau[key] = value;
92
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
93
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
94
+ return true;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.modelLabel = modelLabel;
4
+ exports.normalizeModel = normalizeModel;
5
+ exports.parseModelSpecToModel = parseModelSpecToModel;
6
+ exports.parseYesNo = parseYesNo;
7
+ exports.parsePiListModels = parsePiListModels;
8
+ exports.execFileAsync = execFileAsync;
9
+ exports.getAvailableModels = getAvailableModels;
10
+ exports._setExecFileForTest = _setExecFileForTest;
11
+ exports._clearModelListCacheForTest = _clearModelListCacheForTest;
12
+ const { execFile } = require('node:child_process');
13
+ function modelLabel(model, fallback = '') {
14
+ if (!model)
15
+ return fallback || '';
16
+ if (typeof model === 'string')
17
+ return model;
18
+ if (model.provider && model.id)
19
+ return `${model.provider}/${model.id}`;
20
+ return model.id || model.name || fallback || '';
21
+ }
22
+ // Normalize any model value into the canonical form: null or a full
23
+ // {provider, id, ...} object. Bare `provider/id` strings (and "id" strings
24
+ // with no slash) are parsed into objects; anything unrecognizable becomes null.
25
+ function normalizeModel(value) {
26
+ if (!value)
27
+ return null;
28
+ if (typeof value === 'object') {
29
+ const record = value;
30
+ if (record.provider && record.id)
31
+ return { ...record };
32
+ if (record.id)
33
+ return { ...record, provider: record.provider || '' };
34
+ return null;
35
+ }
36
+ if (typeof value === 'string') {
37
+ const trimmed = value.trim();
38
+ if (!trimmed)
39
+ return null;
40
+ const slashIdx = trimmed.indexOf('/');
41
+ if (slashIdx === -1)
42
+ return { provider: '', id: trimmed };
43
+ const provider = trimmed.slice(0, slashIdx);
44
+ const id = trimmed.slice(slashIdx + 1);
45
+ if (!id)
46
+ return null;
47
+ return { provider, id };
48
+ }
49
+ return null;
50
+ }
51
+ // Parse a `provider/id[:level]` spec string (as passed on session creation or
52
+ // the model-input box) into a canonical {provider, id} object plus an optional
53
+ // thinking level. Returns {model, level} where `model` is null when unparseable.
54
+ function parseModelSpecToModel(spec) {
55
+ const trimmed = String(spec || '').trim();
56
+ if (!trimmed)
57
+ return { model: null, level: null };
58
+ let level = null;
59
+ const colonIdx = trimmed.lastIndexOf(':');
60
+ if (colonIdx !== -1) {
61
+ const candidate = trimmed.slice(colonIdx + 1).toLowerCase();
62
+ if (['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(candidate)) {
63
+ level = candidate;
64
+ }
65
+ }
66
+ const core = (colonIdx !== -1 && level) ? trimmed.slice(0, colonIdx) : trimmed;
67
+ return { model: normalizeModel(core), level };
68
+ }
69
+ function parseYesNo(value) {
70
+ const normalized = String(value || '').trim().toLowerCase();
71
+ if (normalized === 'yes')
72
+ return true;
73
+ if (normalized === 'no')
74
+ return false;
75
+ return value;
76
+ }
77
+ function parsePiListModels(output) {
78
+ const lines = String(output || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
79
+ const models = [];
80
+ for (const line of lines) {
81
+ if (/^provider\s+model\s+/i.test(line))
82
+ continue;
83
+ const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
84
+ if (parts.length < 2)
85
+ continue;
86
+ const [provider, id, context, maxOutput, thinking, images] = parts;
87
+ if (!provider || !id)
88
+ continue;
89
+ models.push({
90
+ provider,
91
+ id,
92
+ ...(context !== undefined ? { context } : {}),
93
+ ...(maxOutput !== undefined ? { maxOutput } : {}),
94
+ ...(thinking !== undefined ? { thinking: parseYesNo(thinking) } : {}),
95
+ ...(images !== undefined ? { images: parseYesNo(images) } : {}),
96
+ });
97
+ }
98
+ return models;
99
+ }
100
+ const MODEL_LIST_CACHE_MS = 5 * 60 * 1000;
101
+ let modelListCache = { at: 0, models: [] };
102
+ let _execFileForTest = null;
103
+ function execFileAsync(file, args, opts) {
104
+ const runner = _execFileForTest || execFile;
105
+ return new Promise((resolve, reject) => {
106
+ runner(file, args, opts, (err, stdout, stderr) => {
107
+ if (err) {
108
+ err.stderr = stderr;
109
+ reject(err);
110
+ return;
111
+ }
112
+ resolve({ stdout, stderr });
113
+ });
114
+ });
115
+ }
116
+ async function getAvailableModels() {
117
+ const now = Date.now();
118
+ if (modelListCache.at && now - modelListCache.at < MODEL_LIST_CACHE_MS) {
119
+ return modelListCache.models;
120
+ }
121
+ try {
122
+ const { stdout } = await execFileAsync('pi', ['--list-models'], { timeout: 30000, encoding: 'utf8' });
123
+ const models = parsePiListModels(stdout);
124
+ modelListCache = { at: now, models };
125
+ return models;
126
+ }
127
+ catch (err) {
128
+ console.warn('[Tau] Failed to list Pi models:', err instanceof Error ? err.message : err);
129
+ modelListCache = { at: now, models: modelListCache.models || [] };
130
+ return modelListCache.models;
131
+ }
132
+ }
133
+ function _setExecFileForTest(fn) { _execFileForTest = fn || null; modelListCache = { at: 0, models: [] }; }
134
+ function _clearModelListCacheForTest() { modelListCache = { at: 0, models: [] }; }