create-walle 0.9.26 → 0.9.28
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 +1 -0
- package/package.json +1 -1
- package/template/claude-task-manager/api-prompts.js +11 -6
- package/template/claude-task-manager/docs/session-status-redesign.html +554 -0
- package/template/claude-task-manager/docs/terminal-rendering-redesign.html +529 -0
- package/template/claude-task-manager/lib/flush-redraw-markers.js +72 -0
- package/template/claude-task-manager/lib/macos-capabilities.js +190 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +224 -3
- package/template/claude-task-manager/lib/ttl-memo.js +61 -0
- package/template/claude-task-manager/public/index.html +892 -11
- package/template/claude-task-manager/public/js/activation-render-check.js +40 -2
- package/template/claude-task-manager/public/js/session-phase.js +370 -0
- package/template/claude-task-manager/public/js/setup.js +74 -1
- package/template/claude-task-manager/public/js/stream-view.js +56 -2
- package/template/claude-task-manager/server.js +643 -68
- package/template/claude-task-manager/workers/read-pool-worker.js +10 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +130 -24
- package/template/wall-e/api-walle.js +12 -1
- package/template/wall-e/brain.js +290 -4
- package/template/wall-e/chat.js +30 -25
- package/template/wall-e/coding/session-plan.js +79 -0
- package/template/wall-e/coding-orchestrator.js +9 -3
- package/template/wall-e/coding-prompts.js +10 -3
- package/template/wall-e/embeddings.js +192 -17
- package/template/wall-e/http/model-admin.js +109 -0
- package/template/wall-e/lib/event-loop-monitor.js +2 -2
- package/template/wall-e/lib/scheduler-worker-jobs.js +156 -121
- package/template/wall-e/lib/scheduler.js +226 -13
- package/template/wall-e/lib/worker-thread-pool.js +58 -4
- package/template/wall-e/llm/ollama-library.js +126 -0
- package/template/wall-e/llm/ollama.js +13 -0
- package/template/wall-e/llm/provider-backpressure.js +134 -0
- package/template/wall-e/llm/provider-health-state.js +24 -0
- package/template/wall-e/loops/backfill.js +43 -16
- package/template/wall-e/loops/initiative.js +1 -0
- package/template/wall-e/loops/think.js +38 -5
- package/template/wall-e/mcp-server.js +20 -4
- package/template/wall-e/skills/skill-fallback.js +34 -1
- package/template/wall-e/skills/skill-planner.js +60 -2
- package/template/wall-e/sources/jsonl-utils.js +84 -11
- package/template/wall-e/telemetry.js +42 -7
- package/template/wall-e/tools/local-tools.js +16 -0
- package/template/wall-e/workers/runtime-worker.js +33 -1
- package/template/website/index.html +5 -0
|
@@ -106,6 +106,7 @@ const sessionIntegrity = require('./session-integrity');
|
|
|
106
106
|
const codexLaunchHealth = require('./lib/codex-launch-health');
|
|
107
107
|
const codexPaths = require('./lib/codex-paths');
|
|
108
108
|
const terminalChoice = require('./lib/terminal-choice');
|
|
109
|
+
const macCaps = require('./lib/macos-capabilities');
|
|
109
110
|
const { auditRelink } = require('./lib/relink-audit');
|
|
110
111
|
const { verifyCandidateForTab } = require('./lib/session-verify');
|
|
111
112
|
const { verifyLineage: verifyLineageForTab } = require('./lib/session-lineage');
|
|
@@ -208,6 +209,8 @@ const {
|
|
|
208
209
|
terminalOutputFlushDelay,
|
|
209
210
|
} = require('./lib/terminal-output-flush');
|
|
210
211
|
const { coalesceSyncFrames } = require('./lib/coalesce-sync-frames');
|
|
212
|
+
const _flushRedrawMarkers = require('./lib/flush-redraw-markers');
|
|
213
|
+
const { createTtlMemo } = require('./lib/ttl-memo');
|
|
211
214
|
const {
|
|
212
215
|
fingerprintFinalText,
|
|
213
216
|
latestCodexFinalTextFromJsonlFile,
|
|
@@ -276,6 +279,7 @@ const cursorConversationStore = require('./lib/cursor-conversation-store');
|
|
|
276
279
|
const SessionSearchUtils = require('./public/js/session-search-utils.js');
|
|
277
280
|
const agentHooksInstaller = require('./lib/agent-hooks-installer');
|
|
278
281
|
const statusHooks = require('./lib/status-hooks');
|
|
282
|
+
const SessionPhase = require('./public/js/session-phase');
|
|
279
283
|
|
|
280
284
|
function publicAgentHookPlan(plan) {
|
|
281
285
|
if (!plan || typeof plan !== 'object') return plan;
|
|
@@ -1246,6 +1250,41 @@ const TURN_SETTLE_SNAPSHOT_OUTPUT_QUIET_FLOOR_MS = 1500;
|
|
|
1246
1250
|
// snapshot debounce (which is 15s for claude/codex) so render-divergence is caught
|
|
1247
1251
|
// within ~1s for every session type, not just gemini.
|
|
1248
1252
|
const FINGERPRINT_QUIET_FLOOR_MS = 1000;
|
|
1253
|
+
// Option A — "keyframe on every boundary" (terminal-rendering-redesign.html). When ON,
|
|
1254
|
+
// the server pushes an AUTHORITATIVE headless keyframe (serialized at each viewer's own
|
|
1255
|
+
// dims) on every render boundary — activation, resize, and output-quiet — and the client
|
|
1256
|
+
// renders it unconditionally instead of running the ~13-path divergence-detect/repair
|
|
1257
|
+
// "heal" overlay. The two sources of truth collapse to one (the headless mirror); a stale
|
|
1258
|
+
// frame on tab-switch becomes structurally impossible. Default OFF — flag-off is byte-for-
|
|
1259
|
+
// byte the legacy behavior, so rollback is instant. Set CTM_KEYFRAME_SYNC=1 to enable.
|
|
1260
|
+
const KEYFRAME_SYNC_ENABLED = process.env.CTM_KEYFRAME_SYNC === '1'
|
|
1261
|
+
|| String(process.env.CTM_KEYFRAME_SYNC || '').toLowerCase() === 'true';
|
|
1262
|
+
// Option A.5 — "streaming keyframe heartbeat". Option A only pushes an authoritative
|
|
1263
|
+
// keyframe at activation/resize/output-QUIET boundaries. A long streaming turn never
|
|
1264
|
+
// goes quiet, so the live byte stream (absolute-positioned, lossy under coalescing /
|
|
1265
|
+
// the output budget / flow-control) can shear mid-turn with NOTHING to correct it until
|
|
1266
|
+
// the turn ends (+1s). This pushes a keyframe at a controlled frame rate WHILE output is
|
|
1267
|
+
// sustained too — mosh's "fast-forward to truth at a frame rate" property — so streaming
|
|
1268
|
+
// divergence is bounded to one interval instead of a whole turn. The client applies one
|
|
1269
|
+
// only when its settled frame actually diverges (fp mismatch), so the converged case is a
|
|
1270
|
+
// free no-op (no flicker). Requires KEYFRAME_SYNC; default OFF. Set CTM_STREAM_KEYFRAME=1.
|
|
1271
|
+
const STREAM_KEYFRAME_ENABLED = process.env.CTM_STREAM_KEYFRAME === '1'
|
|
1272
|
+
|| String(process.env.CTM_STREAM_KEYFRAME || '').toLowerCase() === 'true';
|
|
1273
|
+
const STREAM_KEYFRAME_INTERVAL_MS = Math.max(120, Number(process.env.CTM_STREAM_KEYFRAME_INTERVAL_MS) || 400);
|
|
1274
|
+
// Pure throttle predicate (extracted so it is unit-testable without booting the server).
|
|
1275
|
+
function _streamKeyframeThrottleReady(lastAt, now, intervalMs) {
|
|
1276
|
+
return (now - (Number(lastAt) || 0)) >= intervalMs;
|
|
1277
|
+
}
|
|
1278
|
+
// Called from the output-flush path on each active output chunk. Throttled to one push
|
|
1279
|
+
// per STREAM_KEYFRAME_INTERVAL_MS; the downstream _scheduleKeyframePush debounce +
|
|
1280
|
+
// in-flight coalesce + marker-gate bound the actual serialize cost.
|
|
1281
|
+
function _maybeStreamKeyframe(session) {
|
|
1282
|
+
if (!STREAM_KEYFRAME_ENABLED || !KEYFRAME_SYNC_ENABLED || !session || session._exited) return;
|
|
1283
|
+
const now = Date.now();
|
|
1284
|
+
if (!_streamKeyframeThrottleReady(session._lastStreamKeyframeAt, now, STREAM_KEYFRAME_INTERVAL_MS)) return;
|
|
1285
|
+
session._lastStreamKeyframeAt = now;
|
|
1286
|
+
_scheduleKeyframePush(session, session.id, 'stream-keyframe');
|
|
1287
|
+
}
|
|
1249
1288
|
// NOTE: the proactive turn-settle snapshot PUSH (formerly _scheduleTurnSettleCleanSnapshot,
|
|
1250
1289
|
// gated by TURN_SETTLE_PROACTIVE_SNAPSHOT) was removed — fully replaced by fingerprint
|
|
1251
1290
|
// reconcile-on-divergence (see _scheduleFingerprintRefreshAfterOutputQuiet and the client
|
|
@@ -1324,6 +1363,10 @@ function _scheduleFingerprintRefreshAfterOutputQuiet(session) {
|
|
|
1324
1363
|
session._fingerprintQuietTimer = null;
|
|
1325
1364
|
if (!_fingerprintRefreshEligible(session)) return;
|
|
1326
1365
|
_refreshSessionFingerprint(session).catch(() => {});
|
|
1366
|
+
// Option A: on the same output-quiet boundary, push an authoritative keyframe to all
|
|
1367
|
+
// viewers (no-op unless KEYFRAME_SYNC_ENABLED). This is what lets the client retire the
|
|
1368
|
+
// divergence-detect heal paths — it is handed ground truth instead of guessing.
|
|
1369
|
+
_scheduleKeyframePush(session, session.id, 'output-quiet');
|
|
1327
1370
|
}, FINGERPRINT_QUIET_FLOOR_MS);
|
|
1328
1371
|
if (session._fingerprintQuietTimer && typeof session._fingerprintQuietTimer.unref === 'function') {
|
|
1329
1372
|
session._fingerprintQuietTimer.unref();
|
|
@@ -9036,6 +9079,13 @@ async function handleApi(req, res, url) {
|
|
|
9036
9079
|
if (url.pathname === '/api/services/status' && req.method === 'GET') {
|
|
9037
9080
|
return apiServicesStatus(req, res);
|
|
9038
9081
|
}
|
|
9082
|
+
// --- macOS capability (Full Disk Access) endpoints ---
|
|
9083
|
+
if (url.pathname === '/api/capabilities' && req.method === 'GET') {
|
|
9084
|
+
return apiCapabilities(req, res);
|
|
9085
|
+
}
|
|
9086
|
+
if (url.pathname === '/api/capabilities/open-fda' && req.method === 'POST') {
|
|
9087
|
+
return apiCapabilitiesOpenFda(req, res);
|
|
9088
|
+
}
|
|
9039
9089
|
// --- Approver debug API ---
|
|
9040
9090
|
if (url.pathname.startsWith('/api/approver/debug/') && req.method === 'GET') {
|
|
9041
9091
|
const sessionId = url.pathname.split('/').pop();
|
|
@@ -10524,6 +10574,45 @@ function _standupSummaryFor(source, ctmSessionId, agentSessionId, identity = nul
|
|
|
10524
10574
|
return null;
|
|
10525
10575
|
}
|
|
10526
10576
|
|
|
10577
|
+
// Per-session timeline-summary cache. The materialized standup snapshot refreshes every
|
|
10578
|
+
// STANDUP_MATERIALIZED_REFRESH_AFTER_MS (1.5s) and re-projects EVERY active session — each projection
|
|
10579
|
+
// is a full-conversation merge (conversation-tail-merge dedupText) + summarize. With 16 mostly-idle
|
|
10580
|
+
// sessions, that re-derived the whole history of every session ~every 1.5s on the main loop (the
|
|
10581
|
+
// CPU-profiler's top `dedupText` freeze frame). Cache the per-session summary keyed on a CHEAP
|
|
10582
|
+
// content-version — the durable row count (countSessionMessageRows, an indexed COUNT) plus the live
|
|
10583
|
+
// stream summary's latest-activity ms — so an UNCHANGED session returns its cached summary instantly
|
|
10584
|
+
// and only sessions with new durable rows or new stream output pay the projection. A generous max-age
|
|
10585
|
+
// is a safety net against any version signal a change slips past. Disable with
|
|
10586
|
+
// CTM_TIMELINE_SUMMARY_CACHE=0; tune staleness with CTM_TIMELINE_SUMMARY_CACHE_MAX_AGE_MS.
|
|
10587
|
+
const _timelineStandupSummaryCache = new Map(); // ctmId → { version, summary, at }
|
|
10588
|
+
const _TIMELINE_SUMMARY_CACHE_MAX_AGE_MS = Number(process.env.CTM_TIMELINE_SUMMARY_CACHE_MAX_AGE_MS ?? 300000);
|
|
10589
|
+
function _timelineSummaryCacheVersion(ctmId, rawSummary) {
|
|
10590
|
+
let count = 0;
|
|
10591
|
+
try { count = dbModule.countSessionMessageRows(ctmId) || 0; } catch {}
|
|
10592
|
+
let streamMs = 0;
|
|
10593
|
+
try {
|
|
10594
|
+
const fn = require('./lib/session-timeline-summary')._private?._summaryActivityMs;
|
|
10595
|
+
streamMs = rawSummary && typeof fn === 'function' ? (fn(rawSummary) || 0) : 0;
|
|
10596
|
+
} catch {}
|
|
10597
|
+
return `${count}:${streamMs}`;
|
|
10598
|
+
}
|
|
10599
|
+
async function _cachedTimelineSummaryForStandup(sessionId, identity, rawSummary) {
|
|
10600
|
+
if (process.env.CTM_TIMELINE_SUMMARY_CACHE === '0') return _timelineSummaryForStandup(sessionId, identity);
|
|
10601
|
+
const ctmId = _resolveOwnerCtmSessionId(sessionId) || sessionId;
|
|
10602
|
+
const version = _timelineSummaryCacheVersion(ctmId, rawSummary);
|
|
10603
|
+
const now = Date.now();
|
|
10604
|
+
const hit = _timelineStandupSummaryCache.get(ctmId);
|
|
10605
|
+
if (hit && hit.version === version && (now - hit.at) < _TIMELINE_SUMMARY_CACHE_MAX_AGE_MS) {
|
|
10606
|
+
return hit.summary;
|
|
10607
|
+
}
|
|
10608
|
+
const summary = await _timelineSummaryForStandup(sessionId, identity);
|
|
10609
|
+
// Bound memory: this only ever keys active sessions, but a long-lived process churns through many —
|
|
10610
|
+
// drop the whole map if it grows past a sane ceiling (cheap to repopulate).
|
|
10611
|
+
if (_timelineStandupSummaryCache.size > 512) _timelineStandupSummaryCache.clear();
|
|
10612
|
+
_timelineStandupSummaryCache.set(ctmId, { version, summary, at: now });
|
|
10613
|
+
return summary;
|
|
10614
|
+
}
|
|
10615
|
+
|
|
10527
10616
|
async function _timelineSummaryForStandup(sessionId, identity = null) {
|
|
10528
10617
|
const resolved = identity || _resolveSessionTimelineIdentity(sessionId);
|
|
10529
10618
|
let timeline = null;
|
|
@@ -11108,13 +11197,9 @@ function _standupWaitingReason(sessionId, session) {
|
|
|
11108
11197
|
return String(session?._waitingForInputReason || idleNotifyState.get(sessionId)?.reason || '').toLowerCase();
|
|
11109
11198
|
}
|
|
11110
11199
|
|
|
11200
|
+
// Single definition shared with the client + standup classifier via session-phase.js.
|
|
11111
11201
|
function _standupIsBlockingWaitingReason(reason) {
|
|
11112
|
-
|
|
11113
|
-
return text === 'approval' ||
|
|
11114
|
-
text === 'choice' ||
|
|
11115
|
-
text === 'plan' ||
|
|
11116
|
-
/\bapproval\b/.test(text) ||
|
|
11117
|
-
/\bpermission\b/.test(text);
|
|
11202
|
+
return SessionPhase.isBlockingWaitingReason(reason);
|
|
11118
11203
|
}
|
|
11119
11204
|
|
|
11120
11205
|
function _standupHasRecentNonResolvingInput(session) {
|
|
@@ -11250,6 +11335,95 @@ function _standupLiveStatusWithReasonForSession(session, now = Date.now()) {
|
|
|
11250
11335
|
return { status: '', reason: '' };
|
|
11251
11336
|
}
|
|
11252
11337
|
|
|
11338
|
+
// --- Session phase projection (redesign — see docs/session-status-redesign.html) ---
|
|
11339
|
+
// Single source of truth: map the server's signals to confidence-ranked CONDITIONS
|
|
11340
|
+
// and let SessionPhase.derivePhase() decide ONE phase. The server's existing,
|
|
11341
|
+
// battle-tested liveStatus verdict is seeded as a HIGH-confidence spine so `phase`
|
|
11342
|
+
// can never regress below today's behavior; the raw conditions only ADD value
|
|
11343
|
+
// (they can promote a stale idle on newer evidence, never demote a strong signal).
|
|
11344
|
+
const PHASE_DECISION_LOG = process.env.CTM_PHASE_LOG === '1';
|
|
11345
|
+
|
|
11346
|
+
function _mapLiveStatusToPhase(liveStatus) {
|
|
11347
|
+
switch (String(liveStatus || '')) {
|
|
11348
|
+
case 'running': return 'running';
|
|
11349
|
+
case 'waiting_input': return 'needs_you';
|
|
11350
|
+
case 'resuming': return 'resuming';
|
|
11351
|
+
case 'idle': return 'idle';
|
|
11352
|
+
case 'exited': return 'exited';
|
|
11353
|
+
default: return '';
|
|
11354
|
+
}
|
|
11355
|
+
}
|
|
11356
|
+
|
|
11357
|
+
function _phaseDecisionLogger(session) {
|
|
11358
|
+
if (!PHASE_DECISION_LOG) return null;
|
|
11359
|
+
return (entry) => {
|
|
11360
|
+
try {
|
|
11361
|
+
console.log('[phase]', session && session.id ? session.id.slice(0, 8) : '-', JSON.stringify(entry));
|
|
11362
|
+
} catch {}
|
|
11363
|
+
};
|
|
11364
|
+
}
|
|
11365
|
+
|
|
11366
|
+
// Returns { phase, cls, source, confidence, reason } or null for no-PTY/empty.
|
|
11367
|
+
function _sessionPhaseProjection(session, now, liveStatus, liveStatusReason) {
|
|
11368
|
+
if (!session) return null;
|
|
11369
|
+
try {
|
|
11370
|
+
const status = typeof liveStatus === 'string'
|
|
11371
|
+
? liveStatus
|
|
11372
|
+
: _standupLiveStatusForSession(session, now);
|
|
11373
|
+
const reason = liveStatusReason != null
|
|
11374
|
+
? liveStatusReason
|
|
11375
|
+
: (_standupLiveStatusWithReasonForSession(session, now).reason || '');
|
|
11376
|
+
|
|
11377
|
+
const log = _phaseDecisionLogger(session);
|
|
11378
|
+
const ptyAt = _serverObservedTimeMs(session.lastPtyActivity, now, session, 'lastPtyActivity', 'session-phase');
|
|
11379
|
+
|
|
11380
|
+
const signals = {
|
|
11381
|
+
exited: status === 'exited' || !!session._exited,
|
|
11382
|
+
waitingForInput: _isServerWaitingForInput(session.id, session, now),
|
|
11383
|
+
waitingReason: _standupWaitingReason(session.id, session),
|
|
11384
|
+
activeTurnOpen: _serverSessionHasOpenTurn(session, now),
|
|
11385
|
+
activeTurnStartedAt: _activeTurnObservedMs(session._activeTurnStartedAt, now, session, 'activeTurnStartedAt'),
|
|
11386
|
+
activeTurnLastEvidenceAt: _activeTurnObservedMs(session._activeTurnLastEvidenceAt, now, session, 'activeTurnLastEvidenceAt'),
|
|
11387
|
+
activeTurnSource: session._activeTurnSource || '',
|
|
11388
|
+
lastOutputAt: ptyAt,
|
|
11389
|
+
};
|
|
11390
|
+
const conditions = SessionPhase.buildSessionConditions(signals, { now, baseline: false });
|
|
11391
|
+
|
|
11392
|
+
// Seed the server's considered verdict as the HIGH-confidence spine.
|
|
11393
|
+
const verdictPhase = _mapLiveStatusToPhase(status);
|
|
11394
|
+
if (verdictPhase) {
|
|
11395
|
+
const blocking = verdictPhase === 'needs_you' &&
|
|
11396
|
+
_standupIsBlockingWaitingReason(_standupWaitingReason(session.id, session));
|
|
11397
|
+
conditions.push(SessionPhase.condition(verdictPhase, SessionPhase.CONFIDENCE.HIGH, now,
|
|
11398
|
+
`server:${reason || status}`, blocking ? { blocking: true } : null));
|
|
11399
|
+
}
|
|
11400
|
+
// Weakest floor so a live session with no positive signal reads idle, not unknown.
|
|
11401
|
+
if (_isLiveTerminalSession(session)) {
|
|
11402
|
+
conditions.push(SessionPhase.condition('idle', SessionPhase.CONFIDENCE.LOW, ptyAt, 'default-idle'));
|
|
11403
|
+
}
|
|
11404
|
+
if (!conditions.length) return null;
|
|
11405
|
+
|
|
11406
|
+
const derived = SessionPhase.derivePhase(conditions, { now, log });
|
|
11407
|
+
if (!derived.phase) return null;
|
|
11408
|
+
|
|
11409
|
+
if (PHASE_DECISION_LOG && verdictPhase && derived.phase !== verdictPhase) {
|
|
11410
|
+
console.log('[phase] mismatch', session.id ? session.id.slice(0, 8) : '-',
|
|
11411
|
+
'liveStatus=' + status, 'phase=' + derived.phase, 'reason=' + derived.reason);
|
|
11412
|
+
}
|
|
11413
|
+
|
|
11414
|
+
return {
|
|
11415
|
+
phase: derived.phase,
|
|
11416
|
+
cls: SessionPhase.phaseToCls(derived.phase),
|
|
11417
|
+
source: derived.source,
|
|
11418
|
+
confidence: derived.confidence,
|
|
11419
|
+
reason: derived.reason,
|
|
11420
|
+
};
|
|
11421
|
+
} catch (e) {
|
|
11422
|
+
if (PHASE_DECISION_LOG) console.log('[phase] error', e && e.message);
|
|
11423
|
+
return null;
|
|
11424
|
+
}
|
|
11425
|
+
}
|
|
11426
|
+
|
|
11253
11427
|
async function _buildStandupSnapshot(options = {}) {
|
|
11254
11428
|
const forceRefresh = options.forceRefresh === true;
|
|
11255
11429
|
const includeTimeline = options.includeTimeline !== false;
|
|
@@ -11275,7 +11449,7 @@ async function _buildStandupSnapshot(options = {}) {
|
|
|
11275
11449
|
const timelineIdentity = _resolveSessionTimelineIdentity(payload.id);
|
|
11276
11450
|
const rawSummary = _standupSummaryFor(source, payload.id, agentSessionId, timelineIdentity);
|
|
11277
11451
|
const timelineSummary = includeTimeline
|
|
11278
|
-
? await
|
|
11452
|
+
? await _cachedTimelineSummaryForStandup(payload.id, timelineIdentity, rawSummary)
|
|
11279
11453
|
: null;
|
|
11280
11454
|
const rawMergedSummary = mergeTimelineSummaries(rawSummary, timelineSummary);
|
|
11281
11455
|
let summary = rawMergedSummary ? {
|
|
@@ -12915,6 +13089,28 @@ async function _buildSessionMessagesPageResponseOffThread(payload) {
|
|
|
12915
13089
|
});
|
|
12916
13090
|
}
|
|
12917
13091
|
|
|
13092
|
+
// Stage C: the NON-paginated full read — project (merge + exclusions + image-refs) AND serialize the
|
|
13093
|
+
// whole conversation OFF the main loop (the on-main projection over a 3k-16k-msg array was the
|
|
13094
|
+
// profiled "reading conversational logs" freeze). Same shared projection module → byte-identical
|
|
13095
|
+
// on-main fallback (named apiSessionMessages:serializeFullResponse) on pool failure/degrade.
|
|
13096
|
+
async function _buildSessionMessagesFullResponseOffThread(payload) {
|
|
13097
|
+
const onMain = () => {
|
|
13098
|
+
const obj = require('./lib/session-messages-projection').buildFullMessagesResponse(payload);
|
|
13099
|
+
const str = _perfTracker.runSyncProbed('apiSessionMessages:serializeFullResponse',
|
|
13100
|
+
() => JSON.stringify(obj),
|
|
13101
|
+
{ context: () => ({ msgs: Array.isArray(obj) ? obj.length : (Array.isArray(obj?.messages) ? obj.messages.length : 0), fallback: 1 }) });
|
|
13102
|
+
return Buffer.from(str, 'utf8');
|
|
13103
|
+
};
|
|
13104
|
+
return _offthreadRead({
|
|
13105
|
+
breaker: _sessionMessagesPageBreaker,
|
|
13106
|
+
op: 'buildSessionMessagesFullResponse',
|
|
13107
|
+
timeoutMs: _SESSION_MESSAGES_READPOOL_TIMEOUT_MS,
|
|
13108
|
+
payload,
|
|
13109
|
+
onSuccess: (res) => (res && Buffer.isBuffer(res.__resultBuffer)) ? res.__resultBuffer : onMain(),
|
|
13110
|
+
onFail: () => onMain(),
|
|
13111
|
+
});
|
|
13112
|
+
}
|
|
13113
|
+
|
|
12918
13114
|
// Wave 3.1: off-thread the durable-tail reconcile's bounded JSONL parse. The cheap gate
|
|
12919
13115
|
// (resolve the live JSONL path/size + the imported byte HWM) stays on main; only when the
|
|
12920
13116
|
// importer is lagging (live > imported) do we hit the pool, so the common "caught up" case
|
|
@@ -12972,6 +13168,8 @@ async function apiSessionMessages(req, res, url) {
|
|
|
12972
13168
|
const offset = paginated ? Math.max(0, parseInt(offsetRaw || '0', 10) || 0) : 0;
|
|
12973
13169
|
const limit = paginated ? Math.max(1, Math.min(1000, parseInt(limitRaw || '200', 10) || 200)) : 0;
|
|
12974
13170
|
const paginationMode = url.searchParams.get('mode') === 'turns' ? 'turns' : 'messages';
|
|
13171
|
+
const compactConversation = paginated && paginationMode === 'turns'
|
|
13172
|
+
&& (url.searchParams.get('compact') === 'conversation' || url.searchParams.get('conversationCompact') === '1');
|
|
12975
13173
|
const MAX_SYNC_SIZE = 10 * 1024 * 1024; // 10MB — parse synchronously up to this
|
|
12976
13174
|
if (!sessionId) {
|
|
12977
13175
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -12984,8 +13182,72 @@ async function apiSessionMessages(req, res, url) {
|
|
|
12984
13182
|
return;
|
|
12985
13183
|
}
|
|
12986
13184
|
|
|
13185
|
+
// Off-thread NON-paginated full read: resolve the small projection inputs on main, then merge +
|
|
13186
|
+
// exclude + image-ref + serialize the WHOLE conversation on the read-pool worker (mirrors
|
|
13187
|
+
// sendPaginatedMessagePage, for the full-array case). Removes the on-main projection that froze
|
|
13188
|
+
// the loop while reading a large conversation. Same shared module → byte-identical on-main
|
|
13189
|
+
// fallback on pool failure. Cacheable with a stream-event signature folded into the key (so a hot
|
|
13190
|
+
// tail rebuilds), exactly like the page path — no main-thread merge needed to decide cacheability.
|
|
13191
|
+
const _sendFullMessagesOffThread = async (baseMessages, extra = {}) => {
|
|
13192
|
+
const skipStreamTail = extra.skipStreamTail === true;
|
|
13193
|
+
let identity = null;
|
|
13194
|
+
let streamEvents = [];
|
|
13195
|
+
if (!skipStreamTail) {
|
|
13196
|
+
identity = _resolveSessionTimelineIdentity(sessionId);
|
|
13197
|
+
streamEvents = collectTimelineStreamEvents(identity, {
|
|
13198
|
+
limit: 200,
|
|
13199
|
+
getRecentEvents: (lookupId, lookupLimit) => _getRecentStreamEventsForTimelineId(lookupId, lookupLimit),
|
|
13200
|
+
});
|
|
13201
|
+
}
|
|
13202
|
+
const _extraForKey = { ...extra };
|
|
13203
|
+
delete _extraForKey.skipStreamTail;
|
|
13204
|
+
// Bare array iff the caller passed no extra — matches sendMessages' `JSON.stringify(visibleMessages)`.
|
|
13205
|
+
const bareArray = Object.keys(_extraForKey).length === 0;
|
|
13206
|
+
const _cacheable = CTM_MSG_RESPONSE_CACHE_ENABLED;
|
|
13207
|
+
let _cacheKey = null;
|
|
13208
|
+
if (_cacheable) {
|
|
13209
|
+
_cacheKey = buildResponseCacheKey({
|
|
13210
|
+
sessionId,
|
|
13211
|
+
sourceVersion: _msgBaseSignature(baseMessages),
|
|
13212
|
+
exclusionsVersion: _msgExclusionsSignature(sessionId),
|
|
13213
|
+
offset, limit,
|
|
13214
|
+
mode: `mfull:${skipStreamTail ? '1' : '0'}:s${_streamEventsSignature(streamEvents)}:${JSON.stringify(_extraForKey)}`,
|
|
13215
|
+
});
|
|
13216
|
+
if (!noCache) {
|
|
13217
|
+
const _hit = _msgResponseCache.get(_cacheKey);
|
|
13218
|
+
if (_hit) {
|
|
13219
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Msg-Cache': 'hit' });
|
|
13220
|
+
res.end(_hit.buffer);
|
|
13221
|
+
return;
|
|
13222
|
+
}
|
|
13223
|
+
}
|
|
13224
|
+
}
|
|
13225
|
+
const exclusionRows = _loadSessionMessageExclusions(sessionId);
|
|
13226
|
+
const filterCodexSynthetic = _shouldFilterCodexSyntheticMessages(sessionId, baseMessages, {});
|
|
13227
|
+
const imageManifests = _resolveImageManifestsForProjection(sessionId, baseMessages, streamEvents);
|
|
13228
|
+
const buffer = await _buildSessionMessagesFullResponseOffThread({
|
|
13229
|
+
baseMessages, streamEvents, skipStreamTail, exclusionRows, filterCodexSynthetic, imageManifests,
|
|
13230
|
+
extra, publicIdentity: _publicTimelineIdentity(identity), bareArray,
|
|
13231
|
+
});
|
|
13232
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13233
|
+
if (_cacheable && _cacheKey) _msgResponseCache.set(_cacheKey, { buffer });
|
|
13234
|
+
res.end(buffer);
|
|
13235
|
+
};
|
|
13236
|
+
|
|
13237
|
+
const compactPageForConversation = (page) => (
|
|
13238
|
+
compactConversation
|
|
13239
|
+
? require('./lib/session-messages-projection').applyConversationCompactionToPage(page, { compactConversation: true })
|
|
13240
|
+
: page
|
|
13241
|
+
);
|
|
13242
|
+
|
|
12987
13243
|
const sendMessages = (messages, extra = {}) => {
|
|
12988
13244
|
const baseMessages = Array.isArray(messages) ? messages : [];
|
|
13245
|
+
// Large non-paginated read → project + serialize OFF the main loop (the conversation-read freeze).
|
|
13246
|
+
// Small reads stay on main (a worker round-trip + array clone isn't worth it); paginated reads
|
|
13247
|
+
// already have their own off-thread page path. CTM_MSG_OFFTHREAD_FULL=0 forces the on-main path.
|
|
13248
|
+
if (!paginated && CTM_MSG_OFFTHREAD_FULL_ENABLED && baseMessages.length >= CTM_MSG_OFFTHREAD_FULL_MIN) {
|
|
13249
|
+
return _sendFullMessagesOffThread(baseMessages, extra);
|
|
13250
|
+
}
|
|
12989
13251
|
// Compute the stream-tail merge first (own attribution probe) so we can both name
|
|
12990
13252
|
// it AND gate the serialized-response cache on whether the live tail adds anything.
|
|
12991
13253
|
const merge = extra.skipStreamTail
|
|
@@ -13006,7 +13268,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13006
13268
|
sourceVersion: _msgBaseSignature(baseMessages),
|
|
13007
13269
|
exclusionsVersion: _msgExclusionsSignature(sessionId),
|
|
13008
13270
|
offset, limit,
|
|
13009
|
-
mode: `m:${paginated ? paginationMode : 'full'}:${JSON.stringify(_extraForKey)}`,
|
|
13271
|
+
mode: `m:${paginated ? paginationMode : 'full'}:c${compactConversation ? 1 : 0}:${JSON.stringify(_extraForKey)}`,
|
|
13010
13272
|
});
|
|
13011
13273
|
if (!noCache) {
|
|
13012
13274
|
const _hit = _msgResponseCache.get(_cacheKey);
|
|
@@ -13055,7 +13317,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13055
13317
|
// async source-resolution — i.e. another `(unknown)` candidate. Name it. Phase 2.
|
|
13056
13318
|
const _payload = _perfTracker.runSyncProbed('apiSessionMessages:serializeResponse', () => {
|
|
13057
13319
|
if (paginated) {
|
|
13058
|
-
return JSON.stringify({ ..._paginateSessionMessages(visibleMessages, offset, limit, paginationMode), ...finalExtra });
|
|
13320
|
+
return JSON.stringify({ ...compactPageForConversation(_paginateSessionMessages(visibleMessages, offset, limit, paginationMode)), ...finalExtra });
|
|
13059
13321
|
}
|
|
13060
13322
|
if (Object.keys(finalExtra).length > 0) {
|
|
13061
13323
|
return JSON.stringify({ messages: visibleMessages, ...finalExtra });
|
|
@@ -13098,7 +13360,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13098
13360
|
sourceVersion: _msgBaseSignature(baseMessages),
|
|
13099
13361
|
exclusionsVersion: _msgExclusionsSignature(sessionId),
|
|
13100
13362
|
offset, limit,
|
|
13101
|
-
mode: `p:${paginationMode}:s${_streamEventsSignature(streamEvents)}:${JSON.stringify(_pageMeta)}:${JSON.stringify(_extraForKey)}`,
|
|
13363
|
+
mode: `p:${paginationMode}:c${compactConversation ? 1 : 0}:s${_streamEventsSignature(streamEvents)}:${JSON.stringify(_pageMeta)}:${JSON.stringify(_extraForKey)}`,
|
|
13102
13364
|
});
|
|
13103
13365
|
if (!noCache) {
|
|
13104
13366
|
const _hit = _msgResponseCache.get(_cacheKey);
|
|
@@ -13119,7 +13381,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13119
13381
|
delete _pageMeta.messages;
|
|
13120
13382
|
const buffer = await _buildSessionMessagesPageResponseOffThread({
|
|
13121
13383
|
baseMessages, streamEvents, skipStreamTail, exclusionRows, filterCodexSynthetic, imageManifests,
|
|
13122
|
-
pageMeta: _pageMeta, extra, publicIdentity: _publicTimelineIdentity(identity),
|
|
13384
|
+
pageMeta: _pageMeta, extra, publicIdentity: _publicTimelineIdentity(identity), compactConversation,
|
|
13123
13385
|
});
|
|
13124
13386
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13125
13387
|
if (_cacheable && _cacheKey) _msgResponseCache.set(_cacheKey, { buffer });
|
|
@@ -13135,7 +13397,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13135
13397
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13136
13398
|
const _payload = _perfTracker.runSyncProbed('apiSessionMessages:desktopHistory',
|
|
13137
13399
|
() => (paginated
|
|
13138
|
-
? JSON.stringify(_paginateSessionMessages(messages, offset, limit, paginationMode))
|
|
13400
|
+
? JSON.stringify(compactPageForConversation(_paginateSessionMessages(messages, offset, limit, paginationMode)))
|
|
13139
13401
|
: JSON.stringify(messages)),
|
|
13140
13402
|
{ context: () => ({ msgs: Array.isArray(messages) ? messages.length : 0, paginated: paginated ? 1 : 0 }) });
|
|
13141
13403
|
res.end(_payload);
|
|
@@ -13177,7 +13439,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13177
13439
|
if (paginated) {
|
|
13178
13440
|
res.end(_perfTracker.runSyncProbed('apiSessionMessages:walleHistory',
|
|
13179
13441
|
() => JSON.stringify({
|
|
13180
|
-
..._paginateSessionMessages(messages, offset, limit, paginationMode),
|
|
13442
|
+
...compactPageForConversation(_paginateSessionMessages(messages, offset, limit, paginationMode)),
|
|
13181
13443
|
source: cachedWalleHistory ? 'walle-conversation-cache' : 'walle-local-history',
|
|
13182
13444
|
cacheFresh: !!cachedWalleHistory || walleHistory.length > 0,
|
|
13183
13445
|
}),
|
|
@@ -13205,26 +13467,25 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13205
13467
|
// ladder and never breaks.
|
|
13206
13468
|
if (CTM_SESSION_ROWS_ENABLED) {
|
|
13207
13469
|
try {
|
|
13208
|
-
|
|
13209
|
-
|
|
13210
|
-
|
|
13211
|
-
|
|
13212
|
-
|
|
13213
|
-
|
|
13214
|
-
|
|
13215
|
-
|
|
13216
|
-
if (noCache) { try { requestConversationImport('session-rows-nocache-refresh'); } catch {} }
|
|
13217
|
-
let pageDeferredUnderStorm = false;
|
|
13470
|
+
// A CTM tab can map to one or more provider-native agent ids. The row store is keyed by the
|
|
13471
|
+
// source id that imported the transcript, so check all canonical source ids before falling
|
|
13472
|
+
// through to cache/JSONL. This keeps CTM-id requests on the bounded row path after restart.
|
|
13473
|
+
const rowSourceIds = _sessionMessageRowSourceIds(sessionId);
|
|
13474
|
+
if (noCache && rowSourceIds.length) { try { requestConversationImport('session-rows-nocache-refresh'); } catch {} }
|
|
13475
|
+
let pageDeferredUnderStorm = false;
|
|
13476
|
+
for (const rowSourceId of rowSourceIds) {
|
|
13477
|
+
if (!dbModule.sessionContentRowsAvailable(rowSourceId)) continue;
|
|
13218
13478
|
if (paginated) {
|
|
13219
13479
|
// Wave 3: the page fetch (COUNT(*) over ALL rows + candidate-turn scan + bounded
|
|
13220
13480
|
// SELECT) is the prime apiSessionMessages cold-page block — move it OFF the main loop
|
|
13221
13481
|
// to the read pool, with an on-main fallback. See _fetchSessionMessagesPageOffThread.
|
|
13222
|
-
let page = await _fetchSessionMessagesPageOffThread(
|
|
13482
|
+
let page = await _fetchSessionMessagesPageOffThread(rowSourceId, { offset, limit, mode: paginationMode }, { deferOnStorm: true });
|
|
13223
13483
|
if (page === null) {
|
|
13224
13484
|
// Storm defer (read pool saturated): serve stale via the cache/JSONL ladder below — do NOT
|
|
13225
13485
|
// fall to the full-array getSessionMessagesArray materialize on main (that's the very freeze
|
|
13226
13486
|
// we're avoiding). The next poll retries the bounded page off-thread once a worker frees.
|
|
13227
13487
|
pageDeferredUnderStorm = true;
|
|
13488
|
+
break;
|
|
13228
13489
|
} else {
|
|
13229
13490
|
// Newest page only: reconcile against the live JSONL tail so a lagging
|
|
13230
13491
|
// importer can't drop the turn the model just finished (the durable
|
|
@@ -13240,6 +13501,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13240
13501
|
source: paginationMode === 'turns' ? 'session-rows-turn-page' : 'session-rows-message-page',
|
|
13241
13502
|
cacheFresh: true,
|
|
13242
13503
|
skipStreamTail: offset > 0,
|
|
13504
|
+
rowSourceId: rowSourceId === sessionId ? undefined : rowSourceId,
|
|
13243
13505
|
});
|
|
13244
13506
|
return;
|
|
13245
13507
|
}
|
|
@@ -13247,10 +13509,13 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13247
13509
|
}
|
|
13248
13510
|
if (!pageDeferredUnderStorm) {
|
|
13249
13511
|
const rowMessages = _perfTracker.runSyncProbed('apiSessionMessages:materializeRows',
|
|
13250
|
-
() => dbModule.getSessionMessagesArray(
|
|
13251
|
-
{ context: () => ({}) });
|
|
13512
|
+
() => dbModule.getSessionMessagesArray(rowSourceId, { fallbackToBlob: false }),
|
|
13513
|
+
{ context: () => ({ row_source: rowSourceId === sessionId ? 'requested' : 'mapped' }) });
|
|
13252
13514
|
if (Array.isArray(rowMessages) && rowMessages.length > 0) {
|
|
13253
|
-
sendMessages(rowMessages, paginated ? {
|
|
13515
|
+
sendMessages(rowMessages, paginated ? {
|
|
13516
|
+
source: 'session-rows',
|
|
13517
|
+
rowSourceId: rowSourceId === sessionId ? undefined : rowSourceId,
|
|
13518
|
+
} : {});
|
|
13254
13519
|
return;
|
|
13255
13520
|
}
|
|
13256
13521
|
}
|
|
@@ -13512,7 +13777,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13512
13777
|
sourceVersion: _msgFilesSignature(filePaths),
|
|
13513
13778
|
exclusionsVersion: _msgExclusionsSignature(sessionId),
|
|
13514
13779
|
offset, limit,
|
|
13515
|
-
mode: `jsonl:${paginated ? paginationMode : 'full'}`,
|
|
13780
|
+
mode: `jsonl:${paginated ? paginationMode : 'full'}:c${compactConversation ? 1 : 0}`,
|
|
13516
13781
|
});
|
|
13517
13782
|
if (!noCache) {
|
|
13518
13783
|
const _hit = _msgResponseCache.get(_jsonlCacheKey);
|
|
@@ -13613,7 +13878,7 @@ async function apiSessionMessages(req, res, url) {
|
|
|
13613
13878
|
() => JSON.stringify(obj),
|
|
13614
13879
|
{ context: () => ({ msgs: Array.isArray(visibleMessages) ? visibleMessages.length : 0, partial: partialLoad ? 1 : 0 }) });
|
|
13615
13880
|
if (paginated) {
|
|
13616
|
-
const page = _paginateSessionMessages(visibleMessages, offset, limit, paginationMode);
|
|
13881
|
+
const page = compactPageForConversation(_paginateSessionMessages(visibleMessages, offset, limit, paginationMode));
|
|
13617
13882
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13618
13883
|
if (partialLoad) {
|
|
13619
13884
|
res.end(_serveJsonl({
|
|
@@ -14053,17 +14318,29 @@ const _promptComputeInFlight = new Map(); // sessionId -> Promise<result>
|
|
|
14053
14318
|
const PROMPT_PREVIEW_CACHE_TTL_MS = Math.max(0, Number(process.env.CTM_PROMPT_PREVIEW_CACHE_TTL_MS) || 10000);
|
|
14054
14319
|
const PROMPT_PREVIEW_CACHE_MAX = 200;
|
|
14055
14320
|
|
|
14321
|
+
// The freshness key runs on EVERY /api/session/prompts poll (before the preview-cache
|
|
14322
|
+
// check) and each call is an UNCACHED SQLite probe (getSessionUserPromptFreshness). With
|
|
14323
|
+
// many sessions polling concurrently those probes stack into a main-loop block (observed
|
|
14324
|
+
// up to 790ms on a dev storm, multi-second on the primary with cold pages — the
|
|
14325
|
+
// apiSessionPrompts [perf-lag] freezes). A user-prompt count changes rarely, so collapse
|
|
14326
|
+
// repeated identical probes behind a sub-second TTL: a new prompt still busts the preview
|
|
14327
|
+
// cache within the TTL (far faster than the 10s preview-cache TTL this key guards). null
|
|
14328
|
+
// (DB error) is never cached so a transient failure re-probes on the next poll.
|
|
14329
|
+
const PROMPT_FRESHNESS_KEY_TTL_MS = Math.max(0, Number(process.env.CTM_PROMPT_FRESHNESS_KEY_TTL_MS) || 750);
|
|
14330
|
+
const _promptFreshnessKeyMemo = createTtlMemo({ ttlMs: PROMPT_FRESHNESS_KEY_TTL_MS, max: 512 });
|
|
14056
14331
|
function _promptPreviewFreshnessKey(sessionId) {
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14060
|
-
|
|
14061
|
-
|
|
14062
|
-
|
|
14063
|
-
|
|
14064
|
-
|
|
14065
|
-
|
|
14066
|
-
|
|
14332
|
+
return _promptFreshnessKeyMemo.get(sessionId, () => {
|
|
14333
|
+
try {
|
|
14334
|
+
const ids = _messageIndexPromptSourceIds(sessionId);
|
|
14335
|
+
// USER-scoped: the prompt list only changes when a user prompt is added. Keying on
|
|
14336
|
+
// user-only freshness means a session streaming assistant output (which grows the
|
|
14337
|
+
// all-message index every chunk) no longer busts the prompt-preview cache, so the
|
|
14338
|
+
// sha1-per-prompt summary isn't recomputed on every poll of an active session.
|
|
14339
|
+
const f = dbModule.getSessionUserPromptFreshness(ids);
|
|
14340
|
+
if (!f) return null; // null => do not trust cache (DB error)
|
|
14341
|
+
return `u${f.maxIndex}:${f.rows}`;
|
|
14342
|
+
} catch { return null; }
|
|
14343
|
+
}, (key) => key != null); // never pin a null "do-not-trust" key for the TTL
|
|
14067
14344
|
}
|
|
14068
14345
|
|
|
14069
14346
|
// Phase 1: run the prompt-index compute (SELECT + sort + exclusions + sha1 summary) on the
|
|
@@ -14936,6 +15213,11 @@ const _sessionMessageExclusionsCache = new Map(); // sessionId -> { at, rows }
|
|
|
14936
15213
|
// function of the output and served only when the live stream tail adds nothing
|
|
14937
15214
|
// (merge.added === 0) — turns a steady-state poll into a Map lookup + res.end().
|
|
14938
15215
|
const CTM_MSG_RESPONSE_CACHE_ENABLED = process.env.CTM_MSG_RESPONSE_CACHE !== '0';
|
|
15216
|
+
// Off-thread the NON-paginated full conversation read (merge + exclusions + image-refs + serialize)
|
|
15217
|
+
// once the base is large enough that a worker round-trip + array clone beats blocking the loop.
|
|
15218
|
+
// CTM_MSG_OFFTHREAD_FULL=0 disables (force on-main); CTM_MSG_OFFTHREAD_FULL_MIN tunes the threshold.
|
|
15219
|
+
const CTM_MSG_OFFTHREAD_FULL_ENABLED = process.env.CTM_MSG_OFFTHREAD_FULL !== '0';
|
|
15220
|
+
const CTM_MSG_OFFTHREAD_FULL_MIN = Math.max(1, Number(process.env.CTM_MSG_OFFTHREAD_FULL_MIN) || 800);
|
|
14939
15221
|
const _msgResponseCache = createResponseCache({
|
|
14940
15222
|
maxEntries: Math.max(16, Number(process.env.CTM_MSG_RESPONSE_CACHE_MAX) || 256),
|
|
14941
15223
|
});
|
|
@@ -15013,16 +15295,25 @@ function _loadSessionMessageExclusions(sessionId) {
|
|
|
15013
15295
|
}
|
|
15014
15296
|
}
|
|
15015
15297
|
|
|
15298
|
+
// A session's codex-ness is effectively immutable (set when its agent row is linked), yet
|
|
15299
|
+
// this ran an UNCACHED agent_sessions lookup on every prompt cache-miss and every exclusion
|
|
15300
|
+
// projection. Cache it over a short TTL: a late-linked codex row is still picked up within
|
|
15301
|
+
// the TTL, and _shouldFilterCodexSyntheticMessages ORs this with the live message-provider
|
|
15302
|
+
// check, so a brief stale `false` cannot hide real codex filtering.
|
|
15303
|
+
const SESSION_CODEX_SOURCE_TTL_MS = Math.max(0, Number(process.env.CTM_SESSION_CODEX_SOURCE_TTL_MS) || 30000);
|
|
15304
|
+
const _sessionCodexSourceMemo = createTtlMemo({ ttlMs: SESSION_CODEX_SOURCE_TTL_MS, max: 512 });
|
|
15016
15305
|
function _sessionHasCodexMessageSource(sessionId) {
|
|
15017
15306
|
if (!sessionId) return false;
|
|
15018
|
-
|
|
15019
|
-
|
|
15020
|
-
|
|
15021
|
-
|
|
15022
|
-
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
15307
|
+
return _sessionCodexSourceMemo.get(sessionId, () => {
|
|
15308
|
+
try {
|
|
15309
|
+
return _getAgentSessionRowsForAnyId(sessionId).some((row) => {
|
|
15310
|
+
const provider = String(row?.provider || '').trim().toLowerCase();
|
|
15311
|
+
return provider === 'codex' || _isCodexRolloutFilePath(row?.jsonl_path);
|
|
15312
|
+
});
|
|
15313
|
+
} catch {
|
|
15314
|
+
return false;
|
|
15315
|
+
}
|
|
15316
|
+
});
|
|
15026
15317
|
}
|
|
15027
15318
|
|
|
15028
15319
|
function _messagesCarryCodexProvider(messages) {
|
|
@@ -15091,6 +15382,30 @@ function _getAgentSessionRowsForAnyId(sessionId) {
|
|
|
15091
15382
|
}
|
|
15092
15383
|
}
|
|
15093
15384
|
|
|
15385
|
+
function _sessionMessageRowSourceIds(sessionId) {
|
|
15386
|
+
const ids = [];
|
|
15387
|
+
const seen = new Set();
|
|
15388
|
+
const add = (value) => {
|
|
15389
|
+
const clean = String(value || '').trim();
|
|
15390
|
+
if (!clean || seen.has(clean)) return;
|
|
15391
|
+
seen.add(clean);
|
|
15392
|
+
ids.push(clean);
|
|
15393
|
+
};
|
|
15394
|
+
add(sessionId);
|
|
15395
|
+
try {
|
|
15396
|
+
for (const sourceId of dbModule.getSessionConversationSourceIds(sessionId, { includeCtmFallback: true }) || []) {
|
|
15397
|
+
add(sourceId);
|
|
15398
|
+
}
|
|
15399
|
+
} catch {}
|
|
15400
|
+
try {
|
|
15401
|
+
for (const row of _getAgentSessionRowsForAnyId(sessionId)) {
|
|
15402
|
+
add(row.agent_session_id);
|
|
15403
|
+
add(row.ctm_session_id);
|
|
15404
|
+
}
|
|
15405
|
+
} catch {}
|
|
15406
|
+
return ids;
|
|
15407
|
+
}
|
|
15408
|
+
|
|
15094
15409
|
// Opportunity C: memoize the parsed/sorted cache blob. apiSessionMessages /
|
|
15095
15410
|
// apiSessionPrompts re-parse the SAME multi-MB messages blob on every UI poll (a
|
|
15096
15411
|
// 12k-message session = ~3MB JSON.parse + sort = 400-720ms main-loop blocks). The
|
|
@@ -18631,7 +18946,7 @@ wss.on('connection', (ws, req) => {
|
|
|
18631
18946
|
// browser echoes this in X-CTM-Client-Id when calling PUT /api/settings,
|
|
18632
18947
|
// and we filter it out of broadcastUiPrefs to prevent self-echo.
|
|
18633
18948
|
ws.clientId = crypto.randomUUID();
|
|
18634
|
-
try { ws.send(JSON.stringify({ type: 'hello', clientId: ws.clientId, appVersion: getAppVersionInfo() })); } catch {}
|
|
18949
|
+
try { ws.send(JSON.stringify({ type: 'hello', clientId: ws.clientId, appVersion: getAppVersionInfo(), keyframeSync: KEYFRAME_SYNC_ENABLED, streamKeyframe: STREAM_KEYFRAME_ENABLED })); } catch {}
|
|
18635
18950
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
18636
18951
|
|
|
18637
18952
|
ws.on('message', (raw) => {
|
|
@@ -22250,7 +22565,11 @@ function handleCreate(ws, msg) {
|
|
|
22250
22565
|
const resumeCwdInfo = getCodexThreadResumeCwdInfo(preConfigResume.sessionId, { fallbackCwd: cwd, expectedCwd });
|
|
22251
22566
|
_recordCodexResumeCwdMismatch(id, preConfigResume.sessionId, resumeCwdInfo);
|
|
22252
22567
|
const resumeCwd = resumeCwdInfo.cwd;
|
|
22253
|
-
|
|
22568
|
+
// On cold-boot restore, don't stat a resume cwd that lives on a Dropbox/NFS/external
|
|
22569
|
+
// volume — that's the macOS "network volume" TCC trigger. Trust the stored path; it gets
|
|
22570
|
+
// checked for real when the session actually launches on activation.
|
|
22571
|
+
const resumeCwdDeferStat = !!msg._isRestore && resumeCwd && macCaps.isPathOnNonLocalVolume(resumeCwd);
|
|
22572
|
+
if (resumeCwd && resumeCwd !== cwd && (resumeCwdDeferStat || fs.existsSync(resumeCwd))) {
|
|
22254
22573
|
console.log(`[codex-resume] using thread cwd for ${String(preConfigResume.sessionId).slice(0,8)}: ${cwd} -> ${resumeCwd}`);
|
|
22255
22574
|
_codexResumeCwdToHeal = resumeCwd;
|
|
22256
22575
|
cwd = resumeCwd;
|
|
@@ -22287,6 +22606,16 @@ function handleCreate(ws, msg) {
|
|
|
22287
22606
|
const createAgentType = detectAgentType(cmd);
|
|
22288
22607
|
if (createAgentType === 'codex') {
|
|
22289
22608
|
args = _withCodexResumeCd(args, cwd);
|
|
22609
|
+
// Preflight: Codex reads its config from CODEX_HOME (default ~/.codex) at startup. If that
|
|
22610
|
+
// lives on a File Provider / network volume (e.g. ~/.codex symlinked into Dropbox) and CTM
|
|
22611
|
+
// lacks Full Disk Access, the read fails with EPERM and EVERY Codex session dies with
|
|
22612
|
+
// "Error loading config.toml". Detect it up front and ASK for FDA (banner) instead of
|
|
22613
|
+
// letting it fail silently. Behavior-preserving: we still launch.
|
|
22614
|
+
try {
|
|
22615
|
+
// codexPaths.codexHome() is the canonical resolver (honors $CODEX_HOME, else ~/.codex) —
|
|
22616
|
+
// the SAME path Codex itself reads, so the probe checks exactly what will fail.
|
|
22617
|
+
_preflightAgentPathAccess({ path: path.join(codexPaths.codexHome(), 'config.toml'), provider: 'codex', reason: 'CODEX_HOME config' });
|
|
22618
|
+
} catch { /* preflight is best-effort — never block a launch */ }
|
|
22290
22619
|
// Opt-in (CTM_CODEX_SANDBOX=1): give CTM-spawned Codex its native sandbox +
|
|
22291
22620
|
// on-request approval so it auto-runs safe commands contained and prompts less —
|
|
22292
22621
|
// keeps the Codex TUI; does NOT route the remaining prompts through CTM's classifier
|
|
@@ -22994,6 +23323,12 @@ function handleCreate(ws, msg) {
|
|
|
22994
23323
|
// everything). Client gets full state via snapshot on next tab switch.
|
|
22995
23324
|
const OUTPUT_BUDGET_BYTES = 65536; // 64KB per 32ms flush window
|
|
22996
23325
|
const OUTPUT_BULK_FAST_PATH_BYTES = 256 * 1024;
|
|
23326
|
+
// The redraw classifiers only need the VISIBLE frame (always at the tail of the batch) to
|
|
23327
|
+
// decide "is this an interactive TUI redraw?". During heavy repaint bursts the batch holds
|
|
23328
|
+
// many stacked frames (132KB+ observed) and stripAnsi + the alternation regexes over the WHOLE
|
|
23329
|
+
// batch blocked the loop ~342ms ([freeze-probe] flush:classify-detect) — freezing input in
|
|
23330
|
+
// whatever tab (codex OR claude) is focused. Bound the marker scan to the visible tail.
|
|
23331
|
+
const REDRAW_CLASSIFY_TAIL_BYTES = _flushRedrawMarkers.DEFAULT_TAIL_BYTES;
|
|
22997
23332
|
let _outputBudgetUsed = 0;
|
|
22998
23333
|
let _outputBudgetResetTimer = null;
|
|
22999
23334
|
let _budgetSnapshotPending = false; // suppress raw output until snapshot delivered
|
|
@@ -23035,20 +23370,11 @@ function handleCreate(ws, msg) {
|
|
|
23035
23370
|
const hasAlternateScreenOrCursorRedraw = raw.includes('\x1b[?2026') || /\x1b\[[0-9;?]*[HJfK]/.test(raw);
|
|
23036
23371
|
if (!hasAlternateScreenOrCursorRedraw) return false;
|
|
23037
23372
|
|
|
23038
|
-
|
|
23039
|
-
|
|
23040
|
-
|
|
23041
|
-
|
|
23042
|
-
|
|
23043
|
-
const hasPickerMarker = /\[Skill\]|Press enter to insert|esc to close|no matches/i.test(text);
|
|
23044
|
-
if (hasPickerMarker) return true;
|
|
23045
|
-
|
|
23046
|
-
// Real Codex hides the picker once an exact $skill token is complete, but
|
|
23047
|
-
// keeps repainting the full composer/status screen while the user types the
|
|
23048
|
-
// next token. Those frames are still interactive input echo and should not
|
|
23049
|
-
// enter output-budget snapshot recovery just because the visible picker
|
|
23050
|
-
// rows are gone.
|
|
23051
|
-
return /\b(?:gpt-[\w.-]+|o\d|claude|sonnet|opus|haiku|deepseek|gemini|qwen|llama|mistral|kimi|moonshot)\b[^\n]*(?:\b(?:x?high|medium|low|fast)\b|[~/]|[-·])/i.test(text);
|
|
23373
|
+
// Detect against the VISIBLE tail only — the picker/composer state Codex is echoing is at
|
|
23374
|
+
// the end of the batch; a marker in an earlier superseded frame isn't on screen. Bounding
|
|
23375
|
+
// the strip+regex here is what removes the multi-hundred-ms classify block on big batches.
|
|
23376
|
+
const text = _flushRedrawMarkers.markerTailText(raw, stripAnsi, REDRAW_CLASSIFY_TAIL_BYTES);
|
|
23377
|
+
return _flushRedrawMarkers.hasCodexSkillRedrawMarkers(text);
|
|
23052
23378
|
}
|
|
23053
23379
|
|
|
23054
23380
|
function _isCodexNativeRedrawContinuation(batch) {
|
|
@@ -23078,8 +23404,8 @@ function handleCreate(ws, msg) {
|
|
|
23078
23404
|
// status updates as well as composer input. Treat those as TUI redraws, not
|
|
23079
23405
|
// bulk command output, or the generic output budget drops the latest frame
|
|
23080
23406
|
// and forces visible dirty/snapshot repair while Codex is still working.
|
|
23081
|
-
const text =
|
|
23082
|
-
return
|
|
23407
|
+
const text = _flushRedrawMarkers.markerTailText(raw, stripAnsi, REDRAW_CLASSIFY_TAIL_BYTES);
|
|
23408
|
+
return _flushRedrawMarkers.hasCodexSyncTuiMarkers(text);
|
|
23083
23409
|
}
|
|
23084
23410
|
|
|
23085
23411
|
function _isClaudeTuiRedraw(batch) {
|
|
@@ -23096,8 +23422,8 @@ function handleCreate(ws, msg) {
|
|
|
23096
23422
|
/\x1b\[[0-9;?]*[ACHJKf]/.test(raw);
|
|
23097
23423
|
if (!hasTuiControl) return false;
|
|
23098
23424
|
|
|
23099
|
-
const text =
|
|
23100
|
-
return
|
|
23425
|
+
const text = _flushRedrawMarkers.markerTailText(raw, stripAnsi, REDRAW_CLASSIFY_TAIL_BYTES);
|
|
23426
|
+
return _flushRedrawMarkers.hasClaudeTuiMarkers(text);
|
|
23101
23427
|
}
|
|
23102
23428
|
|
|
23103
23429
|
function _isClaudeTuiRedrawContinuation(batch) {
|
|
@@ -24315,6 +24641,10 @@ function handleCreate(ws, msg) {
|
|
|
24315
24641
|
suppressedUiRefreshChunk ? 'output-quiet:ui-refresh-suppressed' : 'output-quiet'
|
|
24316
24642
|
);
|
|
24317
24643
|
_scheduleFingerprintRefreshAfterOutputQuiet(session);
|
|
24644
|
+
// Option A.5: also push an authoritative keyframe at a controlled frame rate DURING
|
|
24645
|
+
// sustained output (throttled), so a mid-turn byte-stream shear self-corrects within
|
|
24646
|
+
// one interval instead of persisting until the turn quiets.
|
|
24647
|
+
_maybeStreamKeyframe(session);
|
|
24318
24648
|
}
|
|
24319
24649
|
if (busyStatusChunk && waitingForInput && !restoreActivitySuppressed && !suppressedInputEchoChunk) {
|
|
24320
24650
|
const providerId = session?._providerId || _providerIdFromCmd(session?.cmd || '') || 'provider';
|
|
@@ -25531,11 +25861,31 @@ function _interactiveSerializePending() {
|
|
|
25531
25861
|
function _serveCachedSnapshotOnSerializeTimeout(ws, session, sessionId, cols, rows, restoreSource, clientRestoreSeq) {
|
|
25532
25862
|
if (!ws || ws.readyState !== 1) return false;
|
|
25533
25863
|
if (!session) return false;
|
|
25864
|
+
// A REFLOW (unlike a cold attach) targets a terminal that already has a rendered frame
|
|
25865
|
+
// on screen. The bridge below is applied as a forced repaint EXEMPT from the client
|
|
25866
|
+
// seq-behind guard — so bridging a STALE frame over a reflow REVERTS a good render to a
|
|
25867
|
+
// bad one. That is exactly the "reflow fixes it, then it reverts in a second" loop on a
|
|
25868
|
+
// huge, continuously-emitting Codex session whose ~800KB serialize keeps timing out:
|
|
25869
|
+
// each timeout re-paints the dirty cache. For a reflow we therefore refuse to bridge a
|
|
25870
|
+
// STALE cache (dirty, or the interim/wrong-width STEPS 2-4 below) — we leave the client's
|
|
25871
|
+
// current frame untouched (the reflow falls to data:null → keep-frame) and let the
|
|
25872
|
+
// heartbeat / stale-skip retry pull a fresh frame once the worker frees. A cold ATTACH
|
|
25873
|
+
// still bridges (a stale frame beats a 30s blank). Only a CLEAN dims-correct cache (a
|
|
25874
|
+
// genuine good frame, not a revert) is still served on a reflow.
|
|
25875
|
+
const isReflow = restoreSource === 'reflow';
|
|
25534
25876
|
// STEP 1 (always on): a complete, dimension-correct cached frame — the ideal bridge.
|
|
25535
25877
|
// cachedSnapshotMatchesDimensions also rejects an interim cache, so this is a full frame.
|
|
25536
25878
|
// Applied as a forced full repaint (RIS), exempt from the client seq-behind guard; the
|
|
25537
25879
|
// heartbeat divergence check + settle re-check pull a FRESH frame once the worker frees.
|
|
25538
25880
|
if (session._cachedSnapshot && cachedSnapshotMatchesDimensions(session, cols, rows)) {
|
|
25881
|
+
if (isReflow && session._cachedSnapshotDirty) {
|
|
25882
|
+
recordSessionDiagnostic(sessionId, 'serialize-timeout-dirty-reflow-skip', {
|
|
25883
|
+
cols: Number(session._cachedSnapshotCols) || 0,
|
|
25884
|
+
rows: Number(session._cachedSnapshotRows) || 0,
|
|
25885
|
+
bytes: String(session._cachedSnapshot || '').length,
|
|
25886
|
+
});
|
|
25887
|
+
return false; // keep the client's current frame; do not revert it to the dirty cache
|
|
25888
|
+
}
|
|
25539
25889
|
recordSessionDiagnostic(sessionId, 'serialize-timeout-cache-fallback', {
|
|
25540
25890
|
restoreSource: restoreSource || '',
|
|
25541
25891
|
cols: Number(session._cachedSnapshotCols) || 0,
|
|
@@ -25562,6 +25912,10 @@ function _serveCachedSnapshotOnSerializeTimeout(ws, session, sessionId, cols, ro
|
|
|
25562
25912
|
// interim branch paints it readable (seq-guard exempt, never reverted) and waits for the
|
|
25563
25913
|
// fresh frame; the heartbeat/reconcile then pulls a dims-correct serialize and heals it.
|
|
25564
25914
|
if (!CTM_SERIALIZE_TIMEOUT_BRIDGE) return false;
|
|
25915
|
+
// For a reflow, an interim/wrong-width bridge would also revert the live frame (it is
|
|
25916
|
+
// seq-guard-exempt too). The client already has content — keep it; only cold restores
|
|
25917
|
+
// need this anti-blank bridge.
|
|
25918
|
+
if (isReflow) return false;
|
|
25565
25919
|
_ensureScrollbackTailCache(session); // loads the disk tail into _cachedSnapshot if there's no in-memory cache
|
|
25566
25920
|
if (!session._cachedSnapshot) return false;
|
|
25567
25921
|
// Never bridge a Codex transcript-pager frame (it would paint the pager over live output).
|
|
@@ -26830,6 +27184,88 @@ async function _pushSecondarySnapshots(session, sessionId, reason) {
|
|
|
26830
27184
|
}
|
|
26831
27185
|
}
|
|
26832
27186
|
}
|
|
27187
|
+
// --- Option A: authoritative keyframe push (the missing "every boundary" I-frame) ---
|
|
27188
|
+
//
|
|
27189
|
+
// Unlike _pushSecondarySnapshots (secondaries only), this re-syncs EVERY viewer of the
|
|
27190
|
+
// session — primary included — by serializing the headless mirror at each viewer's own
|
|
27191
|
+
// dims and sending it as a 'keyframe' snapshot. It is the server half of Option A: on the
|
|
27192
|
+
// output-quiet boundary the client is handed ground truth, so the client no longer has to
|
|
27193
|
+
// DETECT drift (the fingerprint/garble/flash-loop heal zoo) — it just renders truth. The
|
|
27194
|
+
// client skips the repaint if its viewport already matches the keyframe fp (idempotent
|
|
27195
|
+
// push, not a divergence-driven pull). No-op unless KEYFRAME_SYNC_ENABLED.
|
|
27196
|
+
const KEYFRAME_PUSH_DEBOUNCE_MS = 120;
|
|
27197
|
+
function _scheduleKeyframePush(session, sessionId, reason) {
|
|
27198
|
+
if (!KEYFRAME_SYNC_ENABLED || !session || session._exited) return;
|
|
27199
|
+
if (session._keyframePushTimer) return; // coalesce a burst into one push
|
|
27200
|
+
session._keyframePushTimer = setTimeout(() => {
|
|
27201
|
+
session._keyframePushTimer = null;
|
|
27202
|
+
_pushKeyframeSnapshots(session, sessionId, reason).catch(() => {});
|
|
27203
|
+
}, KEYFRAME_PUSH_DEBOUNCE_MS);
|
|
27204
|
+
if (session._keyframePushTimer && typeof session._keyframePushTimer.unref === 'function') {
|
|
27205
|
+
session._keyframePushTimer.unref();
|
|
27206
|
+
}
|
|
27207
|
+
}
|
|
27208
|
+
async function _pushKeyframeSnapshots(session, sessionId, reason) {
|
|
27209
|
+
if (!KEYFRAME_SYNC_ENABLED) return;
|
|
27210
|
+
if (!sessions.has(sessionId) || sessions.get(sessionId) !== session) return;
|
|
27211
|
+
if (session._keyframePushInFlight) { session._keyframePushAgain = true; session._keyframePushAgainReason = reason; return; }
|
|
27212
|
+
// Only push when output could have changed since the last keyframe we sent. A purely
|
|
27213
|
+
// idle session that produced no new output needs no re-sync (and the client already
|
|
27214
|
+
// pulled a keyframe on activation). Bounds the serialize cost on giant sessions.
|
|
27215
|
+
const marker = _snapshotOutputMarker(session);
|
|
27216
|
+
if (marker && session._lastKeyframeMarker === marker) return;
|
|
27217
|
+
const arb = _viewerArbitration(session, sessionId);
|
|
27218
|
+
if (!arb.viewers.length) return;
|
|
27219
|
+
const primaryCols = arb.primaryCols || session._ptyCols || 0;
|
|
27220
|
+
const primaryRows = arb.primaryRows || session._ptyRows || 0;
|
|
27221
|
+
// Distinct viewer grids → one serialize per grid, fanned out to its viewers.
|
|
27222
|
+
const byGrid = new Map();
|
|
27223
|
+
for (const v of arb.viewers) {
|
|
27224
|
+
const key = v.cols + 'x' + v.rows;
|
|
27225
|
+
if (!byGrid.has(key)) byGrid.set(key, { cols: v.cols, rows: v.rows, wss: [] });
|
|
27226
|
+
byGrid.get(key).wss.push(v.ws);
|
|
27227
|
+
}
|
|
27228
|
+
const scrollbackRows = normalizeSnapshotScrollbackRows(session._snapshotScrollbackRows);
|
|
27229
|
+
const useRolloutHistory = _codexRolloutHistoryActive(session, sessionId);
|
|
27230
|
+
session._keyframePushInFlight = true;
|
|
27231
|
+
try {
|
|
27232
|
+
if (typeof session._flushHeadlessFeed === 'function') session._flushHeadlessFeed();
|
|
27233
|
+
for (const grid of byGrid.values()) {
|
|
27234
|
+
let resp = await _serializeSessionSnapshotAtDims(sessionId, grid.cols, grid.rows, useRolloutHistory ? 0 : scrollbackRows);
|
|
27235
|
+
if (resp && resp.data && useRolloutHistory) resp = _composeCodexRolloutResp(session, sessionId, resp);
|
|
27236
|
+
if (!sessions.has(sessionId) || sessions.get(sessionId) !== session) return;
|
|
27237
|
+
if (!resp || !resp.data) continue;
|
|
27238
|
+
const payload = JSON.stringify({
|
|
27239
|
+
type: 'snapshot', id: sessionId, data: resp.data,
|
|
27240
|
+
ptyCols: resp.cols, ptyRows: resp.rows, scrollback: resp.scrollback,
|
|
27241
|
+
restoreSource: 'keyframe', restoreReason: reason || 'keyframe', fp: resp.fp,
|
|
27242
|
+
autoStartRestore: true,
|
|
27243
|
+
snapshotMarker: marker || undefined,
|
|
27244
|
+
snapshotCurrentMarker: _snapshotOutputMarker(session),
|
|
27245
|
+
});
|
|
27246
|
+
for (const ws of grid.wss) {
|
|
27247
|
+
if (ws.readyState === 1) { try { ws.send(payload); } catch {} }
|
|
27248
|
+
}
|
|
27249
|
+
}
|
|
27250
|
+
session._lastKeyframeMarker = marker;
|
|
27251
|
+
} finally {
|
|
27252
|
+
// Restore authoritative (primary) dims so the live feed + heartbeat resume at the
|
|
27253
|
+
// primary grid (mirrors _pushSecondarySnapshots).
|
|
27254
|
+
if (primaryCols > 0 && primaryRows > 0) {
|
|
27255
|
+
try { headlessWorker.postMessage({ type: 'resize', sessionId, cols: primaryCols, rows: primaryRows }); } catch {}
|
|
27256
|
+
}
|
|
27257
|
+
session._keyframePushInFlight = false;
|
|
27258
|
+
if (session._keyframePushAgain) {
|
|
27259
|
+
session._keyframePushAgain = false;
|
|
27260
|
+
// Preserve a stream-keyframe reason across the coalesce so the client still applies it
|
|
27261
|
+
// via the mid-stream path (a generic 'coalesced-again' would hit the late-snapshot gates).
|
|
27262
|
+
const againReason = session._keyframePushAgainReason === 'stream-keyframe' ? 'stream-keyframe' : 'coalesced-again';
|
|
27263
|
+
session._keyframePushAgainReason = null;
|
|
27264
|
+
_scheduleKeyframePush(session, sessionId, againReason);
|
|
27265
|
+
}
|
|
27266
|
+
}
|
|
27267
|
+
}
|
|
27268
|
+
|
|
26833
27269
|
// Tell a viewer its role so the client knows whether to consume the raw stream
|
|
26834
27270
|
// (primary) or render from pushed snapshots (secondary). Idempotent per-ws.
|
|
26835
27271
|
function _notifyViewerRole(ws, sessionId, role) {
|
|
@@ -29569,6 +30005,17 @@ function _sessionPayload(s, options = {}) {
|
|
|
29569
30005
|
lastPtyActivity,
|
|
29570
30006
|
liveStatus,
|
|
29571
30007
|
liveStatusAt: liveStatus ? now : null,
|
|
30008
|
+
...(() => {
|
|
30009
|
+
const ph = _sessionPhaseProjection(s, now, liveStatus, null);
|
|
30010
|
+
return ph ? {
|
|
30011
|
+
phase: ph.phase,
|
|
30012
|
+
phaseCls: ph.cls,
|
|
30013
|
+
phaseSource: ph.source,
|
|
30014
|
+
phaseConfidence: ph.confidence,
|
|
30015
|
+
phaseReason: ph.reason,
|
|
30016
|
+
phaseAt: now,
|
|
30017
|
+
} : {};
|
|
30018
|
+
})(),
|
|
29572
30019
|
activeTurnOpen,
|
|
29573
30020
|
active_turn_open: activeTurnOpen,
|
|
29574
30021
|
activeTurnStartedAt: activeTurnStartedAt || null,
|
|
@@ -30190,6 +30637,7 @@ setInterval(() => {
|
|
|
30190
30637
|
const activeTurnStartedAt = _activeTurnObservedMs(s._activeTurnStartedAt, now, s, 'activeTurnStartedAt');
|
|
30191
30638
|
const activeTurnLastEvidenceAt = _activeTurnObservedMs(s._activeTurnLastEvidenceAt, now, s, 'activeTurnLastEvidenceAt');
|
|
30192
30639
|
const restoreInterrupted = _isRestoreInterruptedSession(s);
|
|
30640
|
+
const phaseProjection = _sessionPhaseProjection(s, now, liveStatus, liveStatusReason);
|
|
30193
30641
|
active.push({
|
|
30194
30642
|
id,
|
|
30195
30643
|
ts: ptyActivity || lastActivity || createdAt || null,
|
|
@@ -30198,6 +30646,11 @@ setInterval(() => {
|
|
|
30198
30646
|
status: liveStatus,
|
|
30199
30647
|
statusReason: liveStatusReason,
|
|
30200
30648
|
status_reason: liveStatusReason,
|
|
30649
|
+
phase: phaseProjection ? phaseProjection.phase : undefined,
|
|
30650
|
+
phaseCls: phaseProjection ? phaseProjection.cls : undefined,
|
|
30651
|
+
phaseSource: phaseProjection ? phaseProjection.source : undefined,
|
|
30652
|
+
phaseConfidence: phaseProjection ? phaseProjection.confidence : undefined,
|
|
30653
|
+
phaseReason: phaseProjection ? phaseProjection.reason : undefined,
|
|
30201
30654
|
activeTurnOpen,
|
|
30202
30655
|
active_turn_open: activeTurnOpen,
|
|
30203
30656
|
activeTurnStartedAt: activeTurnStartedAt || null,
|
|
@@ -30726,6 +31179,120 @@ async function apiServicesStatus(req, res) {
|
|
|
30726
31179
|
}));
|
|
30727
31180
|
}
|
|
30728
31181
|
|
|
31182
|
+
// --- macOS Full Disk Access capability ---
|
|
31183
|
+
// Why: many sessions live under ~/Library/CloudStorage/Dropbox (a File Provider = "network
|
|
31184
|
+
// volume" to macOS) or NFS mounts. Touching those re-fires the "Coding Task Manager would like
|
|
31185
|
+
// to access files on a network volume" TCC prompt on every restart. FDA is a SUPERSET of the
|
|
31186
|
+
// network-volume / app-data / removable services, so one FDA grant (persisting under the stable
|
|
31187
|
+
// Dev-ID identity) ends all of them. We only NAG when FDA is missing AND there's actually a
|
|
31188
|
+
// network/cloud-backed session — and we can only detect + guide, since Apple forbids granting
|
|
31189
|
+
// FDA programmatically.
|
|
31190
|
+
let _capabilitiesCache = null;
|
|
31191
|
+
const CAPABILITIES_CACHE_TTL_MS = 30000;
|
|
31192
|
+
|
|
31193
|
+
// Recently-observed read-access denials (EPERM/EACCES) on a file an agent NEEDS to launch —
|
|
31194
|
+
// e.g. a Codex CODEX_HOME/config.toml that lives on Dropbox. Recording one makes the FDA
|
|
31195
|
+
// capability banner fire reliably (and name the cause) instead of letting the session fail
|
|
31196
|
+
// silently. Keyed by path; pruned by TTL so the banner self-clears once access is restored.
|
|
31197
|
+
const _accessDenials = new Map(); // path -> { path, provider, reason, code, at }
|
|
31198
|
+
const ACCESS_DENIAL_TTL_MS = 10 * 60 * 1000;
|
|
31199
|
+
|
|
31200
|
+
function _recordAccessDenial({ path: p, provider, reason, code }) {
|
|
31201
|
+
if (!p) return;
|
|
31202
|
+
_accessDenials.set(p, { path: p, provider: provider || '', reason: reason || '', code: code || 'EPERM', at: Date.now() });
|
|
31203
|
+
_capabilitiesCache = null; // surface it on the next capabilities poll
|
|
31204
|
+
}
|
|
31205
|
+
|
|
31206
|
+
function _recentAccessDenials(now = Date.now()) {
|
|
31207
|
+
const out = [];
|
|
31208
|
+
for (const [p, d] of _accessDenials) {
|
|
31209
|
+
if (now - d.at > ACCESS_DENIAL_TTL_MS) { _accessDenials.delete(p); continue; }
|
|
31210
|
+
out.push(d);
|
|
31211
|
+
}
|
|
31212
|
+
return out;
|
|
31213
|
+
}
|
|
31214
|
+
|
|
31215
|
+
// Provider-agnostic preflight: before spawning an agent that reads a config/home outside the
|
|
31216
|
+
// cwd, confirm we can actually read it. On a denial, LOG it and record it (so the user is
|
|
31217
|
+
// ASKED for Full Disk Access) rather than letting the agent crash-loop on an unreadable file.
|
|
31218
|
+
// Never blocks the launch — the agent still spawns; the banner explains why it may fail.
|
|
31219
|
+
function _preflightAgentPathAccess({ path: p, provider, reason }) {
|
|
31220
|
+
try {
|
|
31221
|
+
const code = macCaps.probePathReadDenied(p);
|
|
31222
|
+
if (!code) return false;
|
|
31223
|
+
console.warn(
|
|
31224
|
+
`[access-preflight] ${provider || 'agent'} cannot read ${p} (${code}). ` +
|
|
31225
|
+
'It is likely on a File Provider / network volume (Dropbox/iCloud/NFS) and CTM lacks ' +
|
|
31226
|
+
'Full Disk Access — surfacing the FDA banner so it can be granted instead of failing silently.'
|
|
31227
|
+
);
|
|
31228
|
+
_recordAccessDenial({ path: p, provider, reason, code });
|
|
31229
|
+
return true;
|
|
31230
|
+
} catch { return false; }
|
|
31231
|
+
}
|
|
31232
|
+
|
|
31233
|
+
function _countNonLocalVolumeSessions() {
|
|
31234
|
+
try {
|
|
31235
|
+
const tasks = dbModule.listStartupTasks() || [];
|
|
31236
|
+
let n = 0;
|
|
31237
|
+
for (const t of tasks) {
|
|
31238
|
+
if (!t || (t.status && t.status === 'exited')) continue;
|
|
31239
|
+
if ((t.cwd && macCaps.isPathOnNonLocalVolume(t.cwd)) ||
|
|
31240
|
+
(t.worktree_path && macCaps.isPathOnNonLocalVolume(t.worktree_path))) n++;
|
|
31241
|
+
}
|
|
31242
|
+
return n;
|
|
31243
|
+
} catch { return 0; }
|
|
31244
|
+
}
|
|
31245
|
+
|
|
31246
|
+
function _computeCapabilities() {
|
|
31247
|
+
const platform = process.platform;
|
|
31248
|
+
const fdaGranted = macCaps.fullDiskAccessGranted(); // true | false | null (undeterminable)
|
|
31249
|
+
const networkSessionCount = _countNonLocalVolumeSessions();
|
|
31250
|
+
const accessDenials = _recentAccessDenials();
|
|
31251
|
+
const guidance = macCaps.fdaGuidance();
|
|
31252
|
+
// Nag when we are SURE FDA is missing AND there's a concrete reason: either a network/cloud
|
|
31253
|
+
// session (the cwd heuristic) OR an OBSERVED read denial on an agent's config/home (the
|
|
31254
|
+
// authoritative probe — catches the Codex-on-Dropbox case the cwd heuristic misses).
|
|
31255
|
+
const needsAttention = platform === 'darwin' && fdaGranted === false
|
|
31256
|
+
&& (networkSessionCount > 0 || accessDenials.length > 0);
|
|
31257
|
+
return {
|
|
31258
|
+
platform,
|
|
31259
|
+
fdaGranted,
|
|
31260
|
+
needsAttention,
|
|
31261
|
+
networkSessionCount,
|
|
31262
|
+
accessDenials: accessDenials.map((d) => ({ path: d.path, provider: d.provider, code: d.code })),
|
|
31263
|
+
bundlePath: guidance.bundlePath,
|
|
31264
|
+
settingsUrl: guidance.settingsUrl,
|
|
31265
|
+
hint: guidance.hint,
|
|
31266
|
+
};
|
|
31267
|
+
}
|
|
31268
|
+
|
|
31269
|
+
function apiCapabilities(req, res) {
|
|
31270
|
+
const now = Date.now();
|
|
31271
|
+
if (!_capabilitiesCache || now - _capabilitiesCache.ts > CAPABILITIES_CACHE_TTL_MS) {
|
|
31272
|
+
_capabilitiesCache = { ts: now, payload: _computeCapabilities() };
|
|
31273
|
+
}
|
|
31274
|
+
return sendJsonResponse(res, 200, _capabilitiesCache.payload);
|
|
31275
|
+
}
|
|
31276
|
+
|
|
31277
|
+
function apiCapabilitiesOpenFda(req, res) {
|
|
31278
|
+
if (process.platform !== 'darwin') {
|
|
31279
|
+
return sendJsonResponse(res, 400, { ok: false, error: 'macOS only' });
|
|
31280
|
+
}
|
|
31281
|
+
const g = macCaps.fdaGuidance();
|
|
31282
|
+
// CTM_FDA_OPEN_STUB lets /ctm-dev point this at /usr/bin/true so tests can exercise the
|
|
31283
|
+
// endpoint without actually launching System Settings.
|
|
31284
|
+
const opener = process.env.CTM_FDA_OPEN_STUB || 'open';
|
|
31285
|
+
try {
|
|
31286
|
+
execFile(opener, [g.settingsUrl], () => {}); // jump to Full Disk Access pane
|
|
31287
|
+
execFile(opener, ['-R', g.bundlePath], () => {}); // reveal the bundle so it can be dragged in
|
|
31288
|
+
console.log(`[capabilities] open-fda -> ${opener} ${g.settingsUrl} ; reveal ${g.bundlePath}`);
|
|
31289
|
+
} catch (e) {
|
|
31290
|
+
return sendJsonResponse(res, 500, { ok: false, error: e.message });
|
|
31291
|
+
}
|
|
31292
|
+
_capabilitiesCache = null; // force a fresh probe next poll so the banner clears once granted
|
|
31293
|
+
return sendJsonResponse(res, 200, { ok: true, settingsUrl: g.settingsUrl, bundlePath: g.bundlePath, hint: g.hint });
|
|
31294
|
+
}
|
|
31295
|
+
|
|
30729
31296
|
// --- Worktree API Handlers ---
|
|
30730
31297
|
const gitUtilsWorktree = require('./git-utils');
|
|
30731
31298
|
const branchInventory = require('./lib/branch-inventory');
|
|
@@ -32312,7 +32879,15 @@ function _startupTaskRestoreCwd(task) {
|
|
|
32312
32879
|
identity: _sessionWorkspaceIdentity({ id: task.session_id }),
|
|
32313
32880
|
worktrees: [],
|
|
32314
32881
|
});
|
|
32315
|
-
|
|
32882
|
+
// At cold boot NO session is active yet (this is a deferred restore — the tab isn't open).
|
|
32883
|
+
// Statting a worktree on a Dropbox/NFS/external volume here is what fires the macOS
|
|
32884
|
+
// "network volume" TCC prompt every restart. Skip the existsSync for non-local paths and
|
|
32885
|
+
// trust the stored path; the existence check happens later when the user actually opens
|
|
32886
|
+
// the tab (an intentional access that persists with one Allow / Full Disk Access).
|
|
32887
|
+
if (binding?.worktreePath) {
|
|
32888
|
+
if (macCaps.isPathOnNonLocalVolume(binding.worktreePath)) return binding.worktreePath;
|
|
32889
|
+
if (fs.existsSync(binding.worktreePath)) return binding.worktreePath;
|
|
32890
|
+
}
|
|
32316
32891
|
} catch (e) {
|
|
32317
32892
|
console.error('[restore] workspace binding cwd resolution error:', e.message);
|
|
32318
32893
|
}
|