ai-or-die 0.1.80 → 0.1.82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -487,8 +487,46 @@ function createArtifactReviewRouter(options) {
487
487
  if (typeof validatePath !== 'function') throw new TypeError('validatePath is required');
488
488
  if (typeof mintAssetToken !== 'function') throw new TypeError('mintAssetToken is required');
489
489
 
490
+ // Auto live-reload: one chokidar watcher per session, scoped to the canonical
491
+ // artifact file. On a settled write we broadcast a reload SIGNAL (the panel
492
+ // cache-busts the iframe; the feedback box lives outside it and survives).
493
+ // Capped, replaced on re-open, and torn down on /end. Best-effort: chokidar
494
+ // is loaded lazily so tests/headless runs without it degrade to no reload.
495
+ const MAX_WATCHERS = 64;
496
+ const watchers = new Map(); // sessionId -> { watcher, file }
497
+ function stopWatch(sessionId) {
498
+ const w = watchers.get(sessionId);
499
+ if (!w) return;
500
+ watchers.delete(sessionId);
501
+ try { w.watcher.close(); } catch (_) { /* already closed */ }
502
+ }
503
+ function startWatch(sessionId, file) {
504
+ const existing = watchers.get(sessionId);
505
+ if (existing) { if (existing.file === file) return; stopWatch(sessionId); }
506
+ if (watchers.size >= MAX_WATCHERS) { const first = watchers.keys().next().value; if (first) stopWatch(first); }
507
+ let chokidar;
508
+ try { chokidar = require('chokidar'); } catch (_) { return; }
509
+ let watcher;
510
+ try {
511
+ watcher = chokidar.watch(file, {
512
+ ignoreInitial: true,
513
+ depth: 0,
514
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 30 },
515
+ });
516
+ } catch (_) { return; }
517
+ const onChange = () => {
518
+ if (!store.get(sessionId)) { stopWatch(sessionId); return; }
519
+ broadcastToSession(sessionId, { type: 'artifact_review_reload', sessionId });
520
+ };
521
+ watcher.on('change', onChange);
522
+ watcher.on('add', onChange);
523
+ watcher.on('error', () => stopWatch(sessionId));
524
+ watchers.set(sessionId, { watcher, file });
525
+ }
526
+
490
527
  const router = express.Router();
491
528
 
