ai-or-die 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.cursor/commands/commit-push.md +18 -0
  2. package/.github/agents/architect.md +26 -0
  3. package/.github/agents/engineer.md +29 -0
  4. package/.github/agents/qa-reviewer.md +31 -0
  5. package/.github/agents/researcher.md +30 -0
  6. package/.github/agents/troubleshooter.md +33 -0
  7. package/.github/copilot-instructions.md +55 -0
  8. package/.github/pull_request_template.md +21 -0
  9. package/.github/workflows/build-binaries.yml +76 -0
  10. package/.github/workflows/ci.yml +70 -0
  11. package/.github/workflows/release-on-main.yml +73 -0
  12. package/.prompts/log.md +9 -0
  13. package/AGENTS.md +84 -0
  14. package/CHANGELOG.md +25 -0
  15. package/CLAUDE.md +130 -0
  16. package/CONTRIBUTING.md +76 -0
  17. package/LICENSE +22 -0
  18. package/README.md +165 -0
  19. package/bin/ai-or-die.js +203 -0
  20. package/docs/.nojekyll +1 -0
  21. package/docs/README.md +37 -0
  22. package/docs/adrs/0000-template.md +35 -0
  23. package/docs/adrs/0001-bridge-base-class.md +53 -0
  24. package/docs/adrs/0002-devtunnels-over-ngrok.md +56 -0
  25. package/docs/adrs/0003-multi-tool-architecture.md +71 -0
  26. package/docs/adrs/0004-cross-platform-support.md +101 -0
  27. package/docs/adrs/0005-single-binary-distribution.md +58 -0
  28. package/docs/agent-instructions/00-philosophy.md +55 -0
  29. package/docs/agent-instructions/01-research-and-web.md +49 -0
  30. package/docs/agent-instructions/02-testing-and-validation.md +63 -0
  31. package/docs/agent-instructions/03-tooling-and-pipelines.md +59 -0
  32. package/docs/architecture/bridge-pattern.md +510 -0
  33. package/docs/architecture/overview.md +216 -0
  34. package/docs/architecture/websocket-protocol.md +609 -0
  35. package/docs/history/README.md +26 -0
  36. package/docs/specs/authentication.md +167 -0
  37. package/docs/specs/bridges.md +210 -0
  38. package/docs/specs/client-app.md +308 -0
  39. package/docs/specs/e2e-testing.md +311 -0
  40. package/docs/specs/server.md +334 -0
  41. package/docs/specs/session-store.md +170 -0
  42. package/docs/specs/usage-analytics.md +342 -0
  43. package/nul +0 -0
  44. package/package.json +54 -0
  45. package/scripts/build-sea.js +187 -0
  46. package/scripts/pty-sea-shim.js +21 -0
  47. package/scripts/publish-both.sh +21 -0
  48. package/scripts/release-pr.sh +73 -0
  49. package/scripts/smoke-test-binary.js +190 -0
  50. package/scripts/validate.ps1 +25 -0
  51. package/scripts/validate.sh +16 -0
  52. package/sea-bootstrap.js +54 -0
  53. package/site/ADVANCED_ANALYTICS.md +174 -0
  54. package/site/index.html +151 -0
  55. package/site/script.js +17 -0
  56. package/site/style.css +60 -0
  57. package/src/base-bridge.js +340 -0
  58. package/src/claude-bridge.js +48 -0
  59. package/src/codex-bridge.js +27 -0
  60. package/src/copilot-bridge.js +29 -0
  61. package/src/gemini-bridge.js +26 -0
  62. package/src/public/app.js +2123 -0
  63. package/src/public/auth.js +244 -0
  64. package/src/public/icon-generator.js +26 -0
  65. package/src/public/icons.js +36 -0
  66. package/src/public/index.html +397 -0
  67. package/src/public/manifest.json +45 -0
  68. package/src/public/plan-detector.js +186 -0
  69. package/src/public/service-worker.js +108 -0
  70. package/src/public/session-manager.js +1124 -0
  71. package/src/public/splits.js +574 -0
  72. package/src/public/style.css +2090 -0
  73. package/src/server.js +1269 -0
  74. package/src/terminal-bridge.js +49 -0
  75. package/src/usage-analytics.js +494 -0
  76. package/src/usage-reader.js +895 -0
  77. package/src/utils/auth.js +123 -0
  78. package/src/utils/session-store.js +181 -0
