ai-or-die 0.1.66 → 0.1.68
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 +12 -7
- package/bin/ai-or-die.js +13 -6
- package/bin/supervisor.js +150 -16
- package/package.json +6 -1
- package/src/public/app.js +123 -0
- package/src/public/icon-144.png +0 -0
- package/src/public/icon-16.png +0 -0
- package/src/public/icon-180.png +0 -0
- package/src/public/icon-192.png +0 -0
- package/src/public/icon-32.png +0 -0
- package/src/public/icon-512.png +0 -0
- package/src/public/index.html +4 -1
- package/src/public/manifest.json +1 -1
- package/src/public/plan-detector.js +21 -5
- package/src/public/voice-handler.js +1 -1
- package/src/server.js +641 -67
- package/src/stt-engine.js +18 -0
- package/src/terminal-bridge.js +97 -20
- package/src/usage-reader.js +203 -12
- package/src/utils/eviction-heap.js +119 -0
- package/src/utils/file-watcher.js +164 -6
- package/src/utils/log-rotator.js +254 -0
- package/src/utils/session-store.js +196 -12
- package/src/vscode-tunnel.js +113 -89
package/README.md
CHANGED
|
@@ -112,9 +112,12 @@ Download from [Releases](https://github.com/animeshkundu/ai-or-die/releases) —
|
|
|
112
112
|
## Usage
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
# Default —
|
|
115
|
+
# Default — prints the URL (with secure token); does NOT auto-open a browser
|
|
116
116
|
ai-or-die
|
|
117
117
|
|
|
118
|
+
# Open the browser automatically on start
|
|
119
|
+
ai-or-die --open
|
|
120
|
+
|
|
118
121
|
# Custom port
|
|
119
122
|
ai-or-die --port 8080
|
|
120
123
|
|
|
@@ -146,11 +149,11 @@ ai-or-die --stt
|
|
|
146
149
|
| `--disable-auth` | Disable authentication | `false` |
|
|
147
150
|
| `--tunnel` | Enable Microsoft Dev Tunnel | `false` |
|
|
148
151
|
| `--tunnel-allow-anonymous` | Allow anonymous tunnel access | `false` |
|
|
149
|
-
| `--https` | Enable HTTPS | `false` |
|
|
152
|
+
| `--https` | Enable HTTPS (auto-generates a self-signed cert if `--cert`/`--key` not given) | `false` |
|
|
150
153
|
| `--cert <path>` | SSL certificate file | |
|
|
151
154
|
| `--key <path>` | SSL private key file | |
|
|
152
155
|
| `--dev` | Verbose logging | `false` |
|
|
153
|
-
| `--
|
|
156
|
+
| `--open` | Open the browser on start (never on supervised restart) | `false` |
|
|
154
157
|
| `--plan <type>` | Subscription plan (`pro`, `max5`, `max20`) | `max20` |
|
|
155
158
|
| `--stt` | Enable local speech-to-text | `false` |
|
|
156
159
|
| `--stt-endpoint <url>` | External STT endpoint (OpenAI-compatible) | |
|
|
@@ -190,11 +193,13 @@ ai-or-die is an installable progressive web app. Open Settings → Install in th
|
|
|
190
193
|
|
|
191
194
|
**Browser support**: Chrome / Edge / Samsung Internet support one-tap install. Firefox desktop does not have native install — use the browser menu to pin the tab. Safari iOS uses Share → Add to Home Screen.
|
|
192
195
|
|
|
193
|
-
**Installing on LAN devices**: Browsers refuse to install PWAs over an HTTPS origin whose certificate isn't trusted by the device.
|
|
196
|
+
**Installing on LAN devices**: Browsers refuse to install PWAs over an HTTPS origin whose certificate isn't trusted by the device, and public CAs (Let's Encrypt etc.) won't issue certs for a LAN IP or `localhost`. The `--https` self-signed cert therefore triggers a browser warning, and the in-app Install panel will say *"Not available in this browser."* on a LAN IP. Ways to get an installable origin:
|
|
197
|
+
|
|
198
|
+
1. **On the host machine itself** — just open **`http://localhost:<port>`**. `localhost` is a secure context, so the PWA installs with **no cert and no setup** (no `--https` needed).
|
|
199
|
+
2. **On other devices** — use **`--tunnel`** *(recommended)*: Microsoft Dev Tunnels gives a public URL with a real publicly-trusted cert, so any device installs the PWA with **no per-device setup and no admin**.
|
|
200
|
+
3. **Bring your own trusted cert** via `--cert` / `--key` (e.g. a cert for a hostname your devices already trust).
|
|
194
201
|
|
|
195
|
-
|
|
196
|
-
2. **Trust the self-signed cert** on each device — copy `~/.ai-or-die/certs/server.cert` to the device and install it as a trusted root in the OS certificate store.
|
|
197
|
-
3. **Provide a CA-signed cert** via `--cert` / `--key`. [mkcert](https://github.com/FiloSottile/mkcert) is a low-friction option for development.
|
|
202
|
+
See [docs/history/pwa-install-lan-self-signed-cert.md](docs/history/pwa-install-lan-self-signed-cert.md) for the underlying browser-policy reasoning.
|
|
198
203
|
|
|
199
204
|
See [docs/history/pwa-install-lan-self-signed-cert.md](docs/history/pwa-install-lan-self-signed-cert.md) for the device-by-device procedure and the underlying browser-policy reasoning.
|
|
200
205
|
|
package/bin/ai-or-die.js
CHANGED
|
@@ -20,7 +20,7 @@ program
|
|
|
20
20
|
.description('ai-or-die — Universal AI coding terminal')
|
|
21
21
|
.version(require('../package.json').version)
|
|
22
22
|
.option('-p, --port <number>', 'port to run the server on', '7777')
|
|
23
|
-
.option('--
|
|
23
|
+
.option('--open', 'open the browser on start (default: off; never on supervised restart)')
|
|
24
24
|
.option('--auth <token>', 'authentication token for secure access')
|
|
25
25
|
.option('--disable-auth', 'disable authentication (not recommended for production)')
|
|
26
26
|
.option('--https', 'enable HTTPS (auto-generates self-signed cert if --cert/--key not provided)')
|
|
@@ -38,8 +38,11 @@ program
|
|
|
38
38
|
.option('--stt', 'enable local speech-to-text (downloads ~670MB Parakeet V3 model on first use)')
|
|
39
39
|
.option('--stt-endpoint <url>', 'use external STT endpoint (OpenAI-compatible)')
|
|
40
40
|
.option('--stt-model-dir <path>', 'custom directory for STT model files')
|
|
41
|
-
.option('--stt-threads <number>', 'CPU threads for STT inference (default: auto, max 4)')
|
|
42
|
-
|
|
41
|
+
.option('--stt-threads <number>', 'CPU threads for STT inference (default: auto, max 4)');
|
|
42
|
+
|
|
43
|
+
// Auto-open is OFF by default and opt-in via --open. Legacy callers may still pass
|
|
44
|
+
// --no-open (the old opt-out flag); filter it out so it parses harmlessly as a no-op.
|
|
45
|
+
program.parse(process.argv.filter((arg) => arg !== '--no-open'));
|
|
43
46
|
|
|
44
47
|
const options = program.opts();
|
|
45
48
|
|
|
@@ -131,7 +134,11 @@ async function main() {
|
|
|
131
134
|
console.log(' For LAN access, restart with \x1b[1m--https\x1b[0m or \x1b[1m--tunnel\x1b[0m.');
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
// Dev tunnel or browser open
|
|
137
|
+
// Dev tunnel or browser open.
|
|
138
|
+
// Auto-open only when explicitly requested (--open) AND this is the first launch,
|
|
139
|
+
// never on a supervised restart (the supervisor sets AOD_SUPERVISOR_RESTART on respawn),
|
|
140
|
+
// so crash/memory restarts don't spawn a new browser tab each time.
|
|
141
|
+
const shouldOpen = !!options.open && !process.env.AOD_SUPERVISOR_RESTART;
|
|
135
142
|
let tunnel = null;
|
|
136
143
|
if (options.tunnel) {
|
|
137
144
|
const { TunnelManager } = require('../src/tunnel-manager');
|
|
@@ -141,12 +148,12 @@ async function main() {
|
|
|
141
148
|
dev: options.dev,
|
|
142
149
|
onUrl: (tunnelUrl) => {
|
|
143
150
|
console.log(`\n \x1b[1m\x1b[32mTunnel ready:\x1b[0m \x1b[1m\x1b[4m${tunnelUrl}\x1b[0m\n`);
|
|
144
|
-
if (open &&
|
|
151
|
+
if (open && shouldOpen) open(tunnelUrl).catch(() => {});
|
|
145
152
|
}
|
|
146
153
|
});
|
|
147
154
|
app.setTunnelManager(tunnel);
|
|
148
155
|
await tunnel.start();
|
|
149
|
-
} else if (
|
|
156
|
+
} else if (shouldOpen) {
|
|
150
157
|
try { if (open) await open(url); } catch (error) {
|
|
151
158
|
console.warn(' Could not automatically open browser:', error.message);
|
|
152
159
|
}
|
package/bin/supervisor.js
CHANGED
|
@@ -6,11 +6,38 @@ const { spawn } = require('child_process');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { RESTART_EXIT_CODE } = require('../src/restart-manager');
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Tunables — all overridable via env vars so the regression test can shrink
|
|
11
|
+
// the windows from hours/minutes to ms. See docs/audits/proc-supervisor-breaker.md
|
|
12
|
+
// for the rationale behind every default.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const RESTART_DELAY_MS = parseInt(process.env.RESTART_DELAY_MS, 10) || 1000; // clean RESTART_EXIT_CODE respawn
|
|
16
|
+
const CRASH_RESTART_DELAY_MS = parseInt(process.env.CRASH_RESTART_DELAY_MS, 10) || 3000; // normal crash respawn
|
|
17
|
+
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS, 10) || 10000; // SIGINT/SIGTERM hard-kill fallback
|
|
18
|
+
|
|
19
|
+
// Tier 1 — tight crash loop. 3 crashes in 30 s historically tripped a hard
|
|
20
|
+
// process.exit(1). The fix replaces that with an extended restart delay
|
|
21
|
+
// (and a loud log) so the daemon ALWAYS comes back; permanent halt strands
|
|
22
|
+
// the user's single browser session with no way to recover short of SSH.
|
|
23
|
+
const CIRCUIT_BREAKER_WINDOW_MS = parseInt(process.env.CIRCUIT_BREAKER_WINDOW_MS, 10) || 30000; // 30 s
|
|
24
|
+
const CIRCUIT_BREAKER_MAX_CRASHES = parseInt(process.env.CIRCUIT_BREAKER_MAX_CRASHES, 10) || 3;
|
|
25
|
+
const TIER1_RESTART_DELAY_MS = parseInt(process.env.TIER1_RESTART_DELAY_MS, 10) || 60000; // 1 min
|
|
26
|
+
|
|
27
|
+
// Tier 2 — sustained slow churn. The old breaker missed this entirely:
|
|
28
|
+
// a server that crashed once every 31 s respawned forever at the normal
|
|
29
|
+
// cadence, masking the underlying bug. Tier 2 catches 5 crashes in 1 h
|
|
30
|
+
// and slows respawn to 5 min, dropping log volume by ~100×.
|
|
31
|
+
const SUSTAINED_CRASH_WINDOW_MS = parseInt(process.env.SUSTAINED_CRASH_WINDOW_MS, 10) || 3600000; // 1 h
|
|
32
|
+
const SUSTAINED_CRASH_MAX = parseInt(process.env.SUSTAINED_CRASH_MAX, 10) || 5;
|
|
33
|
+
const TIER2_RESTART_DELAY_MS = parseInt(process.env.TIER2_RESTART_DELAY_MS, 10) || 300000; // 5 min
|
|
34
|
+
|
|
35
|
+
// Hard cap on the crashTimestamps array so a pathological 100/sec crash loop
|
|
36
|
+
// over an hour can't grow it to 360 k entries. 1024 is comfortably more than
|
|
37
|
+
// any realistic backoff cadence would produce in 1 h (even at tier-2's
|
|
38
|
+
// minimum 5-min cadence that's 12 entries/h; at tier-1's 60-s cadence it's
|
|
39
|
+
// 60/h). Trimming oldest-first preserves the most-recent-N invariant.
|
|
40
|
+
const CRASH_TIMESTAMPS_CAP = parseInt(process.env.CRASH_TIMESTAMPS_CAP, 10) || 1024;
|
|
14
41
|
|
|
15
42
|
const serverScript = process.env.SUPERVISOR_CHILD_SCRIPT
|
|
16
43
|
|| path.join(__dirname, 'ai-or-die.js');
|
|
@@ -18,18 +45,112 @@ const forwardedArgs = process.argv.slice(2);
|
|
|
18
45
|
|
|
19
46
|
let child = null;
|
|
20
47
|
let shuttingDown = false;
|
|
48
|
+
let spawnCount = 0;
|
|
21
49
|
let crashTimestamps = [];
|
|
22
50
|
let pendingRestartTimer = null;
|
|
23
51
|
|
|
52
|
+
// Queued IPC message delivered to the NEXT spawned child once its IPC channel
|
|
53
|
+
// is open. Used to forward tier-2 escalation downstream so the in-process
|
|
54
|
+
// server can surface it to the browser ("supervisor is throttling restarts").
|
|
55
|
+
let pendingWarning = null;
|
|
56
|
+
|
|
57
|
+
// Test-only: when SUPERVISOR_ESCALATION_OBSERVER=1, the supervisor emits a
|
|
58
|
+
// {type:'supervisor_escalation', tier, count, ...} IPC message to ITS parent
|
|
59
|
+
// after each classification, so a regression test can deterministically watch
|
|
60
|
+
// tier transitions without parsing log strings. Production runs leave it null.
|
|
61
|
+
let escalationObserver = process.env.SUPERVISOR_ESCALATION_OBSERVER === '1'
|
|
62
|
+
? (info) => { try { if (process.send) process.send({ type: 'supervisor_escalation', ...info }); } catch (_) {} }
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
function classifyCrash(now) {
|
|
66
|
+
// Trim to the longer of the two windows so the array stays bounded.
|
|
67
|
+
const cutoff = now - Math.max(CIRCUIT_BREAKER_WINDOW_MS, SUSTAINED_CRASH_WINDOW_MS);
|
|
68
|
+
crashTimestamps = crashTimestamps.filter((t) => t >= cutoff);
|
|
69
|
+
|
|
70
|
+
// Defence-in-depth cap (SUP-REL review). The time-window trim already
|
|
71
|
+
// bounds the array to "crashes in the last hour"; this guards against
|
|
72
|
+
// an extreme pathological case (e.g. CRASH_RESTART_DELAY_MS overridden
|
|
73
|
+
// to 0 in a test, or a future env-var injection raising the window).
|
|
74
|
+
// Keep the most-recent entries — classification only ever cares about
|
|
75
|
+
// the head of the array.
|
|
76
|
+
if (crashTimestamps.length > CRASH_TIMESTAMPS_CAP) {
|
|
77
|
+
crashTimestamps = crashTimestamps.slice(-CRASH_TIMESTAMPS_CAP);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const inSustained = crashTimestamps.filter((t) => now - t < SUSTAINED_CRASH_WINDOW_MS).length;
|
|
81
|
+
const inTight = crashTimestamps.filter((t) => now - t < CIRCUIT_BREAKER_WINDOW_MS).length;
|
|
82
|
+
|
|
83
|
+
// Higher tier wins.
|
|
84
|
+
if (inSustained >= SUSTAINED_CRASH_MAX) {
|
|
85
|
+
return { tier: 2, count: inSustained, windowMs: SUSTAINED_CRASH_WINDOW_MS, delayMs: TIER2_RESTART_DELAY_MS };
|
|
86
|
+
}
|
|
87
|
+
if (inTight >= CIRCUIT_BREAKER_MAX_CRASHES) {
|
|
88
|
+
return { tier: 1, count: inTight, windowMs: CIRCUIT_BREAKER_WINDOW_MS, delayMs: TIER1_RESTART_DELAY_MS };
|
|
89
|
+
}
|
|
90
|
+
return { tier: 0, count: inTight, windowMs: CIRCUIT_BREAKER_WINDOW_MS, delayMs: CRASH_RESTART_DELAY_MS };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function logEscalation(decision) {
|
|
94
|
+
if (decision.tier === 2) {
|
|
95
|
+
console.error(
|
|
96
|
+
`\n[supervisor] ⚠ TIER 2 ESCALATION: ${decision.count} crashes within ` +
|
|
97
|
+
`${Math.round(decision.windowMs / 60000)}m. Throttling restart to ` +
|
|
98
|
+
`${Math.round(decision.delayMs / 60000)}m. Underlying defect is likely real — ` +
|
|
99
|
+
`inspect server logs.\n`
|
|
100
|
+
);
|
|
101
|
+
} else if (decision.tier === 1) {
|
|
102
|
+
console.error(
|
|
103
|
+
`\n[supervisor] ⚠ TIER 1 ESCALATION: ${decision.count} crashes within ` +
|
|
104
|
+
`${Math.round(decision.windowMs / 1000)}s. Throttling restart to ` +
|
|
105
|
+
`${Math.round(decision.delayMs / 1000)}s.\n`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
24
110
|
function startServer() {
|
|
25
111
|
pendingRestartTimer = null;
|
|
26
112
|
const nodeArgs = ['--expose-gc', serverScript, ...forwardedArgs];
|
|
27
113
|
|
|
114
|
+
// Mark every spawn after the first as a supervised restart, so the child suppresses
|
|
115
|
+
// browser auto-open (--open) on crash/memory restarts and only opens on first launch.
|
|
116
|
+
const isRestart = spawnCount > 0;
|
|
117
|
+
spawnCount += 1;
|
|
118
|
+
|
|
28
119
|
child = spawn(process.execPath, nodeArgs, {
|
|
29
120
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
30
|
-
env: {
|
|
121
|
+
env: {
|
|
122
|
+
...process.env,
|
|
123
|
+
SUPERVISED: '1',
|
|
124
|
+
...(isRestart ? { AOD_SUPERVISOR_RESTART: '1' } : {})
|
|
125
|
+
}
|
|
31
126
|
});
|
|
32
127
|
|
|
128
|
+
// Flush a queued supervisor_warning into the new child's IPC channel.
|
|
129
|
+
// SUP-REL review: the immediately-after-spawn `child.connected` is false
|
|
130
|
+
// (the IPC handshake hasn't completed yet), so this block used to silently
|
|
131
|
+
// drop the warning. Defer via the 'spawn' event, which Node fires AFTER
|
|
132
|
+
// the child has been successfully spawned and the IPC channel is wired.
|
|
133
|
+
// Future CLIENT-04 server-side wiring will then receive it deterministically.
|
|
134
|
+
if (pendingWarning) {
|
|
135
|
+
const warning = pendingWarning;
|
|
136
|
+
pendingWarning = null;
|
|
137
|
+
const flush = () => {
|
|
138
|
+
try {
|
|
139
|
+
if (child && child.connected) child.send(warning);
|
|
140
|
+
} catch (_) { /* IPC may have closed between spawn and send — best-effort */ }
|
|
141
|
+
};
|
|
142
|
+
// Node ≥ 16: 'spawn' event fires once when spawn succeeds. If the child
|
|
143
|
+
// already crashed before 'spawn' fires, we never send; that's correct
|
|
144
|
+
// behaviour — the next-next child will get its own warning if the crash
|
|
145
|
+
// sequence re-escalates.
|
|
146
|
+
if (typeof child.once === 'function') {
|
|
147
|
+
child.once('spawn', flush);
|
|
148
|
+
} else {
|
|
149
|
+
// Defensive: pre-Node-16 fallback (unsupported but harmless).
|
|
150
|
+
process.nextTick(flush);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
33
154
|
child.on('exit', (code, signal) => {
|
|
34
155
|
child = null;
|
|
35
156
|
|
|
@@ -52,21 +173,34 @@ function startServer() {
|
|
|
52
173
|
return;
|
|
53
174
|
}
|
|
54
175
|
|
|
55
|
-
// Unexpected exit —
|
|
176
|
+
// Unexpected exit — classify against both windows.
|
|
56
177
|
const now = Date.now();
|
|
57
178
|
crashTimestamps.push(now);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
179
|
+
const decision = classifyCrash(now);
|
|
180
|
+
|
|
181
|
+
if (decision.tier > 0) {
|
|
182
|
+
logEscalation(decision);
|
|
183
|
+
// Queue a downstream warning so the next-spawned server can surface
|
|
184
|
+
// it to the browser UI. (Receiver-side wiring is a future task — for
|
|
185
|
+
// now this is a no-op on the child side but adds zero risk.)
|
|
186
|
+
pendingWarning = {
|
|
187
|
+
type: 'supervisor_warning',
|
|
188
|
+
tier: decision.tier,
|
|
189
|
+
crashes: decision.count,
|
|
190
|
+
windowMs: decision.windowMs,
|
|
191
|
+
nextDelayMs: decision.delayMs,
|
|
192
|
+
};
|
|
65
193
|
}
|
|
66
194
|
|
|
195
|
+
if (escalationObserver) escalationObserver(decision);
|
|
196
|
+
|
|
67
197
|
const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
|
|
68
|
-
console.warn(
|
|
69
|
-
|
|
198
|
+
console.warn(
|
|
199
|
+
`[supervisor] Server exited unexpectedly (${exitInfo}), restarting in ` +
|
|
200
|
+
`${decision.delayMs}ms... (crash ${decision.count} in ` +
|
|
201
|
+
`${Math.round(decision.windowMs / 1000)}s window, tier ${decision.tier})`
|
|
202
|
+
);
|
|
203
|
+
pendingRestartTimer = setTimeout(startServer, decision.delayMs);
|
|
70
204
|
});
|
|
71
205
|
|
|
72
206
|
child.on('error', (err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
4
4
|
"description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"test": "mocha --exit test/*.test.js",
|
|
16
16
|
"test:integration": "mocha --exit --timeout 60000 test/supervisor-integration.test.js test/integration/*.test.js",
|
|
17
17
|
"test:browser": "npx playwright test --config e2e/playwright.config.js",
|
|
18
|
+
"soak": "node test/longevity/harness/cli.js",
|
|
19
|
+
"test:longevity-smoke": "mocha --exit --timeout 120000 test/longevity/smoke.test.js",
|
|
20
|
+
"test:longevity:server": "mocha --exit --timeout 120000 --recursive --extension .test.js test/longevity/event-loop test/longevity/disk test/longevity/process test/longevity/browser-sampler.test.js test/longevity/cli.test.js test/longevity/gate-evaluator-directional.test.js test/longevity/gate-evaluator-vacuous.test.js test/longevity/resume.test.js test/longevity/smoke.test.js",
|
|
21
|
+
"test:longevity:browser": "playwright test --config test/longevity/playwright.config.js",
|
|
22
|
+
"test:longevity": "npm run test:longevity:server && npm run test:longevity:browser",
|
|
18
23
|
"build:bundle": "node scripts/build-sea.js bundle",
|
|
19
24
|
"build:sea": "node scripts/build-sea.js",
|
|
20
25
|
"release:pr": "bash scripts/release-pr.sh"
|
package/src/public/app.js
CHANGED
|
@@ -3756,6 +3756,14 @@ class ClaudeCodeWebInterface {
|
|
|
3756
3756
|
case 'dismissed':
|
|
3757
3757
|
statusText.textContent = 'Install was cancelled. Reload the page to try again.';
|
|
3758
3758
|
break;
|
|
3759
|
+
case 'unavailable':
|
|
3760
|
+
// Chromium-family browser in a secure context, but beforeinstallprompt
|
|
3761
|
+
// hasn't fired yet (engagement-gated, or already consumed). The app can't
|
|
3762
|
+
// trigger install itself, but the browser's own menu can — and the
|
|
3763
|
+
// beforeinstallprompt listener stays active, so this upgrades to 'available'
|
|
3764
|
+
// if the event fires later.
|
|
3765
|
+
statusText.textContent = 'If no install button appears, use your browser menu → "Install this site as an app".';
|
|
3766
|
+
break;
|
|
3759
3767
|
default:
|
|
3760
3768
|
statusText.textContent = 'Not available in this browser.';
|
|
3761
3769
|
break;
|
|
@@ -5781,3 +5789,118 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
5781
5789
|
window.app = app;
|
|
5782
5790
|
app.startHeartbeat();
|
|
5783
5791
|
});
|
|
5792
|
+
|
|
5793
|
+
// CLIENT-03 (stability-hardening-2026): browser-side diagnostics surface.
|
|
5794
|
+
// Mirrors the server `/api/diagnostics` shape so SUP-SOAK's browser soak
|
|
5795
|
+
// can sample uniformly. Installed at module level so it is callable from
|
|
5796
|
+
// the moment app.js finishes loading — independent of when `window.app`
|
|
5797
|
+
// is constructed or whether a session has been joined. All sub-collectors
|
|
5798
|
+
// are wrapped in try/catch so the function never throws; safe to call
|
|
5799
|
+
// pre-session. Returns a Promise (the optional
|
|
5800
|
+
// `performance.measureUserAgentSpecificMemory()` call is async).
|
|
5801
|
+
// Idempotent: re-loading app.js (e.g. HMR) overwrites the previous
|
|
5802
|
+
// install — last loader wins.
|
|
5803
|
+
// Spec: docs/specs/client-longevity.md
|
|
5804
|
+
window.__diagnostics = async function __diagnostics() {
|
|
5805
|
+
const ts = Date.now();
|
|
5806
|
+
const snap = {
|
|
5807
|
+
ts: ts,
|
|
5808
|
+
dom: { total_nodes: 0 },
|
|
5809
|
+
buffers: { plan_detector_bytes: 0, xterm_scrollback_lines: 0 },
|
|
5810
|
+
ws: { state: null, url: null },
|
|
5811
|
+
sse: { connected: false, streams: 0 },
|
|
5812
|
+
memory: null
|
|
5813
|
+
};
|
|
5814
|
+
|
|
5815
|
+
// dom.total_nodes
|
|
5816
|
+
try {
|
|
5817
|
+
snap.dom.total_nodes = document.querySelectorAll('*').length;
|
|
5818
|
+
} catch (_) { /* leave 0 */ }
|
|
5819
|
+
|
|
5820
|
+
// dom.listeners_tracked — only emit if a tracker exists. No tracker
|
|
5821
|
+
// ships today; SUP-SOAK must tolerate absence per spec v1.
|
|
5822
|
+
try {
|
|
5823
|
+
if (typeof window.__listenerCount === 'number') {
|
|
5824
|
+
snap.dom.listeners_tracked = window.__listenerCount;
|
|
5825
|
+
}
|
|
5826
|
+
} catch (_) { /* leave omitted */ }
|
|
5827
|
+
|
|
5828
|
+
// buffers.plan_detector_bytes — prefer the CLIENT-01 `bufferBytes`
|
|
5829
|
+
// field; fall back to inline sum if running against an older detector.
|
|
5830
|
+
try {
|
|
5831
|
+
const pd = window.app && window.app.planDetector;
|
|
5832
|
+
if (pd) {
|
|
5833
|
+
if (typeof pd.bufferBytes === 'number') {
|
|
5834
|
+
snap.buffers.plan_detector_bytes = pd.bufferBytes;
|
|
5835
|
+
} else if (Array.isArray(pd.outputBuffer)) {
|
|
5836
|
+
let sum = 0;
|
|
5837
|
+
for (let i = 0; i < pd.outputBuffer.length; i++) {
|
|
5838
|
+
const e = pd.outputBuffer[i];
|
|
5839
|
+
if (e && typeof e.data === 'string') sum += e.data.length;
|
|
5840
|
+
}
|
|
5841
|
+
snap.buffers.plan_detector_bytes = sum;
|
|
5842
|
+
}
|
|
5843
|
+
}
|
|
5844
|
+
} catch (_) { /* leave 0 */ }
|
|
5845
|
+
|
|
5846
|
+
// buffers.xterm_scrollback_lines — xterm.js buffer line count.
|
|
5847
|
+
try {
|
|
5848
|
+
const term = window.app && window.app.terminal;
|
|
5849
|
+
if (term && term.buffer && term.buffer.active
|
|
5850
|
+
&& typeof term.buffer.active.length === 'number') {
|
|
5851
|
+
snap.buffers.xterm_scrollback_lines = term.buffer.active.length;
|
|
5852
|
+
}
|
|
5853
|
+
} catch (_) { /* leave 0 */ }
|
|
5854
|
+
|
|
5855
|
+
// ws.state / ws.url
|
|
5856
|
+
try {
|
|
5857
|
+
const sock = window.app && window.app.socket;
|
|
5858
|
+
if (sock) {
|
|
5859
|
+
if (typeof sock.readyState === 'number') snap.ws.state = sock.readyState;
|
|
5860
|
+
if (typeof sock.url === 'string') snap.ws.url = sock.url;
|
|
5861
|
+
}
|
|
5862
|
+
} catch (_) { /* leave null */ }
|
|
5863
|
+
|
|
5864
|
+
// sse — best-effort walk of known holders. No global EventSource count
|
|
5865
|
+
// is exposed by browsers, so this is a lower bound.
|
|
5866
|
+
try {
|
|
5867
|
+
let streams = 0;
|
|
5868
|
+
const candidates = [
|
|
5869
|
+
window.app && window.app._fileBrowserPanel
|
|
5870
|
+
&& window.app._fileBrowserPanel._fileWatcher
|
|
5871
|
+
&& window.app._fileBrowserPanel._fileWatcher._eventSource,
|
|
5872
|
+
window.app && window.app._fileSearchPanel
|
|
5873
|
+
&& window.app._fileSearchPanel._eventSource,
|
|
5874
|
+
window.app && window.app._fileWatcher
|
|
5875
|
+
&& window.app._fileWatcher._eventSource,
|
|
5876
|
+
];
|
|
5877
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
5878
|
+
const es = candidates[i];
|
|
5879
|
+
// EventSource.OPEN === 1; CONNECTING === 0; CLOSED === 2.
|
|
5880
|
+
if (es && typeof es.readyState === 'number' && es.readyState !== 2) {
|
|
5881
|
+
streams++;
|
|
5882
|
+
}
|
|
5883
|
+
}
|
|
5884
|
+
snap.sse.streams = streams;
|
|
5885
|
+
snap.sse.connected = streams > 0;
|
|
5886
|
+
} catch (_) { /* leave defaults */ }
|
|
5887
|
+
|
|
5888
|
+
// memory — cross-origin-isolated Chrome only; fall back to
|
|
5889
|
+
// navigator.deviceMemory; else null.
|
|
5890
|
+
try {
|
|
5891
|
+
if (typeof performance !== 'undefined'
|
|
5892
|
+
&& typeof performance.measureUserAgentSpecificMemory === 'function') {
|
|
5893
|
+
try {
|
|
5894
|
+
snap.memory = await performance.measureUserAgentSpecificMemory();
|
|
5895
|
+
} catch (_) {
|
|
5896
|
+
if (typeof navigator !== 'undefined' && typeof navigator.deviceMemory === 'number') {
|
|
5897
|
+
snap.memory = { deviceMemoryGB: navigator.deviceMemory };
|
|
5898
|
+
}
|
|
5899
|
+
}
|
|
5900
|
+
} else if (typeof navigator !== 'undefined' && typeof navigator.deviceMemory === 'number') {
|
|
5901
|
+
snap.memory = { deviceMemoryGB: navigator.deviceMemory };
|
|
5902
|
+
}
|
|
5903
|
+
} catch (_) { /* leave null */ }
|
|
5904
|
+
|
|
5905
|
+
return snap;
|
|
5906
|
+
};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/public/index.html
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
<meta name="format-detection" content="telephone=no">
|
|
17
17
|
|
|
18
18
|
<!-- Web App Manifest -->
|
|
19
|
-
|
|
19
|
+
<!-- crossorigin=use-credentials: send the session cookie with the manifest (and its icon)
|
|
20
|
+
fetches so they are not redirected to an auth page behind a credentialed proxy/tunnel
|
|
21
|
+
(e.g. Microsoft devtunnel), which otherwise 404s the manifest and breaks installability. -->
|
|
22
|
+
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
|
|
20
23
|
|
|
21
24
|
<!-- Icons for various platforms -->
|
|
22
25
|
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
package/src/public/manifest.json
CHANGED
|
@@ -2,12 +2,18 @@ class PlanDetector {
|
|
|
2
2
|
constructor() {
|
|
3
3
|
this.isMonitoring = false;
|
|
4
4
|
this.outputBuffer = [];
|
|
5
|
+
// Byte-count cap (data.length is a faithful proxy for V8 string heap
|
|
6
|
+
// cost — see docs/audits/client-plan-detector.md). 8 MB hard cap; FIFO
|
|
7
|
+
// eviction when exceeded. Replaces the older 10 000-item cap, which
|
|
8
|
+
// permitted ~80 MB of retained string memory per tab under sustained
|
|
9
|
+
// heavy PTY output.
|
|
10
|
+
this.maxBufferBytes = 8 * 1024 * 1024;
|
|
11
|
+
this.bufferBytes = 0;
|
|
5
12
|
this.planModeActive = false;
|
|
6
13
|
this.currentPlan = null;
|
|
7
14
|
this.currentTool = null;
|
|
8
15
|
this.planStartMarker = '## Implementation Plan:';
|
|
9
16
|
this.planEndMarker = 'User has approved your plan';
|
|
10
|
-
this.maxBufferSize = 10000;
|
|
11
17
|
this.onPlanDetected = null;
|
|
12
18
|
this.onPlanModeChange = null;
|
|
13
19
|
this.onStepProgress = null;
|
|
@@ -64,11 +70,18 @@ class PlanDetector {
|
|
|
64
70
|
timestamp: Date.now(),
|
|
65
71
|
data: data
|
|
66
72
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
this.bufferBytes += data.length;
|
|
74
|
+
|
|
75
|
+
// FIFO eviction: pop oldest until under the byte cap. O(k) per call
|
|
76
|
+
// where k is the number of entries to evict — usually 1 unless a
|
|
77
|
+
// single huge chunk pushes us multiple entries over.
|
|
78
|
+
while (this.bufferBytes > this.maxBufferBytes && this.outputBuffer.length > 0) {
|
|
79
|
+
const evicted = this.outputBuffer.shift();
|
|
80
|
+
this.bufferBytes -= evicted.data.length;
|
|
71
81
|
}
|
|
82
|
+
// Defensive: clamp accounting drift caused by arithmetic on unusual
|
|
83
|
+
// string types (should never happen, but cheap insurance).
|
|
84
|
+
if (this.bufferBytes < 0) this.bufferBytes = 0;
|
|
72
85
|
|
|
73
86
|
// Stage 1: Quick trigger scan on the new chunk only (O(k) where k = chunk size).
|
|
74
87
|
// Prepend overlap from the previous chunk to catch triggers spanning boundaries.
|
|
@@ -405,6 +418,7 @@ class PlanDetector {
|
|
|
405
418
|
startMonitoring() {
|
|
406
419
|
this.isMonitoring = true;
|
|
407
420
|
this.outputBuffer = [];
|
|
421
|
+
this.bufferBytes = 0;
|
|
408
422
|
this.planModeActive = false;
|
|
409
423
|
this.currentPlan = null;
|
|
410
424
|
this._lastChunkTail = '';
|
|
@@ -413,6 +427,7 @@ class PlanDetector {
|
|
|
413
427
|
stopMonitoring() {
|
|
414
428
|
this.isMonitoring = false;
|
|
415
429
|
this.outputBuffer = [];
|
|
430
|
+
this.bufferBytes = 0;
|
|
416
431
|
this.planModeActive = false;
|
|
417
432
|
this.currentPlan = null;
|
|
418
433
|
this._lastChunkTail = '';
|
|
@@ -420,6 +435,7 @@ class PlanDetector {
|
|
|
420
435
|
|
|
421
436
|
clearBuffer() {
|
|
422
437
|
this.outputBuffer = [];
|
|
438
|
+
this.bufferBytes = 0;
|
|
423
439
|
this.currentPlan = null;
|
|
424
440
|
this._lastChunkTail = '';
|
|
425
441
|
}
|
|
@@ -135,7 +135,7 @@ SpeechRecognitionRecorder.prototype.start = function () {
|
|
|
135
135
|
var recognition = new SpeechRecognitionCtor();
|
|
136
136
|
recognition.continuous = true;
|
|
137
137
|
recognition.interimResults = false;
|
|
138
|
-
recognition.lang = 'en-
|
|
138
|
+
recognition.lang = 'en-IN';
|
|
139
139
|
|
|
140
140
|
self._recognition = recognition;
|
|
141
141
|
self._resultText = '';
|