529
+
492
530
  router.post('/:sessionId/open', (req, res) => {
493
531
  const sessionId = req.params.sessionId;
494
532
  const file = req.body && req.body.file;
@@ -510,6 +548,7 @@ function createArtifactReviewRouter(options) {
510
548
  }
511
549
 
512
550
  const review = store.open(sessionId, validation.path);
551
+ startWatch(sessionId, validation.path);
513
552
  const viewUrl = artifactPath(sessionId, '/view', req);
514
553
  broadcastToSession(sessionId, {
515
554
  type: 'artifact_review_opened',
@@ -688,6 +727,7 @@ function createArtifactReviewRouter(options) {
688
727
  router.post('/:sessionId/end', (req, res) => {
689
728
  const sessionId = req.params.sessionId;
690
729
  const review = store.end(sessionId);
730
+ stopWatch(sessionId);
691
731
  if (!review) return res.status(404).json({ error: 'artifact review not found' });
692
732
  broadcastToSession(sessionId, { type: 'artifact_review_ended', sessionId });
693
733
  res.json({ ok: true, status: review.status });
package/src/public/app.js CHANGED
@@ -286,8 +286,13 @@ class ClaudeCodeWebInterface {
286
286
  console.log('[Init] Checking sessions, tabs.size:', this.sessionTabManager.tabs.size);
287
287
  if (this.sessionTabManager.tabs.size > 0) {
288
288
  console.log('[Init] Found sessions, switching to first tab...');
289
- // Sessions exist - switch to the first one (this will handle connecting)
290
- const firstTabId = this.sessionTabManager.tabs.keys().next().value;
289
+ // Sessions exist - switch to the last-active one (persisted across
290
+ // reloads) so a refresh returns to the tab you were on, not tab #1.
291
+ let restoreId = null;
292
+ try { restoreId = localStorage.getItem('cc-active-session'); } catch (_) { /* private mode */ }
293
+ const firstTabId = (restoreId && this.sessionTabManager.tabs.has(restoreId))
294
+ ? restoreId
295
+ : this.sessionTabManager.tabs.keys().next().value;
291
296
  console.log('[Init] Switching to tab:', firstTabId);
292
297
  await this.sessionTabManager.switchToTab(firstTabId);
293
298
  // The session_joined handler decides the overlay state:
@@ -2524,8 +2529,13 @@ class ClaudeCodeWebInterface {
2524
2529
  this.pendingJoinSessionId = null;
2525
2530
  }
2526
2531
 
2527
- // Replay output buffer if available
2528
- if (message.outputBuffer && message.outputBuffer.length > 0) {
2532
+ // Repaint: prefer the rendered snapshot (clean last screen) so a
2533
+ // refresh doesn't blank or show broken control sequences; fall
2534
+ // back to raw outputBuffer replay when there's no snapshot.
2535
+ if (message.renderedSnapshot) {
2536
+ this.terminal.clear();
2537
+ this.terminal.write(message.renderedSnapshot.replace(/\n/g, '\r\n'));
2538
+ } else if (message.outputBuffer && message.outputBuffer.length > 0) {
2529
2539
  this.terminal.clear();
2530
2540
  message.outputBuffer.forEach(data => {
2531
2541
  // Filter out focus tracking sequences (^[[I and ^[[O)
@@ -2738,6 +2748,10 @@ class ClaudeCodeWebInterface {
2738
2748
  if (this._artifactPanel) this._artifactPanel.endReview(message);
2739
2749
  break;
2740
2750
 
2751
+ case 'artifact_review_reload':
2752
+ if (this._artifactPanel) this._artifactPanel.reloadReview(message);
2753
+ break;
2754
+
2741
2755
  case 'artifact_agent_reply':
2742
2756
  if (this._artifactPanel) this._artifactPanel.agentReply(message);
2743
2757
  break;
@@ -172,6 +172,14 @@
172
172
  this._iframe.src = review.viewUrl + sep + '_r=' + Date.now();
173
173
  }
174
174
 
175
+ // Server-driven auto live-reload: file changed under review. Only the active
176
+ // tab's iframe is cache-busted; the chat/feedback box is untouched.
177
+ reloadReview(message) {
178
+ const sessionId = message && message.sessionId ? String(message.sessionId) : this.activeSessionId;
179
+ if (!sessionId || sessionId !== this.activeSessionId || !this.reviews.has(sessionId)) return;
180
+ this.reload();
181
+ }
182
+
175
183
  // ---- iframe <-> server bridge ----------------------------------------
176
184
  _handleIframeMessage(event) {
177
185
  const data = event && event.data;
@@ -737,6 +737,7 @@ class SessionTabManager {
737
737
  tab.classList.add('active');
738
738
  tab.setAttribute('aria-selected', 'true');
739
739
  this.activeTabId = sessionId;
740
+ try { localStorage.setItem('cc-active-session', sessionId); } catch (_) { /* private mode */ }
740
741
  this.ensureTabVisible(sessionId);
741
742
 
742
743
  // Update last accessed timestamp and clear unread indicator
package/src/server.js CHANGED
@@ -3944,7 +3944,12 @@ class ClaudeCodeWebServer {
3944
3944
  this._pushEvictionEntry(claudeSessionId); // PROC-04
3945
3945
  session.lastAccessed = Date.now();
3946
3946
 
3947
- // Send session info and replay buffer
3947
+ // Send session info and replay buffer. Prefer a live rendered tail, but
3948
+ // never block the join on a slow drain — fall back to the stored snapshot.
3949
+ let renderedSnapshot = session.renderedSnapshot || null;
3950
+ if (session._ctlTranscript) {
3951
+ renderedSnapshot = (await this._peekWithTimeout(session._ctlTranscript, 200, 300)) || renderedSnapshot;
3952
+ }
3948
3953
  this.sendToWebSocket(wsInfo.ws, {
3949
3954
  type: 'session_joined',
3950
3955
  sessionId: claudeSessionId,
@@ -3954,6 +3959,7 @@ class ClaudeCodeWebServer {
3954
3959
  wasActive: session.wasActive || false,
3955
3960
  agent: session.agent || null,
3956
3961
  outputBuffer: session.outputBuffer.slice(-200), // Send last 200 lines
3962
+ renderedSnapshot, // rendered last screen so idle/empty-buffer joins repaint
3957
3963
  stickyNote: session.stickyNote || null,
3958
3964
  autoTitle: session.nameIsUserSet ? null : (session.autoTitle || null),
3959
3965
  stickyNotesEnabled: session.stickyNotesEnabled !== false,
@@ -4462,6 +4468,43 @@ class ClaudeCodeWebServer {
4462
4468
  setTimeout(tick, 1500);
4463
4469
  }
4464
4470
 
4471
+ // Artifact-review env trio for a spawned claude session. Injected whenever the
4472
+ // server can serve /api/artifact — that's when auth is set OR auth is disabled
4473
+ // (noAuth leaves the routes public, server.js:1179). Without it the in-tab
4474
+ // agent's artifact_* tools stay dark and the viewer never opens. Token is the
4475
+ // real bearer, or a 'noauth' sentinel under --disable-auth (the routes ignore
4476
+ // it). Returns {} when the server is closed (auth required but unset), so a
4477
+ // misconfigured instance never points the agent at routes it can't reach.
4478
+ _artifactEnvForSession(sessionId) {
4479
+ if (!this.auth && !this.noAuth) return {};
4480
+ return {
4481
+ AIORDIE_BASE_URL: `${this.useHttps ? 'https' : 'http'}://127.0.0.1:${this.port}`,
4482
+ AIORDIE_TOKEN: this.auth || 'noauth',
4483
+ AIORDIE_SESSION_ID: sessionId,
4484
+ };
4485
+ }
4486
+
4487
+ // Read a transcript's rendered tail, but never block longer than `ms` — a
4488
+ // wedged xterm drain must not stall a join or strand a dispose. Resolves null
4489
+ // on timeout/error so callers fall back to the stored snapshot.
4490
+ _peekWithTimeout(t, lines, ms) {
4491
+ return Promise.race([
4492
+ Promise.resolve().then(() => t.peek(lines)).catch(() => null),
4493
+ new Promise((r) => setTimeout(() => r(null), ms)),
4494
+ ]);
4495
+ }
4496
+
4497
+ // Capture the last rendered screen, then ALWAYS dispose — so a refresh
4498
+ // repaints an exited session, without leaking the buffer if peek hangs.
4499
+ _persistSnapshotAndDispose(s) {
4500
+ const t = s._ctlTranscript;
4501
+ if (!t) return;
4502
+ s._ctlTranscript = null;
4503
+ this._peekWithTimeout(t, 200, 1000)
4504
+ .then((snap) => { if (snap) s.renderedSnapshot = snap; })
4505
+ .finally(() => { try { t.dispose(); } catch (_) { /* already disposed */ } });
4506
+ }
4507
+
4465
4508
  // Headless agent spawn (no WebSocket): reuses the same bridge.startSession +
4466
4509
  async _controlStartAgent(sessionId, toolName, opts = {}) {
4467
4510
  const session = this.claudeSessions.get(sessionId);
@@ -4500,12 +4543,9 @@ class ClaudeCodeWebServer {
4500
4543
  const sidecar = this._prepareClaudeBindSidecar(sessionId, session);
4501
4544
  if (sidecar) extraEnv.AIORDIE_CLAUDE_BIND = sidecar; // deterministic JSONL turn detection (ADR-0026)
4502
4545
  } catch (_) { /* sidecar best-effort */ }
4503
- if (this.auth) {
4504
- // So a github-router-claude spawned here can drive the artifact-review loop.
4505
- extraEnv.AIORDIE_BASE_URL = `${this.useHttps ? 'https' : 'http'}://127.0.0.1:${this.port}`;
4506
- extraEnv.AIORDIE_TOKEN = this.auth;
4507
- extraEnv.AIORDIE_SESSION_ID = sessionId;
4508
- }
4546
+ // So a github-router-claude spawned here can drive the artifact-review loop
4547
+ // (works standalone and under --disable-auth; see _artifactEnvForSession).
4548
+ Object.assign(extraEnv, this._artifactEnvForSession(sessionId));
4509
4549
 
4510
4550
  try { session._ctlTranscript = new TranscriptBuffer({ cols, rows }); } catch (_) { session._ctlTranscript = null; }
4511
4551
  session.active = true;
@@ -4540,7 +4580,9 @@ class ClaudeCodeWebServer {
4540
4580
  s.agent = null;
4541
4581
  s._lastExit = { code, signal };
4542
4582
  this.sessionStore.markDirty();
4543
- try { if (s._ctlTranscript) { s._ctlTranscript.dispose(); s._ctlTranscript = null; } } catch (_) { /* isolate */ }
4583
+ // Persist the last rendered screen before disposing so a refresh
4584
+ // repaints an idle/exited session instead of blank (always disposes).
4585
+ this._persistSnapshotAndDispose(s);
4544
4586
  // F12: stop the coarse idle-debounce timer for unbound sessions.
4545
4587
  try { if (s._ctlIdleTimer) { clearTimeout(s._ctlIdleTimer); s._ctlIdleTimer = null; } } catch (_) { /* isolate */ }
4546
4588
  }
@@ -5159,6 +5201,19 @@ class ClaudeCodeWebServer {
5159
5201
  if (sidecarPath) terminalExtraEnv.AIORDIE_CLAUDE_BIND = sidecarPath;
5160
5202
  }
5161
5203
 
5204
+ // Artifact-review parity for the manual (non-fleet) claude tab: a normally
5205
+ // started claude tab gets the same env trio that _controlStartAgent injects,
5206
+ // so the in-tab agent's artifact_* tools activate standalone — no control
5207
+ // plane required. Injected when auth is set OR --disable-auth (routes are
5208
+ // public then), so the viewer works standalone. Token leak to nested
5209
+ // children is blocked by github-router's STRIPPED_PARENT_ENV_KEYS.
5210
+ const claudeArtifactEnv = toolName === 'claude' ? this._artifactEnvForSession(sessionId) : {};
5211
+
5212
+ // Rendered-screen buffer so a refresh/reconnect can repaint the last
5213
+ // screen even when the session is idle and the raw outputBuffer is empty
5214
+ // (manual-tab parity with the control path; mirrors 4523).
5215
+ try { session._ctlTranscript = new TranscriptBuffer({ cols: cols || 80, rows: rows || 24 }); } catch (_) { session._ctlTranscript = null; }
5216
+
5162
5217
  const osc7Hooks = (toolName === 'terminal') ? {
5163
5218
  validatePath: (p) => this.validatePath(p),
5164
5219
  onCwdChange: (cwd, prev) => {
@@ -5189,6 +5244,7 @@ class ClaudeCodeWebServer {
5189
5244
  const currentSession = this.claudeSessions.get(sessionId);
5190
5245
  if (!currentSession) return;
5191
5246
  currentSession.outputBuffer.push(data);
5247
+ try { if (currentSession._ctlTranscript) currentSession._ctlTranscript.write(data); } catch (_) { /* isolate */ }
5192
5248
  this.sessionStore.markDirty();
5193
5249
  this._throttledOutputBroadcast(sessionId, data);
5194
5250
  // Tap for the local-LLM summariser (off the hot path: this only
@@ -5218,6 +5274,9 @@ class ClaudeCodeWebServer {
5218
5274
  currentSession.agent = null;
5219
5275
  currentSession._lastExit = { code, signal };
5220
5276
  this.sessionStore.markDirty();
5277
+ // Persist the last rendered screen before disposing so a later
5278
+ // refresh/join repaints an idle session instead of going blank.
5279
+ this._persistSnapshotAndDispose(currentSession);
5221
5280
  }
5222
5281
  // Final sticky-note flush to capture the "done" state, then stop.
5223
5282
  this.stickyNoteSummarizer.flushExit(sessionId);
@@ -5238,12 +5297,11 @@ class ClaudeCodeWebServer {
5238
5297
  this.broadcastSessionActivity(sessionId, 'session_error');
5239
5298
  },
5240
5299
  ...options,
5241
- extraEnv: toolName === 'terminal'
5242
- ? {
5243
- ...((options.extraEnv && typeof options.extraEnv === 'object') ? options.extraEnv : {}),
5244
- ...terminalExtraEnv,
5245
- }
5246
- : options.extraEnv
5300
+ extraEnv: {
5301
+ ...((options.extraEnv && typeof options.extraEnv === 'object') ? options.extraEnv : {}),
5302
+ ...terminalExtraEnv,
5303
+ ...claudeArtifactEnv,
5304
+ }
5247
5305
  });
5248
5306
 
5249
5307
  session.lastActivity = new Date();
@@ -99,6 +99,31 @@ class TranscriptBuffer {
99
99
  return result;
100
100
  }
101
101
 
102
+ /**
103
+ * Like snapshot() but does NOT reset the new-output counters — a read-only
104
+ * view of the rendered tail for repaint-on-join, so it never steals delta
105
+ * lines from the sticky-note volume trigger.
106
+ * @param {number} [maxLines]
107
+ * @returns {Promise<string>}
108
+ */
109
+ async peek(maxLines) {
110
+ const limit = maxLines || this._maxDeltaLines;
111
+ await this._drain();
112
+ const buf = this._term.buffer.active;
113
+ const cursorRow = buf.baseY + buf.cursorY;
114
+ const rowText = (i) => {
115
+ const line = buf.getLine(i);
116
+ return line ? line.translateToString(true) : '';
117
+ };
118
+ let lastNonEmpty = cursorRow;
119
+ while (lastNonEmpty >= 0 && rowText(lastNonEmpty) === '') lastNonEmpty--;
120
+ if (lastNonEmpty < 0) return '';
121
+ const start = Math.max(0, lastNonEmpty - limit + 1);
122
+ const lines = [];
123
+ for (let i = start; i <= lastNonEmpty; i++) lines.push(rowText(i));
124
+ return lines.join('\n');
125
+ }
126
+
102
127
  /** Resolve once xterm has parsed everything written so far. */
103
128
  _drain() {
104
129
  return new Promise((resolve) => this._term.write('', resolve));