create-walle 0.9.13 → 0.9.14

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 (58) hide show
  1. package/README.md +6 -1
  2. package/bin/create-walle.js +195 -30
  3. package/bin/mcp-inject.js +18 -53
  4. package/package.json +3 -1
  5. package/template/claude-task-manager/approval-agent.js +7 -0
  6. package/template/claude-task-manager/docs/session-standup-command-center-design.md +242 -0
  7. package/template/claude-task-manager/git-utils.js +111 -3
  8. package/template/claude-task-manager/lib/session-history.js +144 -16
  9. package/template/claude-task-manager/lib/session-standup.js +409 -0
  10. package/template/claude-task-manager/lib/standup-attention.js +200 -0
  11. package/template/claude-task-manager/lib/status-hooks.js +8 -2
  12. package/template/claude-task-manager/lib/update-telemetry.js +114 -0
  13. package/template/claude-task-manager/lib/walle-default-model.js +55 -0
  14. package/template/claude-task-manager/lib/walle-mcp-auto-config.js +62 -0
  15. package/template/claude-task-manager/lib/walle-supervisor.js +83 -19
  16. package/template/claude-task-manager/lib/worktree-cwd.js +82 -0
  17. package/template/claude-task-manager/providers/codex-mcp.js +104 -0
  18. package/template/claude-task-manager/providers/index.js +2 -0
  19. package/template/claude-task-manager/public/css/setup.css +2 -1
  20. package/template/claude-task-manager/public/css/walle.css +5 -0
  21. package/template/claude-task-manager/public/index.html +1596 -283
  22. package/template/claude-task-manager/public/js/session-search-utils.js +171 -1
  23. package/template/claude-task-manager/public/js/setup.js +62 -19
  24. package/template/claude-task-manager/public/js/stream-view.js +55 -6
  25. package/template/claude-task-manager/public/js/walle-session.js +73 -16
  26. package/template/claude-task-manager/public/js/walle.js +34 -2
  27. package/template/claude-task-manager/server.js +780 -177
  28. package/template/claude-task-manager/session-integrity.js +58 -15
  29. package/template/claude-task-manager/workers/approval-widget-validator.js +15 -5
  30. package/template/claude-task-manager/workers/state-detectors/codex.js +6 -0
  31. package/template/package.json +1 -1
  32. package/template/wall-e/agent.js +36 -7
  33. package/template/wall-e/api-walle.js +72 -20
  34. package/template/wall-e/coding/stream-processor.js +22 -2
  35. package/template/wall-e/coding-orchestrator.js +26 -6
  36. package/template/wall-e/eval/agent-runner.js +16 -4
  37. package/template/wall-e/eval/benchmark-generator.js +21 -1
  38. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -596
  39. package/template/wall-e/eval/codex-cli-baseline.js +633 -0
  40. package/template/wall-e/eval/eval-orchestrator.js +3 -3
  41. package/template/wall-e/eval/run-agent-benchmarks.js +11 -3
  42. package/template/wall-e/eval/run-codex-cli-baseline.js +177 -0
  43. package/template/wall-e/lib/mcp-integration.js +220 -0
  44. package/template/wall-e/llm/ollama.js +47 -8
  45. package/template/wall-e/llm/ollama.plugin.json +1 -1
  46. package/template/wall-e/llm/tool-adapter.js +1 -0
  47. package/template/wall-e/loops/ingest.js +42 -8
  48. package/template/wall-e/mcp-server.js +272 -10
  49. package/template/wall-e/memory/ctm-session-context.js +910 -0
  50. package/template/wall-e/server.js +26 -1
  51. package/template/wall-e/skills/_bundled/scan-ctm-sessions/SKILL.md +20 -0
  52. package/template/wall-e/skills/_bundled/scan-ctm-sessions/run.js +43 -0
  53. package/template/wall-e/skills/skill-planner.js +52 -3
  54. package/template/wall-e/tools/builtin-middleware.js +55 -2
  55. package/template/wall-e/tools/shell-policy.js +1 -1
  56. package/template/wall-e/tools/slack-owner.js +104 -0
  57. package/template/website/index.html +2 -2
  58. package/template/builder-journal.md +0 -17