@@ -0,0 +1,510 @@
1
+ # Bridge Pattern
2
+
3
+ The bridge pattern is the core extensible architecture that allows ai-or-die to support multiple CLI tools through a uniform interface. Each CLI tool is wrapped in a bridge class that manages process spawning, I/O streaming, terminal resizing, and graceful shutdown.
4
+
5
+ ## Bridge Class Structure
6
+
7
+ Every bridge class follows the same structural pattern. The codebase currently has three implementations:
8
+
9
+ - **ClaudeBridge** (`src/claude-bridge.js`) -- wraps the `claude` CLI
10
+ - **CodexBridge** (`src/codex-bridge.js`) -- wraps the `codex` CLI
11
+ - **AgentBridge** (`src/agent-bridge.js`) -- wraps the `cursor-agent` CLI
12
+
13
+ All three share an identical public interface:
14
+
15
+ ```
16
+ class Bridge {
17
+ constructor() // Find the CLI command on disk
18
+ findCommand() -> string // Platform-aware command discovery
19
+ commandExists(command) -> boolean // Check if command is in PATH
20
+ startSession(sessionId, options) -> session
21
+ sendInput(sessionId, data) -> void
22
+ resize(sessionId, cols, rows) -> void
23
+ stopSession(sessionId) -> void
24
+ getSession(sessionId) -> session | undefined
25
+ getAllSessions() -> session[]
26
+ cleanup() -> void // Stop all sessions
27
+ }
28
+ ```
29
+
30
+ ### Internal State
31
+
32
+ Each bridge maintains a `Map<sessionId, session>` where a session object contains:
33
+
34
+ ```javascript
35
+ {
36
+ process: ptyProcess, // The node-pty IPty instance
37
+ workingDir: string, // Working directory the process was spawned in
38
+ created: Date, // When the session was created
39
+ active: boolean, // Whether the process is still running
40
+ killTimeout: number // Timer ID for the SIGKILL escalation
41
+ }
42
+ ```
43
+
44
+ ## How to Add a New CLI Tool
45
+
46
+ Adding a new CLI tool requires changes in four places. This section walks through adding a hypothetical "Gemini" CLI as an example.
47
+
48
+ ### Step 1: Create the Bridge
49
+
50
+ Create `src/gemini-bridge.js` following the established pattern:
51
+
52
+ ```javascript
53
+ const { spawn } = require('node-pty');
54
+ const path = require('path');
55
+ const fs = require('fs');
56
+
57
+ class GeminiBridge {
58
+ constructor() {
59
+ this.sessions = new Map();
60
+ this.geminiCommand = this.findGeminiCommand();
61
+ }
62
+
63
+ findGeminiCommand() {
64
+ const possibleCommands = [
65
+ path.join(process.env.HOME || '/', '.gemini', 'local', 'gemini'),
66
+ 'gemini',
67
+ path.join(process.env.HOME || '/', '.local', 'bin', 'gemini'),
68
+ '/usr/local/bin/gemini',
69
+ '/usr/bin/gemini'
70
+ ];
71
+
72
+ for (const cmd of possibleCommands) {
73
+ try {
74
+ if (fs.existsSync(cmd) || this.commandExists(cmd)) {
75
+ console.log(`Found Gemini command at: ${cmd}`);
76
+ return cmd;
77
+ }
78
+ } catch (error) {
79
+ continue;
80
+ }
81
+ }
82
+
83
+ console.error('Gemini command not found, using default "gemini"');
84
+ return 'gemini';
85
+ }
86
+
87
+ commandExists(command) {
88
+ try {
89
+ require('child_process').execFileSync('which', [command], { stdio: 'ignore' });
90
+ return true;
91
+ } catch (error) {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ async startSession(sessionId, options = {}) {
97
+ if (this.sessions.has(sessionId)) {
98
+ throw new Error(`Session ${sessionId} already exists`);
99
+ }
100
+
101
+ const {
102
+ workingDir = process.cwd(),
103
+ onOutput = () => {},
104
+ onExit = () => {},
105
+ onError = () => {},
106
+ cols = 80,
107
+ rows = 24
108
+ } = options;
109
+
110
+ const args = []; // Add any CLI-specific flags here
111
+ const geminiProcess = spawn(this.geminiCommand, args, {
112
+ cwd: workingDir,
113
+ env: {
114
+ ...process.env,
115
+ TERM: 'xterm-256color',
116
+ FORCE_COLOR: '1',
117
+ COLORTERM: 'truecolor'
118
+ },
119
+ cols,
120
+ rows,
121
+ name: 'xterm-color'
122
+ });
123
+
124
+ const session = {
125
+ process: geminiProcess,
126
+ workingDir,
127
+ created: new Date(),
128
+ active: true,
129
+ killTimeout: null
130
+ };
131
+
132
+ this.sessions.set(sessionId, session);
133
+
134
+ let dataBuffer = '';
135
+
136
+ geminiProcess.onData((data) => {
137
+ dataBuffer += data;
138
+ if (dataBuffer.length > 10000) {
139
+ dataBuffer = dataBuffer.slice(-5000);
140
+ }
141
+ onOutput(data);
142
+ });
143
+
144
+ geminiProcess.onExit((exitCode, signal) => {
145
+ if (session.killTimeout) {
146
+ clearTimeout(session.killTimeout);
147
+ session.killTimeout = null;
148
+ }
149
+ session.active = false;
150
+ this.sessions.delete(sessionId);
151
+ onExit(exitCode, signal);
152
+ });
153
+
154
+ geminiProcess.on('error', (error) => {
155
+ if (session.killTimeout) {
156
+ clearTimeout(session.killTimeout);
157
+ session.killTimeout = null;
158
+ }
159
+ session.active = false;
160
+ this.sessions.delete(sessionId);
161
+ onError(error);
162
+ });
163
+
164
+ return session;
165
+ }
166
+
167
+ async sendInput(sessionId, data) {
168
+ const session = this.sessions.get(sessionId);
169
+ if (!session || !session.active) {
170
+ throw new Error(`Session ${sessionId} not found or not active`);
171
+ }
172
+ session.process.write(data);
173
+ }
174
+
175
+ async resize(sessionId, cols, rows) {
176
+ const session = this.sessions.get(sessionId);
177
+ if (!session || !session.active) {
178
+ throw new Error(`Session ${sessionId} not found or not active`);
179
+ }
180
+ try {
181
+ session.process.resize(cols, rows);
182
+ } catch (error) {
183
+ console.warn(`Failed to resize session ${sessionId}:`, error.message);
184
+ }
185
+ }
186
+
187
+ async stopSession(sessionId) {
188
+ const session = this.sessions.get(sessionId);
189
+ if (!session) return;
190
+
191
+ if (session.killTimeout) {
192
+ clearTimeout(session.killTimeout);
193
+ session.killTimeout = null;
194
+ }
195
+
196
+ if (session.active && session.process) {
197
+ session.process.kill('SIGTERM');
198
+ session.killTimeout = setTimeout(() => {
199
+ if (session.active && session.process) {
200
+ session.process.kill('SIGKILL');
201
+ }
202
+ }, 5000);
203
+ }
204
+
205
+ session.active = false;
206
+ this.sessions.delete(sessionId);
207
+ }
208
+
209
+ getSession(sessionId) {
210
+ return this.sessions.get(sessionId);
211
+ }
212
+
213
+ getAllSessions() {
214
+ return Array.from(this.sessions.entries()).map(([id, session]) => ({
215
+ id,
216
+ workingDir: session.workingDir,
217
+ created: session.created,
218
+ active: session.active
219
+ }));
220
+ }
221
+
222
+ async cleanup() {
223
+ for (const sessionId of this.sessions.keys()) {
224
+ await this.stopSession(sessionId);
225
+ }
226
+ }
227
+ }
228
+
229
+ module.exports = GeminiBridge;
230
+ ```
231
+
232
+ ### Step 2: Register the Bridge in the Server
233
+
234
+ In `src/server.js`, import and instantiate the bridge in the `ClaudeCodeWebServer` constructor:
235
+
236
+ ```javascript
237
+ const GeminiBridge = require('./gemini-bridge');
238
+
239
+ // Inside constructor:
240
+ this.geminiBridge = new GeminiBridge();
241
+ ```
242
+
243
+ ### Step 3: Add WebSocket Message Handlers
244
+
245
+ Add three things to the server's message handling:
246
+
247
+ **a) A new `start_gemini` case in `handleMessage()`:**
248
+
249
+ ```javascript
250
+ case 'start_gemini':
251
+ await this.startGemini(wsId, data.options || {});
252
+ break;
253
+ ```
254
+
255
+ **b) Route `input`, `resize`, and `stop` to the new bridge when `session.agent === 'gemini'`:**
256
+
257
+ ```javascript
258
+ // In the 'input' handler:
259
+ if (session.agent === 'gemini') {
260
+ await this.geminiBridge.sendInput(wsInfo.claudeSessionId, data.data);
261
+ }
262
+
263
+ // In the 'resize' handler:
264
+ if (session.agent === 'gemini') {
265
+ await this.geminiBridge.resize(wsInfo.claudeSessionId, data.cols, data.rows);
266
+ }
267
+
268
+ // In the 'stop' handler:
269
+ if (session?.agent === 'gemini') {
270
+ await this.stopGemini(wsInfo.claudeSessionId);
271
+ }
272
+ ```
273
+
274
+ **c) Implement `startGemini()` and `stopGemini()` methods** following the same pattern as `startClaude()`/`stopClaude()`. The key details:
275
+
276
+ - Set `session.agent = 'gemini'` when starting
277
+ - Broadcast `{ type: 'gemini_started', sessionId }` on success
278
+ - Broadcast `{ type: 'gemini_stopped' }` on stop
279
+ - Wire up `onOutput`, `onExit`, and `onError` callbacks
280
+
281
+ **d) Handle cleanup in `close()`:**
282
+
283
+ ```javascript
284
+ if (session.agent === 'gemini') {
285
+ this.geminiBridge.stopSession(sessionId);
286
+ }
287
+ ```
288
+
289
+ ### Step 4: Add UI Card
290
+
291
+ In the client-side JavaScript (`src/public/app.js`), add a launch card for the new tool. The client sends `{ type: 'start_gemini', options: {} }` over the WebSocket and listens for `gemini_started` and `gemini_stopped` messages.
292
+
293
+ ---
294
+
295
+ ## Platform-Aware Command Resolution
296
+
297
+ ### Search Strategy
298
+
299
+ Each bridge's `findCommand()` method tries multiple locations in order:
300
+
301
+ 1. **Tool-specific home directory** -- e.g., `~/.claude/local/claude`, `~/.codex/local/codex`
302
+ 2. **Bare command name** -- relies on `PATH` resolution (e.g., `claude`, `codex`)
303
+ 3. **Alternative names** -- e.g., `claude-code` as a fallback for `claude`
304
+ 4. **User-local bin** -- `~/.local/bin/{command}`
305
+ 5. **System-wide locations** -- `/usr/local/bin/{command}`, `/usr/bin/{command}`
306
+
307
+ ### PATH Lookup
308
+
309
+ The `commandExists()` method uses `which` to check if a command is available in the system PATH:
310
+
311
+ ```javascript
312
+ commandExists(command) {
313
+ try {
314
+ require('child_process').execFileSync('which', [command], { stdio: 'ignore' });
315
+ return true;
316
+ } catch (error) {
317
+ return false;
318
+ }
319
+ }
320
+ ```
321
+
322
+ On Windows, this would need to use `where` instead of `which`. The current implementation targets Linux and macOS.
323
+
324
+ ### Home Directory Resolution
325
+
326
+ All bridges use `process.env.HOME` for home directory resolution, with a fallback to `/`:
327
+
328
+ ```javascript
329
+ path.join(process.env.HOME || '/', '.claude', 'local', 'claude')
330
+ ```
331
+
332
+ On Windows systems, `os.homedir()` would be the more reliable approach.
333
+
334
+ ### Fallback Behavior
335
+
336
+ If no command is found at any of the searched locations, the bridge falls back to the bare command name (e.g., `'claude'`). This means the process will fail to spawn at runtime with a descriptive error, rather than failing silently at startup.
337
+
338
+ ---
339
+
340
+ ## Process Lifecycle
341
+
342
+ ```mermaid
343
+ stateDiagram-v2
344
+ [*] --> Discovered: constructor() / findCommand()
345
+ Discovered --> Spawning: startSession()
346
+ Spawning --> Running: node-pty spawn success
347
+ Spawning --> Error: spawn failure
348
+ Running --> Running: onData / sendInput / resize
349
+ Running --> Terminating: stopSession() / SIGTERM
350
+ Terminating --> Dead: Process exits within 5s
351
+ Terminating --> ForceKill: 5s timeout
352
+ ForceKill --> Dead: SIGKILL
353
+ Running --> Dead: Natural exit (onExit)
354
+ Running --> Dead: Process error (onError)
355
+ Error --> [*]
356
+ Dead --> [*]: Session removed from Map
357
+ ```
358
+
359
+ ### Spawn (`startSession`)
360
+
361
+ The bridge spawns the CLI process using `node-pty`:
362
+
363
+ ```javascript
364
+ const process = spawn(this.command, args, {
365
+ cwd: workingDir,
366
+ env: {
367
+ ...process.env,
368
+ TERM: 'xterm-256color',
369
+ FORCE_COLOR: '1',
370
+ COLORTERM: 'truecolor'
371
+ },
372
+ cols,
373
+ rows,
374
+ name: 'xterm-color'
375
+ });
376
+ ```
377
+
378
+ Three callbacks are registered on the process:
379
+
380
+ | Callback | Trigger | Action |
381
+ |----------|---------|--------|
382
+ | `onData(data)` | CLI writes to stdout/stderr | Forward to `onOutput` callback; append to rolling buffer |
383
+ | `onExit(code, signal)` | Process exits | Mark session inactive; remove from map; call `onExit` callback |
384
+ | `on('error', err)` | Spawn or runtime error | Mark session inactive; remove from map; call `onError` callback |
385
+
386
+ ### Data Streaming (`onData`)
387
+
388
+ Each bridge maintains a rolling `dataBuffer` (capped at 10,000 characters, trimmed to the last 5,000) for internal use such as prompt detection. The raw output data is passed through to the server's `onOutput` callback without transformation.
389
+
390
+ ### Input (`sendInput`)
391
+
392
+ Input is written directly to the PTY process:
393
+
394
+ ```javascript
395
+ session.process.write(data);
396
+ ```
397
+
398
+ This is a synchronous write to the pseudo-terminal's master side. The data flows to the CLI process's stdin.
399
+
400
+ ### Resize
401
+
402
+ Terminal dimensions are updated on the PTY:
403
+
404
+ ```javascript
405
+ session.process.resize(cols, rows);
406
+ ```
407
+
408
+ This sends a `SIGWINCH` signal to the child process, causing it to re-query its terminal dimensions.
409
+
410
+ ### Graceful Shutdown (`stopSession`)
411
+
412
+ Shutdown follows a two-phase approach:
413
+
414
+ 1. **SIGTERM** -- Politely ask the process to terminate
415
+ 2. **Wait 5 seconds** -- Give the process time to clean up
416
+ 3. **SIGKILL** -- Force-kill if the process is still alive
417
+
418
+ ```javascript
419
+ session.process.kill('SIGTERM');
420
+ session.killTimeout = setTimeout(() => {
421
+ if (session.active && session.process) {
422
+ session.process.kill('SIGKILL');
423
+ }
424
+ }, 5000);
425
+ ```
426
+
427
+ If the process exits naturally before the timeout, the `onExit` handler clears the kill timeout.
428
+
429
+ ---
430
+
431
+ ## node-pty Configuration
432
+
433
+ ### Terminal Type
434
+
435
+ All bridges configure the PTY with:
436
+
437
+ ```javascript
438
+ {
439
+ TERM: 'xterm-256color', // Terminal type for full color support
440
+ FORCE_COLOR: '1', // Force color output in CLIs that check
441
+ COLORTERM: 'truecolor', // Advertise 24-bit color support
442
+ name: 'xterm-color' // PTY name
443
+ }
444
+ ```
445
+
446
+ This ensures CLI tools produce rich, colorized output that maps correctly to the xterm.js terminal in the browser.
447
+
448
+ ### Default Dimensions
449
+
450
+ If not specified by the client, terminals default to 80 columns by 24 rows -- the standard VT100 size.
451
+
452
+ ### Environment Inheritance
453
+
454
+ The PTY inherits the full server process environment (`...process.env`) with the terminal-specific variables overlaid. This means the CLI tools have access to API keys, PATH, and other environment variables set in the server's context.
455
+
456
+ ### Windows Support (ConPTY)
457
+
458
+ The `node-pty` library uses ConPTY on Windows automatically. However, the current command discovery logic (`which`, `HOME`, Unix paths) is Linux/macOS-oriented. Windows support would require:
459
+
460
+ - Using `where` instead of `which` in `commandExists()`
461
+ - Using `os.homedir()` instead of `process.env.HOME`
462
+ - Adding Windows-specific search paths (e.g., `%APPDATA%`, `%LOCALAPPDATA%`)
463
+
464
+ ---
465
+
466
+ ## Trust Prompt Auto-Accept (Claude-Specific)
467
+
468
+ The `ClaudeBridge` has unique behavior not present in other bridges: it automatically handles Claude's workspace trust prompt.
469
+
470
+ When Claude CLI is launched in a new directory, it asks:
471
+
472
+ ```
473
+ Do you trust the files in this folder?
474
+ ```
475
+
476
+ The bridge detects this prompt in the output buffer and automatically sends a carriage return to accept it:
477
+
478
+ ```javascript
479
+ // Buffer data to check for trust prompt
480
+ dataBuffer += data;
481
+
482
+ // Check for trust prompt and auto-accept it
483
+ if (!trustPromptHandled && dataBuffer.includes('Do you trust the files in this folder?')) {
484
+ trustPromptHandled = true;
485
+ setTimeout(() => {
486
+ claudeProcess.write('\r');
487
+ }, 500);
488
+ }
489
+ ```
490
+
491
+ Key details:
492
+ - The flag `trustPromptHandled` ensures this only fires once per session
493
+ - A 500ms delay is added before sending the keystroke to ensure the prompt UI is fully rendered
494
+ - The `\r` (carriage return) confirms the default selection ("Enter to confirm")
495
+ - Other bridges do not need this because their CLIs do not have a trust prompt
496
+
497
+ ---
498
+
499
+ ## Data Buffer Management
500
+
501
+ Each bridge maintains a per-session rolling buffer for internal prompt detection:
502
+
503
+ ```javascript
504
+ dataBuffer += data;
505
+ if (dataBuffer.length > 10000) {
506
+ dataBuffer = dataBuffer.slice(-5000);
507
+ }
508
+ ```
509
+
510
+ This is separate from the server-level `outputBuffer` (which stores the last 1000 output chunks for client reconnection replay). The bridge-level buffer is only used for pattern matching within the bridge itself (e.g., trust prompt detection in ClaudeBridge).