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 +1 -1
- package/src/artifact-review.js +40 -0
- package/src/public/app.js +18 -4
- package/src/public/artifact-panel.js +8 -0
- package/src/public/session-manager.js +1 -0
- package/src/server.js +72 -14
- package/src/sticky-note-transcript.js +25 -0
package/package.json
CHANGED
package/src/artifact-review.js
CHANGED
|
@@ -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
|
|
290
|
-
|
|
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
|
-
//
|
|
2528
|
-
|
|
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
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
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
|
-
|
|
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:
|
|
5242
|
-
? {
|
|
5243
|
-
|
|
5244
|
-
|
|
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));
|