ai-or-die 0.1.81 → 0.1.83

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.81",
3
+ "version": "0.1.83",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
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)
@@ -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,18 +5201,23 @@ class ClaudeCodeWebServer {
5159
5201
  if (sidecarPath) terminalExtraEnv.AIORDIE_CLAUDE_BIND = sidecarPath;
5160
5202
  }
5161
5203
 
5162
- // Artifact-review parity for the manual (non-fleet) claude tab: a normally
5163
- // started claude tab gets the same env trio that _controlStartAgent injects,
5164
- // so the in-tab agent's artifact_* tools activate standalone — no control
5165
- // plane required. Always-on when auth is set; never injected without auth
5166
- // (so the artifact routes stay non-public). Token leak to nested children
5167
- // is blocked by github-router's STRIPPED_PARENT_ENV_KEYS.
5168
- const claudeArtifactEnv = {};
5169
- if (toolName === 'claude' && this.auth) {
5170
- claudeArtifactEnv.AIORDIE_BASE_URL = `${this.useHttps ? 'https' : 'http'}://127.0.0.1:${this.port}`;
5171
- claudeArtifactEnv.AIORDIE_TOKEN = this.auth;
5172
- claudeArtifactEnv.AIORDIE_SESSION_ID = sessionId;
5173
- }
5204
+ // Artifact-review parity for the manual (non-fleet) claude tab AND a
5205
+ // terminal tab where the user runs `github-router claude` themselves: both
5206
+ // get the env trio _controlStartAgent injects, so the in-tab agent's
5207
+ // artifact_* tools activate standalone no control plane required. For a
5208
+ // terminal tab the trio rides the shell env and the github-router PROXY
5209
+ // (which serves the artifact_* MCP tools) inherits it; the proxy strips
5210
+ // AIORDIE_TOKEN only from the claude CHILD it spawns, so a nested re-invoke
5211
+ // still can't hijack. Per-tab sessionId keeps multiple terminal claudes
5212
+ // isolated. Injected when auth is set OR --disable-auth (routes are public
5213
+ // then), so the viewer works standalone.
5214
+ const claudeArtifactEnv = (toolName === 'claude' || toolName === 'terminal')
5215
+ ? this._artifactEnvForSession(sessionId) : {};
5216
+
5217
+ // Rendered-screen buffer so a refresh/reconnect can repaint the last
5218
+ // screen even when the session is idle and the raw outputBuffer is empty
5219
+ // (manual-tab parity with the control path; mirrors 4523).
5220
+ try { session._ctlTranscript = new TranscriptBuffer({ cols: cols || 80, rows: rows || 24 }); } catch (_) { session._ctlTranscript = null; }
5174
5221
 
5175
5222
  const osc7Hooks = (toolName === 'terminal') ? {
5176
5223
  validatePath: (p) => this.validatePath(p),
@@ -5202,6 +5249,7 @@ class ClaudeCodeWebServer {
5202
5249
  const currentSession = this.claudeSessions.get(sessionId);
5203
5250
  if (!currentSession) return;
5204
5251
  currentSession.outputBuffer.push(data);
5252
+ try { if (currentSession._ctlTranscript) currentSession._ctlTranscript.write(data); } catch (_) { /* isolate */ }
5205
5253
  this.sessionStore.markDirty();
5206
5254
  this._throttledOutputBroadcast(sessionId, data);
5207
5255
  // Tap for the local-LLM summariser (off the hot path: this only
@@ -5231,6 +5279,9 @@ class ClaudeCodeWebServer {
5231
5279
  currentSession.agent = null;
5232
5280
  currentSession._lastExit = { code, signal };
5233
5281
  this.sessionStore.markDirty();
5282
+ // Persist the last rendered screen before disposing so a later
5283
+ // refresh/join repaints an idle session instead of going blank.
5284
+ this._persistSnapshotAndDispose(currentSession);
5234
5285
  }
5235
5286
  // Final sticky-note flush to capture the "done" state, then stop.
5236
5287
  this.stickyNoteSummarizer.flushExit(sessionId);
@@ -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));