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.
Files changed (45) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/api-prompts.js +11 -6
  4. package/template/claude-task-manager/docs/session-status-redesign.html +554 -0
  5. package/template/claude-task-manager/docs/terminal-rendering-redesign.html +529 -0
  6. package/template/claude-task-manager/lib/flush-redraw-markers.js +72 -0
  7. package/template/claude-task-manager/lib/macos-capabilities.js +190 -0
  8. package/template/claude-task-manager/lib/session-messages-projection.js +224 -3
  9. package/template/claude-task-manager/lib/ttl-memo.js +61 -0
  10. package/template/claude-task-manager/public/index.html +892 -11
  11. package/template/claude-task-manager/public/js/activation-render-check.js +40 -2
  12. package/template/claude-task-manager/public/js/session-phase.js +370 -0
  13. package/template/claude-task-manager/public/js/setup.js +74 -1
  14. package/template/claude-task-manager/public/js/stream-view.js +56 -2
  15. package/template/claude-task-manager/server.js +643 -68
  16. package/template/claude-task-manager/workers/read-pool-worker.js +10 -0
  17. package/template/package.json +1 -1
  18. package/template/wall-e/agent.js +130 -24
  19. package/template/wall-e/api-walle.js +12 -1
  20. package/template/wall-e/brain.js +290 -4
  21. package/template/wall-e/chat.js +30 -25
  22. package/template/wall-e/coding/session-plan.js +79 -0
  23. package/template/wall-e/coding-orchestrator.js +9 -3
  24. package/template/wall-e/coding-prompts.js +10 -3
  25. package/template/wall-e/embeddings.js +192 -17
  26. package/template/wall-e/http/model-admin.js +109 -0
  27. package/template/wall-e/lib/event-loop-monitor.js +2 -2
  28. package/template/wall-e/lib/scheduler-worker-jobs.js +156 -121
  29. package/template/wall-e/lib/scheduler.js +226 -13
  30. package/template/wall-e/lib/worker-thread-pool.js +58 -4
  31. package/template/wall-e/llm/ollama-library.js +126 -0
  32. package/template/wall-e/llm/ollama.js +13 -0
  33. package/template/wall-e/llm/provider-backpressure.js +134 -0
  34. package/template/wall-e/llm/provider-health-state.js +24 -0
  35. package/template/wall-e/loops/backfill.js +43 -16
  36. package/template/wall-e/loops/initiative.js +1 -0
  37. package/template/wall-e/loops/think.js +38 -5
  38. package/template/wall-e/mcp-server.js +20 -4
  39. package/template/wall-e/skills/skill-fallback.js +34 -1
  40. package/template/wall-e/skills/skill-planner.js +60 -2
  41. package/template/wall-e/sources/jsonl-utils.js +84 -11
  42. package/template/wall-e/telemetry.js +42 -7
  43. package/template/wall-e/tools/local-tools.js +16 -0
  44. package/template/wall-e/workers/runtime-worker.js +33 -1
  45. 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
- const text = String(reason || '').toLowerCase();
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 _timelineSummaryForStandup(payload.id, timelineIdentity)
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
- if (dbModule.sessionContentRowsAvailable(sessionId)) {
13209
- // noCache used to skip the row store entirely and re-parse the whole JSONL on the main
13210
- // loop for a large Codex session that froze the event loop 6–22s, and the Conversation
13211
- // tab fires nocache=1 on every "fresh" refresh during a running burst (the reported codex
13212
- // freeze). The row store is the durable source (kept fresh by the importer) and the
13213
- // offset-0 page / sendMessages both merge the live stream tail, so it is fresh enough; on
13214
- // noCache we additionally kick an incremental import so the next poll reflects any
13215
- // just-written external JSONL — without ever blocking the loop on a full parse.
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(sessionId, { offset, limit, mode: paginationMode }, { deferOnStorm: true });
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(sessionId, { fallbackToBlob: false }),
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 ? { source: 'session-rows' } : {});
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
- try {
14058
- const ids = _messageIndexPromptSourceIds(sessionId);
14059
- // USER-scoped: the prompt list only changes when a user prompt is added. Keying on
14060
- // user-only freshness means a session streaming assistant output (which grows the
14061
- // all-message index every chunk) no longer busts the prompt-preview cache, so the
14062
- // sha1-per-prompt summary isn't recomputed on every poll of an active session.
14063
- const f = dbModule.getSessionUserPromptFreshness(ids);
14064
- if (!f) return null; // null => do not trust cache (DB error)
14065
- return `u${f.maxIndex}:${f.rows}`;
14066
- } catch { return null; }
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
- try {
15019
- return _getAgentSessionRowsForAnyId(sessionId).some((row) => {
15020
- const provider = String(row?.provider || '').trim().toLowerCase();
15021
- return provider === 'codex' || _isCodexRolloutFilePath(row?.jsonl_path);
15022
- });
15023
- } catch {
15024
- return false;
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
- if (resumeCwd && resumeCwd !== cwd && fs.existsSync(resumeCwd)) {
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
- const text = stripAnsi(raw);
23039
- const hasPromptWithDollarTrigger =
23040
- /(?:^|\n)\s*[›>]\s*[^\n]*\$/m.test(text) ||
23041
- /[›>]\s*[^\n\r]{0,240}\$[A-Za-z0-9_.:-]*/.test(text);
23042
- if (!hasPromptWithDollarTrigger) return false;
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 = stripAnsi(raw);
23082
- return /Working|esc to interrupt|Conversation interrupted|Explored|Ran|Read|Search|Use \/skills|\bgpt-[\w.-]+|\b(?:x?high|medium|low)\b.*(?:fast|[~/])/i.test(text);
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 = stripAnsi(raw);
23100
- return /Crunching|esc to interrupt|Esc to cancel|Do you want to|Bash(?:\(| command)?|Read\(|Edit\(|Write\(|Grep\(|Glob\(|TodoWrite|tokens|thought for|ctrl\+o to expand|⎿|Claude Code|Working|Cogitating|Forming|plan mode|accept edits|shift\+tab|for agents|running stop hooks|[❯⏵]/i.test(text);
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
- if (binding?.worktreePath && fs.existsSync(binding.worktreePath)) return binding.worktreePath;
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
  }