@@ -35,6 +35,7 @@ const {
35
35
  getCodexThreadById,
36
36
  getCodexThreadResumeCwd,
37
37
  getResumeSpec,
38
+ listCodexSessionsFromRollouts,
38
39
  parseSessionStartMs,
39
40
  parseCodexJsonlIntoMessages,
40
41
  parseCodexJsonlFileIntoMessages,
@@ -47,6 +48,13 @@ const { JsonlWatcher } = require('./lib/fs-watcher');
47
48
  const compactStitch = require('./lib/compact-stitch');
48
49
  const { registerSessionJobs, registerStreamJobs } = require('./lib/session-jobs');
49
50
  const { SessionStream } = require('./lib/session-stream');
51
+ const { buildSessionStandupSnapshot } = require('./lib/session-standup');
52
+ const {
53
+ buildStandupAttentionContext,
54
+ heuristicStandupAttention,
55
+ mergeStickyStandupAttention,
56
+ parseStandupAttentionResult,
57
+ } = require('./lib/standup-attention');
50
58
  const { getAllSessionFilesAsync: getAllSessionFilesAsyncFromUtils } = require('./session-utils');
51
59
  const { SessionCapture } = require('./lib/session-capture');
52
60
  const { resolveFallback: resolveLaunchFallback, isEarlyExit } = require('./lib/launch-presets');
@@ -62,8 +70,14 @@ const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
62
70
  const SessionSearchUtils = require('./public/js/session-search-utils.js');
63
71
  const agentHooksInstaller = require('./lib/agent-hooks-installer');
64
72
  const statusHooks = require('./lib/status-hooks');
73
+ const { ensureWalleMcpForAgentSession } = require('./lib/walle-mcp-auto-config');
65
74
  const { getStateDetector } = require('./workers/state-detectors');
66
75
  const { canResumeAgent, getAgentCapabilities, normalizeAgentType } = require('./lib/agent-capabilities');
76
+ const {
77
+ clientUpdateTelemetryEvent,
78
+ errorKind: updateTelemetryErrorKind,
79
+ trackUpdateTelemetry,
80
+ } = require('./lib/update-telemetry');
67
81
  const { resolveWalleChatContext } = require('./lib/walle-session-context');
68
82
  const {
69
83
  applyWalleToolEvent,
@@ -89,6 +103,7 @@ const {
89
103
  loadConfig,
90
104
  ensureWalleToken,
91
105
  } = require('./lib/server-config');
106
+ const { resolveWalleDefaultModelSelection } = require('./lib/walle-default-model');
92
107
  const { createWalleSupervisor } = require('./lib/walle-supervisor');
93
108
  const oauthProxySupervisor = require('./lib/oauth-proxy-supervisor');
94
109
 
@@ -163,7 +178,7 @@ function _storedProviderKey(brain, type) {
163
178
  }
164
179
  }
165
180
 
166
- async function generateSessionSummaryWithWalleProvider({ turnsText, sysPrompt }) {
181
+ async function generateSessionSummaryWithWalleProvider({ turnsText, sysPrompt, maxTokens = 60 }) {
167
182
  const brain = getWalleBrain();
168
183
  const provider = sanitizeSetupProviderType(
169
184
  brain?.getKv?.('walle_provider') || process.env.WALLE_PROVIDER || 'anthropic'
@@ -209,7 +224,7 @@ async function generateSessionSummaryWithWalleProvider({ turnsText, sysPrompt })
209
224
  model: model || undefined,
210
225
  system: sysPrompt,
211
226
  messages: [{ role: 'user', content: turnsText }],
212
- maxTokens: 60,
227
+ maxTokens,
213
228
  temperature: 0.2,
214
229
  thinking: 'disabled',
215
230
  reasoningEffort: 'low',
@@ -294,10 +309,36 @@ function _isBusyStatusPtyChunk(session, data) {
294
309
  return !!detector.isBusyStatusChunk(filtered);
295
310
  }
296
311
 
312
+ const CTM_UI_REFRESH_ACTIVITY_SUPPRESS_MS = 2500;
313
+
314
+ function _markCtmUiRefreshActivitySuppression(session, source) {
315
+ if (!session) return;
316
+ session._ctmUiRefreshActivitySuppressUntil = Date.now() + CTM_UI_REFRESH_ACTIVITY_SUPPRESS_MS;
317
+ session._ctmUiRefreshActivitySuppressSource = source || 'ui-refresh';
318
+ }
319
+
320
+ function _suppressCtmUiRefreshActivity(session, statusOnlyChunk) {
321
+ if (!session || !statusOnlyChunk) return false;
322
+ const until = session._ctmUiRefreshActivitySuppressUntil || 0;
323
+ return until && Date.now() < until;
324
+ }
325
+
297
326
  function _isServerWaitingForInput(sessionId, session) {
298
327
  return !!(session?._waitingForInput || idleNotifyState.get(sessionId)?.notified);
299
328
  }
300
329
 
330
+ function _inputMayResolveWaiting(data, session) {
331
+ const s = String(data || '');
332
+ if (!s) return false;
333
+ if (s.includes('\r') || s.includes('\n')) return true;
334
+ if (s.includes('\x03') || s.includes('\x04')) return true; // Ctrl-C / Ctrl-D
335
+ const reason = session?._waitingForInputReason || '';
336
+ if (reason === 'approval' || reason === 'choice') {
337
+ return /^[\s]*[12yYnN\u001b][\s]*$/.test(s);
338
+ }
339
+ return false;
340
+ }
341
+
301
342
  function resolveAgentCliCommand(cmd) {
302
343
  return agentCliCache.resolveCliCommand(cmd, { cacheFile: AGENT_CLI_CACHE_FILE });
303
344
  }
@@ -2970,6 +3011,9 @@ async function handleApi(req, res, url) {
2970
3011
  if (url.pathname === '/api/recent-sessions' && req.method === 'GET') {
2971
3012
  return apiRecentSessions(req, res, url);
2972
3013
  }
3014
+ if (url.pathname === '/api/sessions/standup' && req.method === 'GET') {
3015
+ return apiSessionStandup(req, res, url);
3016
+ }
2973
3017
  if (url.pathname === '/api/sessions/git-status' && req.method === 'GET') {
2974
3018
  return apiSessionsGitStatus(req, res, url);
2975
3019
  }
@@ -3165,13 +3209,19 @@ async function handleApi(req, res, url) {
3165
3209
  res.writeHead(200, { 'Content-Type': 'application/json' });
3166
3210
  return res.end(JSON.stringify({ sessions: statuses }));
3167
3211
  }
3212
+ if (url.pathname === '/api/app/version' && req.method === 'GET') {
3213
+ return apiAppVersion(req, res);
3214
+ }
3168
3215
  // --- Update check endpoints ---
3169
3216
  if (url.pathname === '/api/updates/check' && req.method === 'GET') {
3170
- return apiUpdatesCheck(req, res);
3217
+ return apiUpdatesCheck(req, res, url);
3171
3218
  }
3172
3219
  if (url.pathname === '/api/updates/apply' && req.method === 'POST') {
3173
3220
  return apiUpdatesApply(req, res);
3174
3221
  }
3222
+ if (url.pathname === '/api/updates/telemetry' && req.method === 'POST') {
3223
+ return apiUpdatesTelemetry(req, res);
3224
+ }
3175
3225
 
3176
3226
  if (url.pathname === '/api/restart/ctm' && req.method === 'POST') {
3177
3227
  return apiRestartCtm(req, res);
@@ -3811,6 +3861,246 @@ async function apiSessionsGitStatus(req, res, url) {
3811
3861
  }
3812
3862
  }
3813
3863
 
3864
+ function _standupStreamSource() {
3865
+ return _sessionCapture || _sessionStream || null;
3866
+ }
3867
+
3868
+ function _standupSummaryFor(source, ctmSessionId, agentSessionId) {
3869
+ if (!source || typeof source.getSummary !== 'function') return null;
3870
+ const ids = [ctmSessionId, agentSessionId].filter(Boolean);
3871
+ for (const id of ids) {
3872
+ try {
3873
+ const summary = source.getSummary(id);
3874
+ if (summary) return summary;
3875
+ } catch {}
3876
+ }
3877
+ return null;
3878
+ }
3879
+
3880
+ const _standupAttentionCache = new Map();
3881
+
3882
+ function _standupAttentionKey(session) {
3883
+ return String(session?.id || session?.ctmSessionId || session?.agentSessionId || session?.sessionId || '');
3884
+ }
3885
+
3886
+ function _pruneStandupAttentionCache(now = Date.now()) {
3887
+ if (_standupAttentionCache.size <= 250) return;
3888
+ for (const [key, value] of _standupAttentionCache) {
3889
+ if (_standupAttentionCache.size <= 200) break;
3890
+ if (!value?.updatedAt || now - value.updatedAt > 24 * 60 * 60 * 1000) {
3891
+ _standupAttentionCache.delete(key);
3892
+ }
3893
+ }
3894
+ }
3895
+
3896
+ function _standupAttentionSystemPrompt() {
3897
+ return [
3898
+ 'Classify whether a CTM coding session has a current attention issue.',
3899
+ 'Return only compact JSON with keys: severity, recommendation, evidence, confidence.',
3900
+ 'severity must be one of: none, warning, failure.',
3901
+ 'Use failure only for an unresolved blocker/failure that currently prevents progress or needs operator action.',
3902
+ 'Use warning for a real non-blocking warning, risk, or caution that should remain visible.',
3903
+ 'Use none for historical warning/failure wording, resolved issues, normal progress, or generic status text.',
3904
+ 'evidence must be an array of at most 3 short phrases copied or paraphrased from the context.',
3905
+ 'confidence must be low, medium, or high.',
3906
+ ].join(' ');
3907
+ }
3908
+
3909
+ async function _classifyStandupAttentionWithAi(context) {
3910
+ if (!context?.text) return null;
3911
+ const result = await generateSessionSummaryWithWalleProvider({
3912
+ turnsText: context.text,
3913
+ sysPrompt: _standupAttentionSystemPrompt(),
3914
+ maxTokens: 220,
3915
+ });
3916
+ return parseStandupAttentionResult(result?.text, {
3917
+ source: 'ai',
3918
+ model: result?.model || '',
3919
+ });
3920
+ }
3921
+
3922
+ async function _standupAttentionFor(session, summary, now = Date.now()) {
3923
+ const key = _standupAttentionKey(session);
3924
+ if (!key) return null;
3925
+ const context = buildStandupAttentionContext({ session, summary });
3926
+ const cached = _standupAttentionCache.get(key);
3927
+ if (cached && cached.hash === context.hash && cached.attention) {
3928
+ return cached.attention;
3929
+ }
3930
+
3931
+ let next = null;
3932
+ if (context.hasAttentionLanguage) {
3933
+ try {
3934
+ next = await _classifyStandupAttentionWithAi(context);
3935
+ } catch (e) {
3936
+ console.warn('[standup] attention classifier failed:', e.message || e);
3937
+ }
3938
+ }
3939
+ if (!next) next = heuristicStandupAttention(context);
3940
+
3941
+ const attention = mergeStickyStandupAttention(cached?.attention || null, next, context.text, now);
3942
+ _standupAttentionCache.set(key, {
3943
+ hash: context.hash,
3944
+ attention,
3945
+ updatedAt: now,
3946
+ });
3947
+ _pruneStandupAttentionCache(now);
3948
+ return attention;
3949
+ }
3950
+
3951
+ async function _applyStandupAttention(pairs, now = Date.now()) {
3952
+ for (const pair of pairs) {
3953
+ const attention = await _standupAttentionFor(pair.session, pair.summary || {}, now);
3954
+ if (!attention || attention.severity === 'none') continue;
3955
+ pair.session.attention = attention;
3956
+ if (pair.summary) pair.summary.attention = attention;
3957
+ }
3958
+ }
3959
+
3960
+ function _standupTimeMs(value) {
3961
+ if (!value) return 0;
3962
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
3963
+ const parsed = Date.parse(String(value));
3964
+ return Number.isFinite(parsed) ? parsed : 0;
3965
+ }
3966
+
3967
+ function _normalizeStandupLiveStatus(status) {
3968
+ const text = String(status || '').toLowerCase();
3969
+ if (!text) return '';
3970
+ if (text === 'busy') return 'running';
3971
+ if (text === 'waiting-for-input') return 'waiting_input';
3972
+ if (['running', 'waiting', 'waiting_input', 'idle', 'exited'].includes(text)) return text;
3973
+ return '';
3974
+ }
3975
+
3976
+ const STANDUP_MIN_RUNNING_HOLD_MS = 5000;
3977
+
3978
+ function _standupProviderIdForSession(session) {
3979
+ if (!session) return '';
3980
+ return session._providerId || _providerIdFromCmd(session.cmd || '');
3981
+ }
3982
+
3983
+ function _standupRunningHoldMsForSession(session) {
3984
+ const providerId = _standupProviderIdForSession(session);
3985
+ let detectorMs = 0;
3986
+ try {
3987
+ const detector = getStateDetector(providerId);
3988
+ detectorMs = Number(detector && detector.idleDebounceMs) || 0;
3989
+ } catch {
3990
+ // Unknown providers fall back to the conservative minimum running hold.
3991
+ }
3992
+ return Math.max(STANDUP_MIN_RUNNING_HOLD_MS, detectorMs);
3993
+ }
3994
+
3995
+ function _standupWaitingReason(sessionId, session) {
3996
+ return String(session?._waitingForInputReason || idleNotifyState.get(sessionId)?.reason || '').toLowerCase();
3997
+ }
3998
+
3999
+ function _standupIsBlockingWaitingReason(reason) {
4000
+ return reason === 'approval' || reason === 'choice';
4001
+ }
4002
+
4003
+ function _standupHasRecentNonResolvingInput(session) {
4004
+ return !!(session?._waitingForInput &&
4005
+ session._lastNonResolvingWaitingInputAt &&
4006
+ (!session._lastWaitingResolveInputAt ||
4007
+ session._lastNonResolvingWaitingInputAt > session._lastWaitingResolveInputAt));
4008
+ }
4009
+
4010
+ function _standupLiveStatusForSession(session, now = Date.now()) {
4011
+ if (!session) return '';
4012
+
4013
+ // Wall-E chat tabs do not have PTYs. A non-generating chat is available for
4014
+ // the next message, not blocked waiting for operator input.
4015
+ if (session.type === 'walle') {
4016
+ return session.abortController ? 'running' : 'idle';
4017
+ }
4018
+
4019
+ const providerId = _standupProviderIdForSession(session);
4020
+ const ptyActivity = _standupTimeMs(session.lastPtyActivity);
4021
+ const runningHoldMs = _standupRunningHoldMsForSession(session);
4022
+ const recentPtyActivity = !!(ptyActivity && (now - ptyActivity) < runningHoldMs);
4023
+
4024
+ if (_isServerWaitingForInput(session.id, session)) {
4025
+ const reason = _standupWaitingReason(session.id, session);
4026
+ if (providerId === 'codex' &&
4027
+ recentPtyActivity &&
4028
+ !_standupIsBlockingWaitingReason(reason) &&
4029
+ !_standupHasRecentNonResolvingInput(session)) {
4030
+ return 'running';
4031
+ }
4032
+ return 'waiting_input';
4033
+ }
4034
+
4035
+ let busState = '';
4036
+ try {
4037
+ busState = statusHooks._bus && typeof statusHooks._bus.getState === 'function'
4038
+ ? statusHooks._bus.getState(session.id)
4039
+ : '';
4040
+ } catch {}
4041
+ const liveBusStatus = _normalizeStandupLiveStatus(busState);
4042
+ if (liveBusStatus === 'running') return 'running';
4043
+ if (liveBusStatus === 'waiting_input') return 'waiting_input';
4044
+
4045
+ if (recentPtyActivity) return 'running';
4046
+ if (liveBusStatus) return liveBusStatus;
4047
+
4048
+ return '';
4049
+ }
4050
+
4051
+ async function apiSessionStandup(req, res, url) {
4052
+ try {
4053
+ await _refreshSessionWorktreeStatusCache({ force: url.searchParams.get('force') === '1' });
4054
+ const source = _standupStreamSource();
4055
+ let statuses = [];
4056
+ if (source && typeof source.getAllStatuses === 'function') {
4057
+ try { statuses = source.getAllStatuses(); } catch (e) { console.error('[standup] status snapshot error:', e.message); }
4058
+ }
4059
+
4060
+ const summaries = [];
4061
+ const standupPairs = [];
4062
+ const now = Date.now();
4063
+ const activeSessions = [];
4064
+ for (const session of sessions.values()) {
4065
+ const payload = _sessionPayload(session);
4066
+ const agentSessionId = payload.agentSessionId || session._claudeSessionId || null;
4067
+ const rawSummary = _standupSummaryFor(source, payload.id, agentSessionId);
4068
+ const summary = rawSummary ? {
4069
+ ...rawSummary,
4070
+ ctmSessionId: rawSummary.ctmSessionId || payload.id,
4071
+ agentSessionId: rawSummary.agentSessionId || agentSessionId || null,
4072
+ sessionId: rawSummary.sessionId || agentSessionId || payload.id,
4073
+ } : null;
4074
+ if (summary) summaries.push(summary);
4075
+ const standupSession = {
4076
+ ...payload,
4077
+ serverState: statusHooks._bus && typeof statusHooks._bus.getState === 'function'
4078
+ ? statusHooks._bus.getState(session.id)
4079
+ : null,
4080
+ standupStatus: _standupLiveStatusForSession(session, now),
4081
+ waitingForInput: _isServerWaitingForInput(session.id, session),
4082
+ waitingReason: session._waitingForInputReason || '',
4083
+ };
4084
+ activeSessions.push(standupSession);
4085
+ standupPairs.push({ session: standupSession, summary });
4086
+ }
4087
+ await _applyStandupAttention(standupPairs, now);
4088
+
4089
+ const snapshot = buildSessionStandupSnapshot({
4090
+ sessions: activeSessions,
4091
+ summaries,
4092
+ statuses,
4093
+ now,
4094
+ });
4095
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4096
+ res.end(JSON.stringify(snapshot));
4097
+ } catch (e) {
4098
+ console.error('[standup] snapshot error:', e.stack || e.message || e);
4099
+ res.writeHead(500, { 'Content-Type': 'application/json' });
4100
+ res.end(JSON.stringify({ error: e.message || 'standup snapshot failed' }));
4101
+ }
4102
+ }
4103
+
3814
4104
  function apiRecentSessions(req, res, url) {
3815
4105
  const limit = parseInt(url.searchParams.get('limit') || '50', 10);
3816
4106
  const forceRescan = url.searchParams.get('rescan') === '1';
@@ -3827,45 +4117,52 @@ function apiRecentSessions(req, res, url) {
3827
4117
  } catch { /* skip */ }
3828
4118
  }
3829
4119
 
3830
- // Include Codex sessions from ~/.codex/state_5.sqlite
4120
+ // Include Codex sessions. Primary: ~/.codex/state_5.sqlite. Fallback: scan ~/.codex/sessions/*.jsonl
4121
+ // (used when state_5.sqlite is corrupt or missing — avoids silent loss of all Codex sessions)
4122
+ let _codexThreadsFromDb = null;
3831
4123
  try {
3832
4124
  const codexDbPath = path.join(process.env.HOME, '.codex', 'state_5.sqlite');
3833
4125
  if (fs.existsSync(codexDbPath)) {
3834
4126
  const BetterSqlite3 = require('better-sqlite3');
3835
4127
  const codexDb = new BetterSqlite3(codexDbPath, { readonly: true });
3836
- const codexThreads = codexDb.prepare(
4128
+ _codexThreadsFromDb = codexDb.prepare(
3837
4129
  'SELECT id, title, first_user_message, model, cwd, git_branch, created_at, updated_at FROM threads ORDER BY updated_at DESC LIMIT ?'
3838
4130
  ).all(limit);
3839
4131
  codexDb.close();
3840
- const existingIds = new Set(recentSessions.map(s => s.sessionId));
3841
- for (const t of codexThreads) {
3842
- if (existingIds.has(t.id)) continue;
3843
- const title = t.title || t.first_user_message || '';
3844
- const titleTrimmed = title.slice(0, 80);
3845
- const rolloutInfo = _codexRolloutInfo(t.id);
3846
- const modifiedAt = rolloutInfo.modifiedAt || new Date(t.updated_at * 1000).toISOString();
3847
- recentSessions.push({
3848
- sessionId: t.id,
3849
- project: t.cwd || '',
3850
- projectEntry: '',
3851
- cwd: t.cwd || '',
3852
- firstMessage: t.first_user_message || '',
3853
- title: titleTrimmed,
3854
- aiTitle: titleTrimmed || undefined, // prevent re-generation
3855
- isEmpty: !t.first_user_message,
3856
- userMsgCount: t.first_user_message ? 1 : 0,
3857
- modifiedAt,
3858
- fileModifiedAt: rolloutInfo.modifiedAt || '',
3859
- timestamp: new Date(t.created_at * 1000).toISOString(),
3860
- version: '',
3861
- gitBranch: t.git_branch || '',
3862
- fileSize: rolloutInfo.fileSize || 0,
3863
- agent: 'codex',
3864
- model: t.model || '',
3865
- });
3866
- }
3867
4132
  }
3868
- } catch { /* Codex DB not available skip */ }
4133
+ } catch { /* state_5.sqlite unreadable fall through to JSONL scan */ }
4134
+
4135
+ const _codexThreads = _codexThreadsFromDb
4136
+ || listCodexSessionsFromRollouts(process.env.HOME, limit);
4137
+
4138
+ const existingIds = new Set(recentSessions.map(s => s.sessionId));
4139
+ for (const t of _codexThreads) {
4140
+ if (existingIds.has(t.id)) continue;
4141
+ const title = t.title || t.first_user_message || '';
4142
+ const titleTrimmed = title.slice(0, 80);
4143
+ const rolloutInfo = _codexRolloutInfo(t.id);
4144
+ const modifiedAt = rolloutInfo.modifiedAt || new Date(t.updated_at * 1000).toISOString();
4145
+ recentSessions.push({
4146
+ sessionId: t.id,
4147
+ project: t.cwd || '',
4148
+ projectEntry: '',
4149
+ cwd: t.cwd || '',
4150
+ firstMessage: t.first_user_message || '',
4151
+ title: titleTrimmed,
4152
+ aiTitle: titleTrimmed || undefined, // prevent re-generation
4153
+ isEmpty: !t.first_user_message,
4154
+ userMsgCount: t.first_user_message ? 1 : 0,
4155
+ modifiedAt,
4156
+ fileModifiedAt: rolloutInfo.modifiedAt || '',
4157
+ timestamp: new Date(t.created_at * 1000).toISOString(),
4158
+ version: '',
4159
+ gitBranch: t.git_branch || '',
4160
+ fileSize: rolloutInfo.fileSize || 0,
4161
+ agent: 'codex',
4162
+ model: t.model || '',
4163
+ });
4164
+ existingIds.add(t.id);
4165
+ }
3869
4166
 
3870
4167
  // Populate ctm_sessions + agent_sessions from scanned sessions (wrap in transaction for perf)
3871
4168
  // First, build a map of JSONL file IDs already claimed by CTM tab rows (where id != agent_session_id).
@@ -3879,6 +4176,20 @@ function apiRecentSessions(req, res, url) {
3879
4176
  claimedMap[r.agent_session_id] = r.ctm_session_id;
3880
4177
  }
3881
4178
  } catch (e) { console.error('[session-index] claimedMap build error:', e.message); }
4179
+ try {
4180
+ const cols = new Set(db.prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name));
4181
+ const agentCols = [];
4182
+ if (cols.has('agent_session_id')) agentCols.push('agent_session_id');
4183
+ if (cols.has('claude_session_id')) agentCols.push('claude_session_id');
4184
+ if (cols.has('ctm_session_id') && agentCols.length) {
4185
+ const selectCols = ['ctm_session_id', ...agentCols].join(', ');
4186
+ for (const r of db.prepare(`SELECT ${selectCols} FROM startup_tasks WHERE ctm_session_id IS NOT NULL`).all()) {
4187
+ for (const col of agentCols) {
4188
+ if (r[col] && !claimedMap[r[col]]) claimedMap[r[col]] = r.ctm_session_id;
4189
+ }
4190
+ }
4191
+ }
4192
+ } catch (e) { console.error('[session-index] startup claimedMap build error:', e.message); }
3882
4193
  // Also build a reverse map: CTM tab id → { agentId, fileSize, projectEntry, createdAt }
3883
4194
  // so we can detect stale links (tab claims a tiny file when a larger continuation exists)
3884
4195
  const tabClaimInfo = {}; // CTM tab id → { agentId, fileSize, projectEntry, createdAt }
@@ -3981,7 +4292,7 @@ function apiRecentSessions(req, res, url) {
3981
4292
  if (row.id) { sessionsMap[row.id] = row; idMapped.add(row.id); }
3982
4293
  }
3983
4294
  for (const row of allSessionRows) {
3984
- if (row.agent_session_id && !idMapped.has(row.agent_session_id)) {
4295
+ if (row.agent_session_id && (!idMapped.has(row.agent_session_id) || _preferAgentOwnerRow(sessionsMap[row.agent_session_id], row, row.agent_session_id))) {
3985
4296
  sessionsMap[row.agent_session_id] = row;
3986
4297
  }
3987
4298
  }
@@ -4028,7 +4339,7 @@ function apiRecentSessions(req, res, url) {
4028
4339
  // columns so post-compact continuations inherit a useful title.
4029
4340
  const rowForTitle = {
4030
4341
  user_renamed: s.userRenamed ? 1 : 0,
4031
- title: s.title || '',
4342
+ title: s.aiTitle || s.title || '',
4032
4343
  rename_name: dbRow ? dbRow.rename_name : '',
4033
4344
  first_message: s.firstMessage || '',
4034
4345
  last_user_content: dbRow ? dbRow.last_user_content : '',
@@ -4302,6 +4613,7 @@ function _cacheParsedCodexMessages(ctmSessionId, parsedFiles) {
4302
4613
  const agentSessionId = meta.id || _codexAgentIdFromRolloutPath(item.fp);
4303
4614
  const fileMessages = Array.isArray(item.messages) ? item.messages : [];
4304
4615
  if (!agentSessionId || fileMessages.length === 0) continue;
4616
+ const ownerCtmSessionId = _resolveCodexOwnerCtmSessionId(ctmSessionId, agentSessionId);
4305
4617
 
4306
4618
  const users = fileMessages.filter(m => m.role === 'user' && m.text);
4307
4619
  const assistants = fileMessages.filter(m => m.role === 'assistant' && m.text);
@@ -4338,13 +4650,14 @@ function _cacheParsedCodexMessages(ctmSessionId, parsedFiles) {
4338
4650
  const st = fs.statSync(item.fp);
4339
4651
  const modifiedAt = st.mtime ? st.mtime.toISOString() : '';
4340
4652
  const projectDir = path.dirname(item.fp);
4341
- dbModule.updateStartupTaskAgentSession(ctmSessionId, agentSessionId, projectDir);
4342
- dbModule.upsertSession(ctmSessionId, {
4653
+ const indexTitle = _codexIndexTitleForSession(ownerCtmSessionId, title);
4654
+ dbModule.updateStartupTaskAgentSession(ownerCtmSessionId, agentSessionId, projectDir);
4655
+ dbModule.upsertSession(ownerCtmSessionId, {
4343
4656
  agentSessionId,
4344
4657
  provider: 'codex',
4345
4658
  cwd: meta.cwd || '',
4346
4659
  projectPath: meta.cwd || '',
4347
- title,
4660
+ title: indexTitle,
4348
4661
  jsonlPath: item.fp,
4349
4662
  fileSize: item.size || st.size || item.parsed?.bytesRead || 0,
4350
4663
  modifiedAt,
@@ -4352,7 +4665,7 @@ function _cacheParsedCodexMessages(ctmSessionId, parsedFiles) {
4352
4665
  gitBranch: meta.git_branch || '',
4353
4666
  hostname: os.hostname(),
4354
4667
  userMsgCount: users.length,
4355
- });
4668
+ }, ownerCtmSessionId && ownerCtmSessionId !== agentSessionId ? { allowReclaim: true } : undefined);
4356
4669
  } catch (e) {
4357
4670
  console.error(`[session-messages] Codex index update failed for ${agentSessionId.slice(0, 8)}:`, e.message);
4358
4671
  }
@@ -4771,6 +5084,16 @@ function _codexThreadTitle(thread) {
4771
5084
  return title ? title.slice(0, 80) : '';
4772
5085
  }
4773
5086
 
5087
+ function _preferAgentOwnerRow(existing, row, agentSessionId) {
5088
+ if (!row || !agentSessionId) return false;
5089
+ if (!existing) return true;
5090
+ const rowIsOwner = row.id && row.id !== agentSessionId;
5091
+ const existingIsStandaloneAgent = existing.id === agentSessionId;
5092
+ if (rowIsOwner && existingIsStandaloneAgent && (row.user_renamed || !existing.user_renamed)) return true;
5093
+ if (row.user_renamed && !existing.user_renamed) return true;
5094
+ return false;
5095
+ }
5096
+
4774
5097
  function _isKnownCodexThreadId(agentSessionId) {
4775
5098
  if (!agentSessionId) return false;
4776
5099
  if (getCodexThreadById(agentSessionId)) return true;
@@ -4877,6 +5200,64 @@ function _existingAgentOwner(agentSessionId) {
4877
5200
  }
4878
5201
  }
4879
5202
 
5203
+ function _startupTaskOwnerForAgent(agentSessionId) {
5204
+ if (!agentSessionId) return '';
5205
+ try {
5206
+ const db = dbModule.getDb();
5207
+ const cols = new Set(db.prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name));
5208
+ const clauses = [];
5209
+ const params = [];
5210
+ if (cols.has('agent_session_id')) {
5211
+ clauses.push('agent_session_id = ?');
5212
+ params.push(agentSessionId);
5213
+ }
5214
+ if (cols.has('claude_session_id')) {
5215
+ clauses.push('claude_session_id = ?');
5216
+ params.push(agentSessionId);
5217
+ }
5218
+ if (clauses.length === 0) return '';
5219
+ const row = db.prepare(`SELECT ctm_session_id FROM startup_tasks WHERE ${clauses.join(' OR ')} ORDER BY created_at DESC LIMIT 1`).get(...params);
5220
+ return row?.ctm_session_id || '';
5221
+ } catch {
5222
+ return '';
5223
+ }
5224
+ }
5225
+
5226
+ function _resolveCodexOwnerCtmSessionId(requestedSessionId, agentSessionId) {
5227
+ if (agentSessionId) {
5228
+ const owner = _existingAgentOwner(agentSessionId)?.ctm_session_id || _startupTaskOwnerForAgent(agentSessionId);
5229
+ if (owner && owner !== agentSessionId) return owner;
5230
+ }
5231
+ if (requestedSessionId && sessions.has(requestedSessionId)) return requestedSessionId;
5232
+ try {
5233
+ const task = dbModule.getStartupTask ? dbModule.getStartupTask(requestedSessionId) : null;
5234
+ if (task?.ctm_session_id || task?.session_id) return task.ctm_session_id || task.session_id;
5235
+ } catch {}
5236
+ try {
5237
+ const resolved = dbModule.resolveSession(requestedSessionId);
5238
+ if (resolved?.id) return resolved.id;
5239
+ } catch {}
5240
+ return requestedSessionId || agentSessionId || '';
5241
+ }
5242
+
5243
+ function _codexIndexTitleForSession(ctmSessionId, candidateTitle) {
5244
+ let existingTitle = null;
5245
+ try { existingTitle = dbModule.getSessionTitleNew(ctmSessionId); } catch {}
5246
+ const liveSession = sessions.get(ctmSessionId) || null;
5247
+ const candidate = (candidateTitle || '').trim();
5248
+ if (candidate && shouldApplyCodexAutoTitle({ session: liveSession || { label: existingTitle?.title || '' }, existingTitle })) {
5249
+ return candidate;
5250
+ }
5251
+ return existingTitle?.title || liveSession?.label || candidate;
5252
+ }
5253
+
5254
+ function _shouldReclaimStandaloneAgentOwner(agentSessionId, ctmSessionId) {
5255
+ if (!agentSessionId || !ctmSessionId || agentSessionId === ctmSessionId) return false;
5256
+ if (sessions.has(agentSessionId)) return false;
5257
+ const owner = _existingAgentOwner(agentSessionId);
5258
+ return owner?.ctm_session_id === agentSessionId;
5259
+ }
5260
+
4880
5261
  function _ensureCodexThreadLink(ctmSessionId, source) {
4881
5262
  if (!ctmSessionId) return null;
4882
5263
 
@@ -5003,7 +5384,7 @@ function _linkCodexThreadToSession(ctmSessionId, liveSession, thread, source) {
5003
5384
  model: thread.model || liveSession.model_id || '',
5004
5385
  gitBranch: thread.git_branch || liveSession.branch || '',
5005
5386
  hostname: os.hostname(),
5006
- });
5387
+ }, _shouldReclaimStandaloneAgentOwner(agentSessionId, ctmSessionId) ? { allowReclaim: true } : undefined);
5007
5388
  } catch (e) {
5008
5389
  console.error('[codex-link] upsertSession error:', e.message);
5009
5390
  }
@@ -5461,7 +5842,7 @@ function apiSessionSearch(req, res, url) {
5461
5842
  if (row.id) { _searchSessionsMap[row.id] = row; _searchIdMapped.add(row.id); }
5462
5843
  }
5463
5844
  for (const row of _searchRows) {
5464
- if (row.agent_session_id && !_searchIdMapped.has(row.agent_session_id)) {
5845
+ if (row.agent_session_id && (!_searchIdMapped.has(row.agent_session_id) || _preferAgentOwnerRow(_searchSessionsMap[row.agent_session_id], row, row.agent_session_id))) {
5465
5846
  _searchSessionsMap[row.agent_session_id] = row;
5466
5847
  }
5467
5848
  }
@@ -5891,7 +6272,7 @@ ${summaryText}`;
5891
6272
  delete env.CLAUDE_CODE;
5892
6273
  delete env.CLAUDE_CODE_ENTRYPOINT;
5893
6274
  delete env.CLAUDE_CODE_ENABLE_TELEMETRY;
5894
- sanitizeAnthropicAuth(env);
6275
+ sanitizeAnthropicAuth(env, 'claude');
5895
6276
  let result;
5896
6277
  result = require('child_process').spawnSync('claude', ['-p', prompt], {
5897
6278
  encoding: 'utf8',
@@ -5969,7 +6350,7 @@ function callClaude(prompt) {
5969
6350
  delete env.CLAUDE_CODE;
5970
6351
  delete env.CLAUDE_CODE_ENTRYPOINT;
5971
6352
  delete env.CLAUDE_CODE_ENABLE_TELEMETRY;
5972
- sanitizeAnthropicAuth(env);
6353
+ sanitizeAnthropicAuth(env, 'claude');
5973
6354
  const result = spawnSync('claude', ['-p', prompt], {
5974
6355
  encoding: 'utf8', timeout: 60000, env, maxBuffer: 1024 * 1024,
5975
6356
  });
@@ -6042,13 +6423,16 @@ function apiRenameSession(req, res) {
6042
6423
  const trimmed = title.trim().slice(0, 120);
6043
6424
  dbModule.setSessionTitleNew(sessionId, trimmed, true);
6044
6425
 
6426
+ const liveSession = findLiveSessionByAnyId(sessionId);
6427
+ const liveSessionId = liveSession ? liveSession.id : sessionId;
6428
+
6045
6429
  // Update startup_tasks so the label survives CTM restart
6046
- try { dbModule.updateStartupTaskLabel(sessionId, trimmed); } catch (e) { console.error('[ctm] updateStartupTaskLabel error:', e.message); }
6430
+ try { dbModule.updateStartupTaskLabel(liveSessionId, trimmed); } catch (e) { console.error('[ctm] updateStartupTaskLabel error:', e.message); }
6047
6431
 
6048
6432
  // Update in-memory session if active
6049
- const session = sessions.get(sessionId);
6050
- if (session) {
6051
- session.label = trimmed;
6433
+ if (liveSession) {
6434
+ liveSession.label = trimmed;
6435
+ liveSession.userRenamed = true;
6052
6436
  broadcastSessionList();
6053
6437
  }
6054
6438
 
@@ -6061,6 +6445,15 @@ function apiRenameSession(req, res) {
6061
6445
  });
6062
6446
  }
6063
6447
 
6448
+ function findLiveSessionByAnyId(id) {
6449
+ const direct = sessions.get(id);
6450
+ if (direct) return direct;
6451
+ for (const session of sessions.values()) {
6452
+ if (session && session._claudeSessionId === id) return session;
6453
+ }
6454
+ return null;
6455
+ }
6456
+
6064
6457
  function apiGenerateTitles(req, res) {
6065
6458
  let body = '';
6066
6459
  req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
@@ -7006,7 +7399,12 @@ function _markServerSessionResumedFromPty(sessionId, session, source) {
7006
7399
  }
7007
7400
  if (session) {
7008
7401
  session._waitingForInput = false;
7402
+ session._waitingForInputReason = '';
7403
+ session._waitingForInputAt = 0;
7404
+ session._lastNonResolvingWaitingInputAt = 0;
7405
+ session._lastWaitingResolveInputAt = now;
7009
7406
  session.lastActivity = now;
7407
+ session.lastPtyActivity = now;
7010
7408
  }
7011
7409
  broadcastToAll({ type: 'session-resumed', id: sessionId, source, timestamp: now });
7012
7410
  if (telemetryReceiver.hasAuthoritativeSource(sessionId)) {
@@ -7045,14 +7443,24 @@ function checkIdleNotify(sessionId, session, data) {
7045
7443
  /\w/.test(freshStripped) &&
7046
7444
  freshStripped.length > 5;
7047
7445
  if (hasWords && st.notified) {
7048
- st.notified = false;
7049
- if (session) session._waitingForInput = false;
7050
- // Tell clients this session is no longer idle — clears _waitingForInput
7051
- broadcastToAll({ type: 'session-resumed', id: sessionId });
7052
- // If a provider hook missed the next start transition, real terminal
7053
- // output is enough to unstick the sidebar until the hook catches up.
7054
- if (hasAuthoritativeSource) {
7055
- broadcastToAll({ type: 'session.status', id: sessionId, working: true, source: 'terminal-output', timestamp: Date.now() });
7446
+ const hasUnsubmittedWaitingInput = !!(session?._waitingForInput &&
7447
+ session._lastNonResolvingWaitingInputAt &&
7448
+ (!session._lastWaitingResolveInputAt ||
7449
+ session._lastNonResolvingWaitingInputAt > session._lastWaitingResolveInputAt));
7450
+ if (!hasUnsubmittedWaitingInput) {
7451
+ st.notified = false;
7452
+ if (session) {
7453
+ session._waitingForInput = false;
7454
+ session._waitingForInputReason = '';
7455
+ session._waitingForInputAt = 0;
7456
+ }
7457
+ // Tell clients this session is no longer idle — clears _waitingForInput
7458
+ broadcastToAll({ type: 'session-resumed', id: sessionId });
7459
+ // If a provider hook missed the next start transition, real terminal
7460
+ // output is enough to unstick the sidebar until the hook catches up.
7461
+ if (hasAuthoritativeSource) {
7462
+ broadcastToAll({ type: 'session.status', id: sessionId, working: true, source: 'terminal-output', timestamp: Date.now() });
7463
+ }
7056
7464
  }
7057
7465
  }
7058
7466
 
@@ -7086,7 +7494,13 @@ function checkIdleNotify(sessionId, session, data) {
7086
7494
  }
7087
7495
  if (!st.notified) {
7088
7496
  st.notified = true;
7089
- if (session) session._waitingForInput = true;
7497
+ if (session) {
7498
+ session._waitingForInput = true;
7499
+ session._waitingForInputReason = reason;
7500
+ session._waitingForInputAt = Date.now();
7501
+ session._lastNonResolvingWaitingInputAt = 0;
7502
+ session._lastWaitingResolveInputAt = 0;
7503
+ }
7090
7504
  // Provider hooks are useful, but a visible prompt is stronger evidence
7091
7505
  // than a stale "working" hook that never received its stop event.
7092
7506
  if (hasAuthoritativeSource) {
@@ -7334,7 +7748,8 @@ function handleCreate(ws, msg) {
7334
7748
 
7335
7749
  const cols = msg.cols || 120;
7336
7750
  const rows = msg.rows || 30;
7337
- const env = { ...process.env, ...(msg._projectEnv || {}), ...msg.env };
7751
+ // Keep PWD aligned with the PTY cwd; some agent CLIs trust env.PWD over getcwd().
7752
+ const env = { ...process.env, ...(msg._projectEnv || {}), ...msg.env, PWD: cwd };
7338
7753
  // Remove Claude Code env vars so spawned sessions don't think they're nested
7339
7754
  delete env.CLAUDECODE;
7340
7755
  delete env.CLAUDE_CODE;
@@ -7345,6 +7760,12 @@ function handleCreate(ws, msg) {
7345
7760
  // This adds OTEL_EXPORTER_OTLP_ENDPOINT → http://localhost:<PORT> for agents
7346
7761
  // that emit OTLP logs, plus CTM_SESSION_ID for our hook scripts to read.
7347
7762
  Object.assign(env, buildTelemetryEnv(id, cmd, PORT));
7763
+ ensureWalleMcpForAgentSession({
7764
+ cmd,
7765
+ wallePort: WALLE_PORT,
7766
+ homeDir: env.HOME || process.env.HOME,
7767
+ env,
7768
+ });
7348
7769
 
7349
7770
  // Model: explicit selection, or auto-detected from coding agent config
7350
7771
  let model_id = msg.model_id || null;
@@ -7389,6 +7810,11 @@ function handleCreate(ws, msg) {
7389
7810
  if (agentType === 'walle') {
7390
7811
  // Override auto-generated shell label if user didn't provide one
7391
7812
  if (!msg.label) label = 'Wall-E session';
7813
+ if (!model_id) {
7814
+ const defaults = resolveWalleDefaultModelSelection({ brain: getWalleBrain(), env: process.env });
7815
+ model_id = defaults.model_id || null;
7816
+ model_provider = defaults.model_provider || null;
7817
+ }
7392
7818
  const chatSessionId = msg.chatSessionId || `walle-${id}`;
7393
7819
  const jsonlPath = walleTranscript.sessionPath(WALLE_SESSIONS_DIR, id);
7394
7820
  const createResult = walleTranscript.createSession(jsonlPath, {
@@ -7437,7 +7863,15 @@ function handleCreate(ws, msg) {
7437
7863
  }
7438
7864
 
7439
7865
  if (ws) {
7440
- ws.send(JSON.stringify({ type: 'created', id, label: session.label, cwd, sessionType: 'walle' }));
7866
+ ws.send(JSON.stringify({
7867
+ type: 'created',
7868
+ id,
7869
+ label: session.label,
7870
+ cwd,
7871
+ sessionType: 'walle',
7872
+ model_id: session.model_id || null,
7873
+ model_provider: session.model_provider || null,
7874
+ }));
7441
7875
  }
7442
7876
  broadcastSessionList(true);
7443
7877
  return;
@@ -7500,8 +7934,10 @@ function handleCreate(ws, msg) {
7500
7934
  createdAt: sessionSpawnedAt,
7501
7935
  _ctmOriginalCreatedAt: sessionOriginalCreatedAt,
7502
7936
  lastActivity: sessionSpawnedAt,
7937
+ lastPtyActivity: 0,
7503
7938
  model_id,
7504
7939
  model_provider,
7940
+ userRenamed: !!(existingTitle && existingTitle.userRenamed),
7505
7941
  _claudeSessionId: claudeResumeId || _injectedSessionId || null,
7506
7942
  // When we injected --session-id, the JSONL path is deterministic. Setting
7507
7943
  // _claudeProjectDir now lets the post-spawn detection timers short-circuit
@@ -7791,9 +8227,14 @@ function handleCreate(ws, msg) {
7791
8227
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
7792
8228
  const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
7793
8229
  const waitingForInput = _isServerWaitingForInput(id, session);
7794
- const activityChunk = activeChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
7795
- if (busyStatusChunk && waitingForInput) _markServerSessionResumedFromPty(id, session, 'codex-working-status');
7796
- if (activityChunk) session.lastActivity = Date.now();
8230
+ const suppressedUiRefreshChunk = _suppressCtmUiRefreshActivity(session, statusOnlyChunk);
8231
+ const activityChunk = activeChunk && !suppressedUiRefreshChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
8232
+ if (busyStatusChunk && waitingForInput && !suppressedUiRefreshChunk) _markServerSessionResumedFromPty(id, session, 'codex-working-status');
8233
+ if (activityChunk) {
8234
+ const now = Date.now();
8235
+ session.lastActivity = now;
8236
+ session.lastPtyActivity = now;
8237
+ }
7797
8238
  // Monotonic output byte counter — read by approval-agent's Phase 3
7798
8239
  // post-keystroke verification (sendApprovalKeystroke in approval-agent.js).
7799
8240
  session._outputBytesCounter = (session._outputBytesCounter || 0) + data.length;
@@ -8234,6 +8675,7 @@ function handleCreate(ws, msg) {
8234
8675
  model_id: session.model_id || model_id,
8235
8676
  model_provider: session.model_provider || model_provider,
8236
8677
  branch,
8678
+ userRenamed: !!session.userRenamed,
8237
8679
  claudeSessionId: session._claudeSessionId || null,
8238
8680
  agentSessionId: session._claudeSessionId || null,
8239
8681
  claudeProjectDir: session._claudeProjectDir ? path.basename(session._claudeProjectDir) : null,
@@ -8268,6 +8710,12 @@ function apiAttachSession(req, res) {
8268
8710
  sanitizeAnthropicAuth(env, claudeCmd);
8269
8711
  // Resume path: inject CTM telemetry env the same way as the create path.
8270
8712
  Object.assign(env, buildTelemetryEnv(tabId, claudeCmd, PORT));
8713
+ ensureWalleMcpForAgentSession({
8714
+ cmd: claudeCmd,
8715
+ wallePort: WALLE_PORT,
8716
+ homeDir: env.HOME || process.env.HOME,
8717
+ env,
8718
+ });
8271
8719
 
8272
8720
  // Restore .jsonl from .bak if needed (Claude Code migrates sessions to directory format)
8273
8721
  const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
@@ -8345,6 +8793,7 @@ function apiAttachSession(req, res) {
8345
8793
  // scrollback removed — headless terminal is source of truth (Phase 3D)
8346
8794
  createdAt: Date.now(),
8347
8795
  lastActivity: Date.now(),
8796
+ lastPtyActivity: 0,
8348
8797
  model_id: null,
8349
8798
  model_provider: null,
8350
8799
  _autoRestartCount: 0,
@@ -8384,9 +8833,14 @@ function apiAttachSession(req, res) {
8384
8833
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
8385
8834
  const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
8386
8835
  const waitingForInput = _isServerWaitingForInput(tabId, session);
8387
- const activityChunk = activeChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
8388
- if (busyStatusChunk && waitingForInput) _markServerSessionResumedFromPty(tabId, session, 'codex-working-status');
8389
- if (activityChunk) session.lastActivity = Date.now();
8836
+ const suppressedUiRefreshChunk = _suppressCtmUiRefreshActivity(session, statusOnlyChunk);
8837
+ const activityChunk = activeChunk && !suppressedUiRefreshChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
8838
+ if (busyStatusChunk && waitingForInput && !suppressedUiRefreshChunk) _markServerSessionResumedFromPty(tabId, session, 'codex-working-status');
8839
+ if (activityChunk) {
8840
+ const now = Date.now();
8841
+ session.lastActivity = now;
8842
+ session.lastPtyActivity = now;
8843
+ }
8390
8844
  const cleanData = data.indexOf('\x1b[') >= 0
8391
8845
  ? data.replace(/\x1b\[3J|\x1b\[\?100[0236]h|\x1b\[\?1015h/g, '')
8392
8846
  : data;
@@ -8597,10 +9051,20 @@ function handleInput(ws, msg) {
8597
9051
  const prevInputTs = session._lastInputTs || 0;
8598
9052
  session._lastInputData = msg.data;
8599
9053
  session._lastInputTs = now;
8600
- session.lastActivity = now;
8601
- session._waitingForInput = false;
9054
+ session.lastUserInputAt = now;
9055
+ const resolvesWaiting = _inputMayResolveWaiting(msg.data, session);
9056
+ if (resolvesWaiting) {
9057
+ session.lastActivity = now;
9058
+ session._waitingForInput = false;
9059
+ session._waitingForInputReason = '';
9060
+ session._waitingForInputAt = 0;
9061
+ session._lastWaitingResolveInputAt = now;
9062
+ session._lastNonResolvingWaitingInputAt = 0;
9063
+ } else if (_isServerWaitingForInput(msg.id, session)) {
9064
+ session._lastNonResolvingWaitingInputAt = now;
9065
+ }
8602
9066
  const idleState = idleNotifyState.get(msg.id);
8603
- if (idleState) idleState.notified = false;
9067
+ if (idleState && resolvesWaiting) idleState.notified = false;
8604
9068
  session._inputFrame = (session._inputFrame || 0) + 1;
8605
9069
 
8606
9070
  // Miss detection: if user manually types "1" or "2" while the approver is in
@@ -8630,11 +9094,12 @@ function handleResize(ws, msg) {
8630
9094
  const session = sessions.get(msg.id);
8631
9095
  if (session && msg.cols && msg.rows) {
8632
9096
  if (!session.ptyProcess) return;
9097
+ _markCtmUiRefreshActivitySuppression(session, 'resize');
8633
9098
  try {
8634
9099
  session.ptyProcess.resize(msg.cols, msg.rows);
8635
9100
  } catch (e) {
8636
- // EBADF: PTY fd already closed (process exited between check and resize)
8637
- if (e.message && e.message.includes('EBADF')) return;
9101
+ // EBADF / ENOTTY: PTY fd already closed or not a terminal (process exited between check and resize)
9102
+ if (e.message && (e.message.includes('EBADF') || e.message.includes('ENOTTY'))) return;
8638
9103
  throw e;
8639
9104
  }
8640
9105
  // Keep headless terminal in sync so snapshots have correct dimensions
@@ -8673,6 +9138,7 @@ function handleReflow(ws, msg) {
8673
9138
  headlessWorker.postMessage({ type: 'serialize', sessionId: msg.id, requestId: reqId });
8674
9139
  // Also sync PTY dimensions (in case they drifted)
8675
9140
  if (session.ptyProcess) {
9141
+ _markCtmUiRefreshActivitySuppression(session, 'reflow');
8676
9142
  try { session.ptyProcess.resize(cols, rows); } catch {}
8677
9143
  }
8678
9144
  }
@@ -8875,26 +9341,51 @@ async function handleWalleMessage(ws, msg) {
8875
9341
  if (!session || session.type !== 'walle') return;
8876
9342
 
8877
9343
  session.lastActivity = Date.now();
9344
+ const broadcastToSession = (data) => {
9345
+ const payload = JSON.stringify(data);
9346
+ for (const client of session.clients) {
9347
+ if (client.readyState === 1) client.send(payload);
9348
+ }
9349
+ };
9350
+ if (session.abortController) {
9351
+ console.warn('[walle-session] ignored message while busy:', session.id);
9352
+ return;
9353
+ }
9354
+ if (!session.model_id) {
9355
+ const defaults = resolveWalleDefaultModelSelection({ brain: getWalleBrain(), env: process.env });
9356
+ if (defaults.model_id) {
9357
+ session.model_id = defaults.model_id;
9358
+ session.model_provider = defaults.model_provider || session.model_provider || null;
9359
+ try {
9360
+ dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, session.model_id, 'walle', session.chatSessionId);
9361
+ } catch (e) {
9362
+ console.error('[ctm] addStartupTask (walle default model) error:', e.message);
9363
+ }
9364
+ broadcastToSession({
9365
+ type: 'walle-model',
9366
+ id: session.id,
9367
+ model_id: session.model_id,
9368
+ model_provider: session.model_provider,
9369
+ });
9370
+ broadcastSessionList(true);
9371
+ }
9372
+ }
9373
+ const selectedModel = msg.model || session.model_id || '';
9374
+ const selectedProvider = msg.provider
9375
+ || (!msg.model || msg.model === session.model_id ? (session.model_provider || '') : '');
8878
9376
  const userAppend = walleTranscript.appendUserMessage(session.jsonlPath, {
8879
9377
  sessionId: session.id,
8880
9378
  chatSessionId: session.chatSessionId,
8881
9379
  parentUuid: session._walleLastTranscriptUuid || null,
8882
9380
  cwd: session.cwd,
8883
9381
  text: msg.text,
8884
- modelId: msg.model || session.model_id || '',
8885
- modelProvider: session.model_provider || '',
9382
+ modelId: selectedModel,
9383
+ modelProvider: selectedProvider || session.model_provider || '',
8886
9384
  });
8887
9385
  _recordWalleTranscriptAppend(session, userAppend);
8888
9386
 
8889
9387
  session.abortController = new AbortController();
8890
9388
 
8891
- const broadcastToSession = (data) => {
8892
- const payload = JSON.stringify(data);
8893
- for (const client of session.clients) {
8894
- if (client.readyState === 1) client.send(payload);
8895
- }
8896
- };
8897
-
8898
9389
  const thinkingEvent = { type: 'thinking' };
8899
9390
  broadcastToSession({ type: 'walle-progress', id: session.id, event: thinkingEvent });
8900
9391
  _appendWallePart(session, 'thinking', { turn: null });
@@ -8916,7 +9407,8 @@ async function handleWalleMessage(ws, msg) {
8916
9407
  message: msg.text,
8917
9408
  session_id: session.chatSessionId,
8918
9409
  channel: 'ctm-session',
8919
- model: msg.model || undefined,
9410
+ model: selectedModel || undefined,
9411
+ provider: selectedProvider || undefined,
8920
9412
  cwd: effectiveCwd,
8921
9413
  context: {
8922
9414
  cwd: effectiveCwd,
@@ -8990,7 +9482,7 @@ async function handleWalleMessage(ws, msg) {
8990
9482
  jsonlPath: session.jsonlPath,
8991
9483
  fileSize: st.size || 0,
8992
9484
  modifiedAt: st.mtime ? st.mtime.toISOString() : '',
8993
- model: result.model || msg.model || session.model_id || '',
9485
+ model: result.model || selectedModel,
8994
9486
  userMsgCount: 1,
8995
9487
  hostname: HOSTNAME,
8996
9488
  });
@@ -9055,16 +9547,18 @@ function handleRename(ws, msg) {
9055
9547
  if (!trimmed) return;
9056
9548
 
9057
9549
  // Update in-memory session if active
9058
- const session = sessions.get(id);
9550
+ const session = findLiveSessionByAnyId(id);
9551
+ const liveSessionId = session ? session.id : id;
9059
9552
  if (session) {
9060
9553
  session.label = trimmed;
9554
+ session.userRenamed = true;
9061
9555
  }
9062
9556
 
9063
9557
  // Persist to sessions table (single source of truth)
9064
9558
  dbModule.setSessionTitleNew(id, trimmed, true);
9065
9559
 
9066
9560
  // Update startup_tasks so the label survives CTM restart
9067
- try { dbModule.updateStartupTaskLabel(id, trimmed); } catch (e) { console.error('[ctm] updateStartupTaskLabel (ws) error:', e.message); }
9561
+ try { dbModule.updateStartupTaskLabel(liveSessionId, trimmed); } catch (e) { console.error('[ctm] updateStartupTaskLabel (ws) error:', e.message); }
9068
9562
 
9069
9563
  ws.send(JSON.stringify({ type: 'renamed', id, label: trimmed }));
9070
9564
  sessionEvents.emit('session:renamed', { id, label: trimmed });
@@ -9158,6 +9652,7 @@ function _sessionPayload(s) {
9158
9652
  const caps = getAgentCapabilities(agentType);
9159
9653
  const fileInfo = _activeSessionFileInfo(s, agentType);
9160
9654
  const modifiedAt = fileInfo.modifiedAt || _sessionActivityIso(s.lastActivity) || _sessionActivityIso(s.createdAt);
9655
+ const liveStatus = _standupLiveStatusForSession(s);
9161
9656
  return {
9162
9657
  id: s.id,
9163
9658
  type: s.type || 'pty',
@@ -9167,11 +9662,15 @@ function _sessionPayload(s) {
9167
9662
  pid: s.pid,
9168
9663
  createdAt: s.createdAt,
9169
9664
  lastActivity: s.lastActivity,
9665
+ lastPtyActivity: s.lastPtyActivity || 0,
9666
+ liveStatus,
9667
+ liveStatusAt: liveStatus ? Date.now() : null,
9170
9668
  modifiedAt,
9171
9669
  fileModifiedAt: fileInfo.modifiedAt || null,
9172
9670
  fileSize: fileInfo.fileSize || 0,
9173
9671
  model_id: s.model_id,
9174
9672
  model_provider: s.model_provider,
9673
+ userRenamed: !!s.userRenamed,
9175
9674
  branch: s.branch || null,
9176
9675
  worktree_path: s.worktree_path || null,
9177
9676
  worktreeStatus: _sessionWorktreeStatusPayload(s),
@@ -9228,33 +9727,25 @@ function broadcastSessionList(immediate) {
9228
9727
  }
9229
9728
  }
9230
9729
 
9231
- // --- Activity heartbeat (3s) ---
9232
- // Sends lightweight activity timestamps so clients can detect Running status
9233
- // for background (unsubscribed) sessions without a full session list broadcast.
9234
- // Timestamps always reflect observed session output, never the heartbeat time,
9235
- // so this path cannot corrupt "last touched" sorting.
9236
- // Two cases are included:
9237
- // 1. Sessions with recent PTY output (< 5s) — actively producing output
9238
- // 2. Sessions in "thinking" state — no recent output but idle detection found
9239
- // no prompt pattern (st.notified === false), meaning the agent is still
9240
- // working (e.g., compacting, reasoning).
9730
+ // --- Live-status heartbeat (3s) ---
9731
+ // Sends the server's current live status projection so background tabs, the
9732
+ // sidebar, and Command Center converge without waiting for a full session-list
9733
+ // broadcast. Timestamps still reflect observed session output/activity, never
9734
+ // the heartbeat time, so this path cannot corrupt "last touched" sorting.
9241
9735
  setInterval(() => {
9242
9736
  if (wss.clients.size === 0) return;
9243
9737
  const now = Date.now();
9244
9738
  const active = [];
9245
9739
  for (const [id, s] of sessions) {
9246
- if (s.lastActivity && (now - s.lastActivity) < 5000) {
9247
- // Recent PTY output — definitely running
9248
- active.push({ id, ts: s.lastActivity, state: 'active' });
9249
- } else {
9250
- // No recent output — check if session is in a "thinking" state:
9251
- // PTY alive, idle detection did NOT find a prompt (not notified),
9252
- // and had output within the last 60s.
9253
- const st = idleNotifyState.get(id);
9254
- if (st && !st.notified && s.lastActivity && (now - s.lastActivity) < 60000) {
9255
- active.push({ id, ts: s.lastActivity, state: 'thinking' });
9256
- }
9257
- }
9740
+ const liveStatus = _standupLiveStatusForSession(s, now);
9741
+ if (!liveStatus) continue;
9742
+ const ptyActivity = s.lastPtyActivity || 0;
9743
+ active.push({
9744
+ id,
9745
+ ts: ptyActivity || s.lastActivity || s.createdAt || now,
9746
+ state: liveStatus === 'running' ? 'active' : liveStatus,
9747
+ status: liveStatus,
9748
+ });
9258
9749
  }
9259
9750
  if (active.length === 0) return;
9260
9751
  const payload = JSON.stringify({ type: 'session-activity', sessions: active });
@@ -9506,26 +9997,34 @@ function _executeHook(hook, data) {
9506
9997
  }
9507
9998
  }
9508
9999
 
9509
- function apiServicesStatus(req, res) {
10000
+ async function apiServicesStatus(req, res) {
9510
10001
  const ctmUptime = Math.floor((Date.now() - _ctmStartTime) / 1000);
9511
- const walleStatus = walleSupervisor.getStatus();
10002
+ let walleStatus;
10003
+ try {
10004
+ walleStatus = await walleSupervisor.getStatus();
10005
+ } catch (err) {
10006
+ walleStatus = { running: false, pid: null, error: err.message };
10007
+ }
9512
10008
  res.writeHead(200, { 'Content-Type': 'application/json' });
9513
10009
  res.end(JSON.stringify({
9514
10010
  ctm: { running: true, pid: process.pid, uptime: ctmUptime },
9515
- walle: { running: walleStatus.running, pid: walleStatus.pid }
10011
+ walle: {
10012
+ running: walleStatus.running,
10013
+ pid: walleStatus.pid,
10014
+ pid_source: walleStatus.pidSource || null,
10015
+ stale_pid: walleStatus.stalePid || null,
10016
+ repaired_pid_file: !!walleStatus.repairedPidFile,
10017
+ removed_stale_pid_file: !!walleStatus.removedStalePidFile,
10018
+ error: walleStatus.error || null,
10019
+ }
9516
10020
  }));
9517
10021
  }
9518
10022
 
9519
10023
  // --- Worktree API Handlers ---
9520
10024
  const gitUtilsWorktree = require('./git-utils');
10025
+ const { resolveWorktreeRepoRoot } = require('./lib/worktree-cwd');
9521
10026
  const _projectRoot = path.resolve(__dirname, '..');
9522
10027
 
9523
- // Validate cwd is within the project root (prevents path traversal)
9524
- function isValidWorktreeCwd(cwd) {
9525
- const resolved = path.resolve(cwd);
9526
- return resolved === _projectRoot || resolved.startsWith(_projectRoot + path.sep);
9527
- }
9528
-
9529
10028
  // Validate git ref names (branch names, tags) — reject values starting with - and special chars
9530
10029
  const VALID_GIT_REF = /^[a-zA-Z0-9][a-zA-Z0-9._\/-]*$/;
9531
10030
  const WORKTREE_JSON_HEADERS = {
@@ -9533,12 +10032,18 @@ const WORKTREE_JSON_HEADERS = {
9533
10032
  'Cache-Control': 'no-store, max-age=0',
9534
10033
  };
9535
10034
 
10035
+ function _resolveWorktreeApiCwd(res, reqCwd, headers) {
10036
+ const result = resolveWorktreeRepoRoot(reqCwd || _projectRoot);
10037
+ if (result.ok) return result.cwd;
10038
+ res.writeHead(result.status || 400, headers || WORKTREE_JSON_HEADERS);
10039
+ res.end(JSON.stringify({ error: result.error }));
10040
+ return null;
10041
+ }
10042
+
9536
10043
  function apiListWorktrees(req, res) {
9537
- const cwd = new URL(req.url, 'http://localhost').searchParams.get('cwd') || _projectRoot;
9538
- if (!isValidWorktreeCwd(cwd)) {
9539
- res.writeHead(403, WORKTREE_JSON_HEADERS);
9540
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9541
- }
10044
+ const reqCwd = new URL(req.url, 'http://localhost').searchParams.get('cwd') || _projectRoot;
10045
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10046
+ if (!cwd) return;
9542
10047
  gitUtilsWorktree.listRichWorktrees(cwd).then(worktrees => {
9543
10048
  // Match worktree paths to active sessions
9544
10049
  for (const wt of worktrees) {
@@ -9600,19 +10105,8 @@ function apiCreateWorktree(req, res) {
9600
10105
  res.writeHead(400, { 'Content-Type': 'application/json' });
9601
10106
  return res.end(JSON.stringify({ error: 'Invalid base branch name' }));
9602
10107
  }
9603
- // Reject ~ in cwd that's the corruption source for ghost worktrees
9604
- // (literal `~/ws/tools/...` shows up when shell expansion didn't run).
9605
- if (reqCwd && reqCwd.includes('~')) {
9606
- res.writeHead(400, { 'Content-Type': 'application/json' });
9607
- return res.end(JSON.stringify({ error: 'cwd must be an absolute path with no `~` (the literal-tilde is the source of ghost worktrees).' }));
9608
- }
9609
- let cwd = reqCwd || _projectRoot;
9610
- // Resolve symlinks so we always pass git an absolute, canonical path.
9611
- try { cwd = require('fs').realpathSync(cwd); } catch (_) { /* fall through to validation */ }
9612
- if (!isValidWorktreeCwd(cwd)) {
9613
- res.writeHead(403, { 'Content-Type': 'application/json' });
9614
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9615
- }
10108
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10109
+ if (!cwd) return;
9616
10110
  gitUtilsWorktree.createWorktree(cwd, name, base_branch).then(result => {
9617
10111
  res.writeHead(200, { 'Content-Type': 'application/json' });
9618
10112
  res.end(JSON.stringify({ ok: true, ...result }));
@@ -9641,11 +10135,8 @@ function apiMergeWorktree(req, res) {
9641
10135
  res.writeHead(400, { 'Content-Type': 'application/json' });
9642
10136
  return res.end(JSON.stringify({ error: 'Invalid merge strategy. Use: squash, no-ff, or ff' }));
9643
10137
  }
9644
- const cwd = reqCwd || _projectRoot;
9645
- if (!isValidWorktreeCwd(cwd)) {
9646
- res.writeHead(403, { 'Content-Type': 'application/json' });
9647
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9648
- }
10138
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10139
+ if (!cwd) return;
9649
10140
 
9650
10141
  // Pre-check for conflicts
9651
10142
  gitUtilsWorktree.mergeWorktreePreCheck(cwd, name).then(check => {
@@ -9680,7 +10171,7 @@ function apiRemoveWorktree(req, res) {
9680
10171
  res.writeHead(400, { 'Content-Type': 'application/json' });
9681
10172
  return res.end(JSON.stringify({ error: 'Invalid worktree name' }));
9682
10173
  }
9683
- const cwd = reqUrl.searchParams.get('cwd') || _projectRoot;
10174
+ const reqCwd = reqUrl.searchParams.get('cwd') || _projectRoot;
9684
10175
  const deleteBranchRaw = String(reqUrl.searchParams.get('delete_branch') || 'true').toLowerCase();
9685
10176
  const deleteBranch = !['false', '0', 'no'].includes(deleteBranchRaw);
9686
10177
  const force = reqUrl.searchParams.get('force') === 'true';
@@ -9689,10 +10180,8 @@ function apiRemoveWorktree(req, res) {
9689
10180
  res.writeHead(400, { 'Content-Type': 'application/json' });
9690
10181
  return res.end(JSON.stringify({ error: 'Invalid worktree name' }));
9691
10182
  }
9692
- if (!isValidWorktreeCwd(cwd)) {
9693
- res.writeHead(403, { 'Content-Type': 'application/json' });
9694
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9695
- }
10183
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10184
+ if (!cwd) return;
9696
10185
 
9697
10186
  // Check if any active session is using this worktree
9698
10187
  const worktreePath = path.join(cwd, '.claude', 'worktrees', name);
@@ -9759,11 +10248,8 @@ function apiSyncWorktree(req, res) {
9759
10248
  res.writeHead(400, WORKTREE_JSON_HEADERS);
9760
10249
  return res.end(JSON.stringify({ error: 'Invalid sync strategy. Use: merge or rebase' }));
9761
10250
  }
9762
- const cwd = reqCwd || _projectRoot;
9763
- if (!isValidWorktreeCwd(cwd)) {
9764
- res.writeHead(403, WORKTREE_JSON_HEADERS);
9765
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9766
- }
10251
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10252
+ if (!cwd) return;
9767
10253
  gitUtilsWorktree.syncWorktree(cwd, name, strategy || 'merge').then(result => {
9768
10254
  if (result.conflicts) {
9769
10255
  res.writeHead(409, WORKTREE_JSON_HEADERS);
@@ -9793,11 +10279,8 @@ function apiSyncAllWorktrees(req, res) {
9793
10279
  res.writeHead(400, WORKTREE_JSON_HEADERS);
9794
10280
  return res.end(JSON.stringify({ error: 'Invalid sync strategy. Use: merge or rebase' }));
9795
10281
  }
9796
- const cwd = reqCwd || _projectRoot;
9797
- if (!isValidWorktreeCwd(cwd)) {
9798
- res.writeHead(403, WORKTREE_JSON_HEADERS);
9799
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9800
- }
10282
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10283
+ if (!cwd) return;
9801
10284
  const activeWorktreePaths = [...sessions.values()]
9802
10285
  .map(s => s.worktree_path || s.cwd)
9803
10286
  .filter(Boolean)
@@ -9820,11 +10303,8 @@ function apiSyncAllWorktrees(req, res) {
9820
10303
 
9821
10304
  function apiPruneWorktrees(req, res) {
9822
10305
  const reqUrl = new URL(req.url, 'http://localhost');
9823
- const cwd = reqUrl.searchParams.get('cwd') || _projectRoot;
9824
- if (!isValidWorktreeCwd(cwd)) {
9825
- res.writeHead(403, { 'Content-Type': 'application/json' });
9826
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9827
- }
10306
+ const cwd = _resolveWorktreeApiCwd(res, reqUrl.searchParams.get('cwd') || _projectRoot, WORKTREE_JSON_HEADERS);
10307
+ if (!cwd) return;
9828
10308
  gitUtilsWorktree.pruneGhosts(cwd).then(result => {
9829
10309
  res.writeHead(200, { 'Content-Type': 'application/json' });
9830
10310
  res.end(JSON.stringify({ ok: true, ...result }));
@@ -9857,11 +10337,8 @@ function apiRecoverDetached(req, res) {
9857
10337
  res.writeHead(400, { 'Content-Type': 'application/json' });
9858
10338
  return res.end(JSON.stringify({ error: 'Invalid worktree path' }));
9859
10339
  }
9860
- const cwd = reqCwd || _projectRoot;
9861
- if (!isValidWorktreeCwd(cwd)) {
9862
- res.writeHead(403, { 'Content-Type': 'application/json' });
9863
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9864
- }
10340
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10341
+ if (!cwd) return;
9865
10342
  gitUtilsWorktree.recoverWorktree(cwd, { name, path: requestedPath }).then(result => {
9866
10343
  res.writeHead(200, { 'Content-Type': 'application/json' });
9867
10344
  res.end(JSON.stringify(result));
@@ -9890,11 +10367,8 @@ function apiCreatePR(req, res) {
9890
10367
  res.writeHead(400, { 'Content-Type': 'application/json' });
9891
10368
  return res.end(JSON.stringify({ error: 'Invalid base branch' }));
9892
10369
  }
9893
- const cwd = reqCwd || _projectRoot;
9894
- if (!isValidWorktreeCwd(cwd)) {
9895
- res.writeHead(403, { 'Content-Type': 'application/json' });
9896
- return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
9897
- }
10370
+ const cwd = _resolveWorktreeApiCwd(res, reqCwd, WORKTREE_JSON_HEADERS);
10371
+ if (!cwd) return;
9898
10372
  gitUtilsWorktree.pushAndCreatePR(cwd, name, { base: base || 'main', title, body: prBody }).then(result => {
9899
10373
  res.writeHead(200, { 'Content-Type': 'application/json' });
9900
10374
  res.end(JSON.stringify(result));
@@ -10208,14 +10682,36 @@ function _restoreSessionsAsync() {
10208
10682
 
10209
10683
  // --- Update checker ---
10210
10684
  const _updateState = { latestVersion: null, currentVersion: null, checkedAt: null, updateAvailable: false, error: null };
10685
+ const UPDATE_CHECK_TTL_MS = 60 * 60 * 1000;
10686
+ let _updateCheckInFlight = null;
10211
10687
 
10212
- function getCurrentVersion() {
10688
+ function readPackageVersion(pkgPath) {
10213
10689
  try {
10214
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
10690
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
10215
10691
  return pkg.version;
10216
10692
  } catch { return null; }
10217
10693
  }
10218
10694
 
10695
+ function getCurrentVersion() {
10696
+ return readPackageVersion(path.join(__dirname, '..', 'package.json'));
10697
+ }
10698
+
10699
+ function getAppVersionInfo() {
10700
+ return {
10701
+ version: getCurrentVersion(),
10702
+ product: 'create-walle',
10703
+ components: {
10704
+ ctm: readPackageVersion(path.join(__dirname, 'package.json')),
10705
+ wallE: readPackageVersion(path.join(__dirname, '..', 'wall-e', 'package.json')),
10706
+ },
10707
+ };
10708
+ }
10709
+
10710
+ function apiAppVersion(req, res) {
10711
+ res.writeHead(200, { 'Content-Type': 'application/json' });
10712
+ res.end(JSON.stringify(getAppVersionInfo()));
10713
+ }
10714
+
10219
10715
  // Simple semver comparison: returns 1 if a > b, -1 if a < b, 0 if equal
10220
10716
  function semverCompare(a, b) {
10221
10717
  const pa = a.split('.').map(Number);
@@ -10227,8 +10723,12 @@ function semverCompare(a, b) {
10227
10723
  return 0;
10228
10724
  }
10229
10725
 
10230
- async function checkForUpdates() {
10726
+ async function checkForUpdates(source = 'scheduled') {
10231
10727
  _updateState.currentVersion = getCurrentVersion();
10728
+ trackUpdateTelemetry(telemetry, 'check_started', {
10729
+ source,
10730
+ currentVersion: _updateState.currentVersion,
10731
+ });
10232
10732
  try {
10233
10733
  const resp = await fetch('https://registry.npmjs.org/create-walle/latest', {
10234
10734
  headers: { 'Accept': 'application/json' },
@@ -10245,8 +10745,21 @@ async function checkForUpdates() {
10245
10745
  _updateState.latestVersion && _updateState.currentVersion &&
10246
10746
  semverCompare(_updateState.latestVersion, _updateState.currentVersion) > 0
10247
10747
  );
10748
+ trackUpdateTelemetry(telemetry, 'check_completed', {
10749
+ source,
10750
+ currentVersion: _updateState.currentVersion,
10751
+ latestVersion: _updateState.latestVersion,
10752
+ updateAvailable: _updateState.updateAvailable,
10753
+ result: _updateState.updateAvailable ? 'available' : 'up_to_date',
10754
+ });
10248
10755
 
10249
10756
  if (_updateState.updateAvailable) {
10757
+ trackUpdateTelemetry(telemetry, 'update_available', {
10758
+ source,
10759
+ currentVersion: _updateState.currentVersion,
10760
+ latestVersion: _updateState.latestVersion,
10761
+ updateAvailable: true,
10762
+ });
10250
10763
  broadcastToAll({
10251
10764
  type: 'update-available',
10252
10765
  currentVersion: _updateState.currentVersion,
@@ -10256,11 +10769,42 @@ async function checkForUpdates() {
10256
10769
  }
10257
10770
  } catch (e) {
10258
10771
  _updateState.error = e.message;
10772
+ trackUpdateTelemetry(telemetry, 'check_failed', {
10773
+ source,
10774
+ currentVersion: _updateState.currentVersion,
10775
+ errorKind: updateTelemetryErrorKind(e),
10776
+ result: 'error',
10777
+ });
10259
10778
  }
10260
10779
  return _updateState;
10261
10780
  }
10262
10781
 
10263
- function apiUpdatesCheck(req, res) {
10782
+ function updateStateIsStale() {
10783
+ const checkedAt = Date.parse(_updateState.checkedAt || '');
10784
+ return !checkedAt || (Date.now() - checkedAt) > UPDATE_CHECK_TTL_MS;
10785
+ }
10786
+
10787
+ async function refreshUpdateState(source) {
10788
+ if (_updateCheckInFlight) return _updateCheckInFlight;
10789
+ _updateCheckInFlight = checkForUpdates(source).finally(() => {
10790
+ _updateCheckInFlight = null;
10791
+ });
10792
+ return _updateCheckInFlight;
10793
+ }
10794
+
10795
+ async function apiUpdatesCheck(req, res, url) {
10796
+ _updateState.currentVersion = getCurrentVersion();
10797
+ const refresh = url?.searchParams?.get('refresh') === '1';
10798
+ if (refresh || updateStateIsStale()) {
10799
+ await refreshUpdateState(refresh ? 'api_refresh' : 'api_stale');
10800
+ }
10801
+ trackUpdateTelemetry(telemetry, 'api_check', {
10802
+ source: 'api',
10803
+ currentVersion: _updateState.currentVersion,
10804
+ latestVersion: _updateState.latestVersion,
10805
+ updateAvailable: _updateState.updateAvailable,
10806
+ result: _updateState.updateAvailable ? 'available' : 'up_to_date',
10807
+ });
10264
10808
  res.writeHead(200, { 'Content-Type': 'application/json' });
10265
10809
  res.end(JSON.stringify(_updateState));
10266
10810
  }
@@ -10268,7 +10812,20 @@ function apiUpdatesCheck(req, res) {
10268
10812
  function apiUpdatesApply(req, res) {
10269
10813
  const current = _updateState.currentVersion;
10270
10814
  const latest = _updateState.latestVersion;
10815
+ trackUpdateTelemetry(telemetry, 'apply_requested', {
10816
+ source: 'api',
10817
+ currentVersion: current,
10818
+ latestVersion: latest,
10819
+ updateAvailable: _updateState.updateAvailable,
10820
+ });
10271
10821
  if (!_updateState.updateAvailable) {
10822
+ trackUpdateTelemetry(telemetry, 'apply_ignored', {
10823
+ source: 'api',
10824
+ currentVersion: current,
10825
+ latestVersion: latest,
10826
+ updateAvailable: false,
10827
+ result: 'ignored',
10828
+ });
10272
10829
  res.writeHead(200, { 'Content-Type': 'application/json' });
10273
10830
  res.end(JSON.stringify({ status: 'up-to-date', version: current }));
10274
10831
  return;
@@ -10279,12 +10836,58 @@ function apiUpdatesApply(req, res) {
10279
10836
 
10280
10837
  // Run update in background — npx create-walle@latest update handles stop/update/restart
10281
10838
  const { spawn } = require('child_process');
10282
- const child = spawn('npx', ['create-walle@latest', 'update'], {
10283
- cwd: path.join(__dirname, '..'),
10284
- detached: true,
10285
- stdio: 'ignore',
10839
+ try {
10840
+ const child = spawn('npx', ['create-walle@latest', 'update'], {
10841
+ cwd: path.join(__dirname, '..'),
10842
+ detached: true,
10843
+ stdio: 'ignore',
10844
+ });
10845
+ trackUpdateTelemetry(telemetry, 'apply_started', {
10846
+ source: 'api',
10847
+ currentVersion: current,
10848
+ latestVersion: latest,
10849
+ updateAvailable: true,
10850
+ result: 'started',
10851
+ });
10852
+ child.on('error', (e) => {
10853
+ trackUpdateTelemetry(telemetry, 'apply_spawn_error', {
10854
+ source: 'api',
10855
+ currentVersion: current,
10856
+ latestVersion: latest,
10857
+ errorKind: updateTelemetryErrorKind(e),
10858
+ result: 'error',
10859
+ });
10860
+ });
10861
+ child.unref();
10862
+ } catch (e) {
10863
+ trackUpdateTelemetry(telemetry, 'apply_spawn_error', {
10864
+ source: 'api',
10865
+ currentVersion: current,
10866
+ latestVersion: latest,
10867
+ errorKind: updateTelemetryErrorKind(e),
10868
+ result: 'error',
10869
+ });
10870
+ }
10871
+ }
10872
+
10873
+ function apiUpdatesTelemetry(req, res) {
10874
+ let body = '';
10875
+ req.on('data', (chunk) => {
10876
+ body += chunk;
10877
+ if (body.length > 4096) req.destroy();
10878
+ });
10879
+ req.on('end', () => {
10880
+ try {
10881
+ const parsed = body ? JSON.parse(body) : {};
10882
+ const item = clientUpdateTelemetryEvent(parsed);
10883
+ if (item) trackUpdateTelemetry(telemetry, item.event, item.details);
10884
+ res.writeHead(item ? 200 : 400, { 'Content-Type': 'application/json' });
10885
+ res.end(JSON.stringify(item ? { ok: true } : { ok: false, error: 'invalid telemetry event' }));
10886
+ } catch {
10887
+ res.writeHead(400, { 'Content-Type': 'application/json' });
10888
+ res.end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
10889
+ }
10286
10890
  });
10287
- child.unref();
10288
10891
  }
10289
10892
 
10290
10893
  // --- Auto Title Generation ---
@@ -10556,8 +11159,8 @@ server.on('listening', () => {
10556
11159
  _restoreSessionsAsync();
10557
11160
 
10558
11161
  // Check for updates on startup (after 10s delay) and every 24h
10559
- setTimeout(() => checkForUpdates().catch(() => {}), 10000);
10560
- setInterval(() => checkForUpdates().catch(() => {}), 24 * 60 * 60 * 1000);
11162
+ setTimeout(() => checkForUpdates('startup').catch(() => {}), 10000);
11163
+ setInterval(() => checkForUpdates('scheduled').catch(() => {}), 24 * 60 * 60 * 1000);
10561
11164
 
10562
11165
  // --- Scheduler: replaces ad-hoc setInterval timers for session management ---
10563
11166
  const ctmScheduler = _ctmScheduler = new Scheduler({ tickMs: 5000, pools: { io: 3, llm: 1 } });