create-walle 0.9.21 → 0.9.22

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 (52) hide show
  1. package/README.md +5 -5
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +13 -0
  4. package/template/claude-task-manager/api-reviews.js +5 -2
  5. package/template/claude-task-manager/db.js +348 -15
  6. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  7. package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
  8. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  9. package/template/claude-task-manager/git-utils.js +146 -17
  10. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  11. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  12. package/template/claude-task-manager/lib/document-review.js +33 -2
  13. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
  14. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  15. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  16. package/template/claude-task-manager/lib/session-standup.js +36 -13
  17. package/template/claude-task-manager/lib/session-stream.js +11 -4
  18. package/template/claude-task-manager/lib/transport-security.js +50 -0
  19. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  20. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  21. package/template/claude-task-manager/public/css/reviews.css +10 -0
  22. package/template/claude-task-manager/public/css/setup.css +13 -0
  23. package/template/claude-task-manager/public/css/walle.css +145 -0
  24. package/template/claude-task-manager/public/index.html +539 -44
  25. package/template/claude-task-manager/public/ipad.html +363 -0
  26. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  27. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  28. package/template/claude-task-manager/public/js/reviews.js +30 -6
  29. package/template/claude-task-manager/public/js/setup.js +42 -2
  30. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  31. package/template/claude-task-manager/public/js/walle.js +314 -18
  32. package/template/claude-task-manager/public/m/app.css +789 -11
  33. package/template/claude-task-manager/public/m/app.js +1070 -67
  34. package/template/claude-task-manager/public/m/claim.html +9 -2
  35. package/template/claude-task-manager/public/m/index.html +17 -10
  36. package/template/claude-task-manager/public/m/sw.js +1 -1
  37. package/template/claude-task-manager/server.js +365 -95
  38. package/template/claude-task-manager/session-integrity.js +4 -0
  39. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  40. package/template/package.json +1 -1
  41. package/template/wall-e/api-walle.js +19 -1
  42. package/template/wall-e/brain.js +152 -6
  43. package/template/wall-e/chat.js +85 -0
  44. package/template/wall-e/coding-orchestrator.js +106 -12
  45. package/template/wall-e/http/model-admin.js +131 -0
  46. package/template/wall-e/lib/service-health.js +194 -0
  47. package/template/wall-e/llm/anthropic.js +7 -0
  48. package/template/wall-e/llm/client.js +46 -12
  49. package/template/wall-e/llm/openai.js +17 -2
  50. package/template/wall-e/llm/portkey-sync.js +201 -0
  51. package/template/wall-e/server.js +13 -0
  52. package/template/website/index.html +10 -10
@@ -95,6 +95,7 @@ const {
95
95
  buildSessionStandupSnapshot,
96
96
  isWalleOwnedSession,
97
97
  } = require('./lib/session-standup');
98
+ const { summarizeRestartGuard } = require('./lib/restart-guard');
98
99
  const { createStandupIncrementalCache } = require('./lib/standup-incremental');
99
100
  const {
100
101
  buildStandupAttentionContext,
@@ -166,6 +167,8 @@ const {
166
167
  isLoopbackRequest,
167
168
  loadTlsOptions,
168
169
  parseAllowedOrigins,
170
+ primaryProcessIpLockoutExemptions,
171
+ requestClientIp,
169
172
  requestOrigin,
170
173
  } = require('./lib/transport-security');
171
174
  const {
@@ -185,6 +188,7 @@ const {
185
188
  clearMicrosoftDevTunnelTraffic,
186
189
  detectMicrosoftDevTunnelSetup,
187
190
  getMicrosoftDevTunnelProgress,
191
+ logoutMicrosoftDevTunnelUser,
188
192
  probeMicrosoftDevTunnelPublicAccess,
189
193
  recordMicrosoftDevTunnelTraffic,
190
194
  setMicrosoftDevTunnelKeepAwake,
@@ -712,7 +716,9 @@ const loggedSetupProviderDuplicateSignatures = new Set();
712
716
  const WALLE_SESSIONS_DIR = walleTranscript.defaultSessionsDir(process.env);
713
717
 
714
718
  const config = loadConfig();
715
- const authRateLimiter = new AuthRateLimiter();
719
+ const authRateLimiter = new AuthRateLimiter({
720
+ ipLockoutExemptions: primaryProcessIpLockoutExemptions(HOST),
721
+ });
716
722
  const AGENT_CLI_CACHE_FILE = path.join(CONFIG_DIR, 'agent-cli-cache.json');
717
723
  const {
718
724
  detectAgentType,
@@ -1513,7 +1519,7 @@ function _sendAuthFailure(res, decision) {
1513
1519
  }
1514
1520
 
1515
1521
  function _remoteIpFromReq(req) {
1516
- return req?.socket?.remoteAddress || '';
1522
+ return requestClientIp(req);
1517
1523
  }
1518
1524
 
1519
1525
  function _auditAuthDecision({ req, auth, rule, decision, route, wsType, action }) {
@@ -1544,15 +1550,18 @@ function _rateLimitDecision(auth, rule, keyPath) {
1544
1550
 
1545
1551
  function _authorizeHttpRequest(req, res, url) {
1546
1552
  const remoteIp = _remoteIpFromReq(req);
1547
- const locked = authRateLimiter.isIpLocked(remoteIp);
1548
- if (!locked.ok) {
1549
- _sendAuthFailure(res, { status: 429, ...locked });
1550
- return false;
1551
- }
1552
-
1553
1553
  const auth = _authContextForRequest(req, url);
1554
1554
  req.ctmAuth = auth;
1555
1555
 
1556
+ if (!auth.isLoopback) {
1557
+ const locked = authRateLimiter.isIpLocked(remoteIp);
1558
+ if (!locked.ok && !auth.authenticated) {
1559
+ _sendAuthFailure(res, { status: 429, ...locked });
1560
+ return false;
1561
+ }
1562
+ if (!locked.ok && auth.authenticated) authRateLimiter.recordAuthSuccess(remoteIp);
1563
+ }
1564
+
1556
1565
  const rule = getHttpAuthRule(req.method, url.pathname);
1557
1566
  if (!rule && auth.isLoopback) return true;
1558
1567
 
@@ -2559,6 +2568,19 @@ async function handleSetupNetwork(req, res, url) {
2559
2568
  return true;
2560
2569
  }
2561
2570
  }
2571
+ if (url.pathname === '/api/setup/network/microsoft-dev-tunnel/logout' && req.method === 'POST') {
2572
+ try {
2573
+ const result = await logoutMicrosoftDevTunnelUser({ configDir: CONFIG_DIR, port: PORT });
2574
+ const status = result.ok ? 200 : 400;
2575
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2576
+ res.end(JSON.stringify(result));
2577
+ return true;
2578
+ } catch (e) {
2579
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2580
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2581
+ return true;
2582
+ }
2583
+ }
2562
2584
  if (url.pathname === '/api/setup/network/microsoft-dev-tunnel/stop' && req.method === 'POST') {
2563
2585
  const result = await stopMicrosoftDevTunnel({ configDir: CONFIG_DIR, port: PORT });
2564
2586
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2812,6 +2834,45 @@ function _isLiveTerminalSession(session) {
2812
2834
  return !!(session && session.ptyProcess && session.status !== 'exited' && session.status !== 'closed');
2813
2835
  }
2814
2836
 
2837
+ function _isDefaultWalleChatSessionId(sessionId) {
2838
+ const id = String(sessionId || '').trim().toLowerCase();
2839
+ return id === 'default' || id === 'walle-default' || id === 'walle-default-chat';
2840
+ }
2841
+
2842
+ async function _sendRemoteDefaultWalleChatMessage(text, body = {}) {
2843
+ if (!String(text || '').trim()) return { ok: false, error: 'message_required' };
2844
+ const attachments = Array.isArray(body.attachments) ? body.attachments : [];
2845
+ const explicitModel = body.model_id || body.model || '';
2846
+ const explicitProvider = body.model_provider || body.provider || '';
2847
+ const upstream = await walleClient.requestJson('/api/wall-e/chat', {
2848
+ method: 'POST',
2849
+ body: {
2850
+ message: text,
2851
+ session_id: 'default',
2852
+ channel: 'ctm',
2853
+ attachments: attachments.length ? attachments : undefined,
2854
+ model: explicitModel || undefined,
2855
+ provider: explicitProvider || undefined,
2856
+ modelPinned: body.modelPinned === true || body.model_pinned === true || !!explicitModel,
2857
+ allowProviderFallback: explicitModel ? false : body.allowProviderFallback === true,
2858
+ },
2859
+ });
2860
+ if (upstream.status >= 400) {
2861
+ return {
2862
+ ok: false,
2863
+ error: upstream.json?.error || upstream.json?.message || `wall_e_chat_failed_${upstream.status}`,
2864
+ status: upstream.status,
2865
+ providerError: upstream.json?.providerError || null,
2866
+ };
2867
+ }
2868
+ return {
2869
+ ok: true,
2870
+ delivered: true,
2871
+ session_id: 'default',
2872
+ reply: upstream.json?.data?.reply || '',
2873
+ };
2874
+ }
2875
+
2815
2876
  function _remoteTerminalAgentType(session) {
2816
2877
  if (!session || session.type === 'walle') return '';
2817
2878
  return normalizeAgentType(session.agentType || session._providerId || '') ||
@@ -3094,6 +3155,9 @@ async function handleRemoteApi(req, res, url) {
3094
3155
  },
3095
3156
  sendWalleMessage: async (sessionId, text, body = {}) => {
3096
3157
  const session = sessions.get(sessionId);
3158
+ if (!session && _isDefaultWalleChatSessionId(sessionId)) {
3159
+ return _sendRemoteDefaultWalleChatMessage(text, body);
3160
+ }
3097
3161
  if (!session) return { ok: false, error: 'session_not_found' };
3098
3162
  if (session.type !== 'walle') return { ok: false, error: 'not_walle_session' };
3099
3163
  if (!text.trim()) return { ok: false, error: 'message_required' };
@@ -3117,7 +3181,7 @@ async function handleRemoteApi(req, res, url) {
3117
3181
  setWalleModel: async (sessionId, body = {}) => {
3118
3182
  const session = sessions.get(sessionId);
3119
3183
  if (!session) return { ok: false, error: 'session_not_found' };
3120
- if (session.type !== 'walle') return { ok: false, error: 'not_walle_session' };
3184
+ if (!_isWalleModelConfigurableSession(session)) return { ok: false, error: 'not_walle_session' };
3121
3185
  const pref = _persistWalleSessionModelPreference(session, {
3122
3186
  model_id: body.model_id || body.model || '',
3123
3187
  model_provider: body.model_provider || body.provider || '',
@@ -3127,14 +3191,15 @@ async function handleRemoteApi(req, res, url) {
3127
3191
  source: 'phone',
3128
3192
  pinned: body.pinned !== false,
3129
3193
  });
3194
+ const selection = _walleModelSelectionForActiveSession(session);
3130
3195
  return {
3131
3196
  ok: true,
3132
3197
  delivered: true,
3133
- model_id: session.model_id || null,
3134
- model_provider: session.model_provider || null,
3135
- model_registry_id: session.model_registry_id || '',
3136
- model_provider_id: session.model_provider_id || '',
3137
- model_pinned: !!session.model_pinned,
3198
+ model_id: selection.model_id || null,
3199
+ model_provider: selection.model_provider || null,
3200
+ model_registry_id: selection.model_registry_id || '',
3201
+ model_provider_id: selection.model_provider_id || '',
3202
+ model_pinned: !!selection.model_pinned,
3138
3203
  cleared: !pref,
3139
3204
  };
3140
3205
  },
@@ -3192,6 +3257,7 @@ async function handleApi(req, res, url) {
3192
3257
  let walleProvider = process.env.WALLE_PROVIDER || 'anthropic';
3193
3258
  let walleModel = process.env.WALLE_MODEL || '';
3194
3259
  let serviceAlerts = [];
3260
+ let serviceAlertSummary = null;
3195
3261
  try {
3196
3262
  const upstream = await walleClient.requestJson('/api/wall-e/status');
3197
3263
  const data = upstream.json?.data || {};
@@ -3200,6 +3266,7 @@ async function handleApi(req, res, url) {
3200
3266
  if (data.owner && !ownerName) ownerName = data.owner;
3201
3267
  if (typeof data.has_api_key === 'boolean') hasApiKey = hasApiKey || data.has_api_key;
3202
3268
  serviceAlerts = Array.isArray(data.service_alerts) ? data.service_alerts : [];
3269
+ if (data.service_alert_summary && typeof data.service_alert_summary === 'object') serviceAlertSummary = data.service_alert_summary;
3203
3270
  } catch {}
3204
3271
  // Also check env-based provider keys
3205
3272
  if (!hasApiKey && walleProvider === 'openai' && process.env.OPENAI_API_KEY) hasApiKey = true;
@@ -3212,7 +3279,7 @@ async function handleApi(req, res, url) {
3212
3279
  const authMethod = providerState.authMethod || '';
3213
3280
  const codingViaCli = process.env.WALLE_CODING_USE_CLI === 'true';
3214
3281
  const deviceLabel = dbModule.getSetting('ui_setup_device_label', 'Owner iPhone');
3215
- res.end(JSON.stringify({ owner_name: ownerName, has_api_key: hasApiKey, slack_connected: slackConnected, slack_team: slackTeam, needs_setup: !hasApiKey && setup.needsSetup(), version, ctm_data_dir: ctmDataDir, walle_data_dir: walleDataDir, hostname: HOSTNAME, walle_model: walleModel, walle_provider: walleProvider, auth_method: authMethod, coding_via_cli: codingViaCli, service_alerts: serviceAlerts, device_label: deviceLabel }));
3282
+ res.end(JSON.stringify({ owner_name: ownerName, has_api_key: hasApiKey, slack_connected: slackConnected, slack_team: slackTeam, needs_setup: !hasApiKey && setup.needsSetup(), version, ctm_data_dir: ctmDataDir, walle_data_dir: walleDataDir, hostname: HOSTNAME, walle_model: walleModel, walle_provider: walleProvider, auth_method: authMethod, coding_via_cli: codingViaCli, service_alerts: serviceAlerts, service_alert_summary: serviceAlertSummary, device_label: deviceLabel }));
3216
3283
  return;
3217
3284
  }
3218
3285
  if (url.pathname === '/api/setup/test-key' && req.method === 'GET') {
@@ -6655,6 +6722,14 @@ function apiRecentSessions(req, res, url) {
6655
6722
  // slug-first verifyLineage and respects the grace window.
6656
6723
  });
6657
6724
  txn();
6725
+ try {
6726
+ const identityRepair = dbModule.repairCodexRolloutAgentIdentities?.();
6727
+ if (identityRepair && identityRepair.repaired > 0) {
6728
+ console.log(`[session-index] repaired ${identityRepair.repaired} Codex rollout agent identity row(s)`);
6729
+ }
6730
+ } catch (e) {
6731
+ console.error('[session-index] Codex identity repair error:', e.message);
6732
+ }
6658
6733
  try {
6659
6734
  const repaired = dbModule.repairCodexSessionMetadataFromConversations?.();
6660
6735
  if (repaired && repaired.repaired > 0) {
@@ -9549,6 +9624,22 @@ function _persistTelemetrySessionToken({ ctmSessionId, agentSessionId, existingA
9549
9624
  * Parse JSONL content and append messages to the provided array.
9550
9625
  * Handles user, assistant, and system messages with deduplication.
9551
9626
  */
9627
+ function _jsonlContentText(content) {
9628
+ if (typeof content === 'string') return content;
9629
+ if (!Array.isArray(content)) return '';
9630
+ const parts = [];
9631
+ let imageCount = 0;
9632
+ for (const block of content) {
9633
+ if (!block || typeof block !== 'object') continue;
9634
+ if ((block.type === 'text' || block.type === 'input_text') && block.text) {
9635
+ parts.push(block.text);
9636
+ } else if (block.type === 'image' || block.type === 'input_image' || block.type === 'image_ref') {
9637
+ parts.push(`[Image #${++imageCount}]`);
9638
+ }
9639
+ }
9640
+ return parts.join('\n');
9641
+ }
9642
+
9552
9643
  function _parseJsonlIntoMessages(content, messages, opts = {}) {
9553
9644
  if (content.includes('"type":"session_meta"') && content.includes('"originator":"codex-tui"')) {
9554
9645
  parseCodexJsonlIntoMessages(content, messages);
@@ -9572,8 +9663,7 @@ function _parseJsonlIntoMessages(content, messages, opts = {}) {
9572
9663
  const entry = JSON.parse(line);
9573
9664
  if (entry.type === 'user' && entry.message?.role === 'user') {
9574
9665
  const c = entry.message.content;
9575
- let text = typeof c === 'string' ? c
9576
- : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
9666
+ let text = _jsonlContentText(c);
9577
9667
  if (!text) continue;
9578
9668
  // Detect system/tool messages masquerading as user messages
9579
9669
  const isToolResult = Array.isArray(c) && c.some(b => b.type === 'tool_result');
@@ -11663,11 +11753,6 @@ wss.on('connection', (ws, req) => {
11663
11753
  socket: { remoteAddress: _remoteIpFromReq(req) },
11664
11754
  headers: { 'user-agent': req.headers?.['user-agent'] || '' },
11665
11755
  };
11666
- const locked = authRateLimiter.isIpLocked(reqSnapshot.socket.remoteAddress);
11667
- if (!locked.ok) {
11668
- ws.close(4008, locked.code || 'Rate limited');
11669
- return;
11670
- }
11671
11756
  const cfDecision = cloudflareAccessVerifier.verifyRequestSync(req, { isLocalhost });
11672
11757
  if (!cfDecision.ok) {
11673
11758
  _auditAuthDecision({
@@ -11682,6 +11767,14 @@ wss.on('connection', (ws, req) => {
11682
11767
  return;
11683
11768
  }
11684
11769
  const auth = _authContextForRequest(req, url);
11770
+ if (!auth.isLoopback) {
11771
+ const locked = authRateLimiter.isIpLocked(reqSnapshot.socket.remoteAddress);
11772
+ if (!locked.ok && !auth.authenticated) {
11773
+ ws.close(4008, locked.code || 'Rate limited');
11774
+ return;
11775
+ }
11776
+ if (!locked.ok && auth.authenticated) authRateLimiter.recordAuthSuccess(reqSnapshot.socket.remoteAddress);
11777
+ }
11685
11778
 
11686
11779
  if (!auth.authenticated) {
11687
11780
  if (!auth.isLoopback) authRateLimiter.recordAuthFailure(reqSnapshot.socket.remoteAddress);
@@ -11721,7 +11814,7 @@ wss.on('connection', (ws, req) => {
11721
11814
  // browser echoes this in X-CTM-Client-Id when calling PUT /api/settings,
11722
11815
  // and we filter it out of broadcastUiPrefs to prevent self-echo.
11723
11816
  ws.clientId = crypto.randomUUID();
11724
- try { ws.send(JSON.stringify({ type: 'hello', clientId: ws.clientId })); } catch {}
11817
+ try { ws.send(JSON.stringify({ type: 'hello', clientId: ws.clientId, appVersion: getAppVersionInfo() })); } catch {}
11725
11818
  ws.on('pong', () => { ws.isAlive = true; });
11726
11819
 
11727
11820
  ws.on('message', (raw) => {
@@ -13368,12 +13461,18 @@ function handleCreate(ws, msg) {
13368
13461
  model_provider = defaults.model_provider || null;
13369
13462
  model_pinned = false;
13370
13463
  }
13371
- const agentMode = msg.agentMode || msg.agent_mode || 'coding';
13372
- const agentKind = msg.agentKind || msg.agent_kind || (agentMode === 'coding' ? 'walle-coding' : 'walle-chat');
13373
- const taskType = msg.taskType || msg.task_type || (agentMode === 'coding' ? 'coding' : 'chat');
13374
- const chatSessionId = msg.chatSessionId || `walle-${id}`;
13464
+ const requestedChatSessionId = msg.chatSessionId || `walle-${id}`;
13465
+ let jsonlPath = _walleTranscriptPathForSession(id, requestedChatSessionId);
13466
+ const restoredMeta = msg._isRestore ? walleTranscript.readSessionMeta(jsonlPath) : null;
13467
+ const restoredMode = restoredMeta?.agentMode || restoredMeta?.agent_mode || '';
13468
+ const restoredKind = restoredMeta?.agentKind || restoredMeta?.agent_kind || '';
13469
+ const restoredTaskType = restoredMeta?.taskType || restoredMeta?.task_type || '';
13470
+ const agentMode = msg.agentMode || msg.agent_mode || restoredMode || 'coding';
13471
+ const agentKind = msg.agentKind || msg.agent_kind || restoredKind || (agentMode === 'coding' ? 'walle-coding' : 'walle-chat');
13472
+ const taskType = msg.taskType || msg.task_type || restoredTaskType || (agentMode === 'coding' ? 'coding' : 'chat');
13473
+ const chatSessionId = msg.chatSessionId || restoredMeta?.chatSessionId || `walle-${id}`;
13375
13474
  const initialMessage = String(msg.initialMessage || msg.initial_message || '').trim();
13376
- const jsonlPath = _walleTranscriptPathForSession(id, chatSessionId);
13475
+ if (chatSessionId !== requestedChatSessionId) jsonlPath = _walleTranscriptPathForSession(id, chatSessionId);
13377
13476
  const createResult = walleTranscript.createSession(jsonlPath, {
13378
13477
  reset: !msg._isRestore,
13379
13478
  sessionId: id,
@@ -13442,6 +13541,11 @@ function handleCreate(ws, msg) {
13442
13541
  model_registry_id: session.model_registry_id || '',
13443
13542
  model_provider_id: session.model_provider_id || '',
13444
13543
  model_pinned: !!session.model_pinned,
13544
+ agentMode: session.agentMode,
13545
+ agentKind: session.agentKind,
13546
+ taskType: session.taskType,
13547
+ walle_chat_session: session.agentMode === 'chat' || session.taskType === 'chat',
13548
+ walleChatSession: session.agentMode === 'chat' || session.taskType === 'chat',
13445
13549
  }));
13446
13550
  }
13447
13551
  broadcastSessionList(true);
@@ -15837,21 +15941,28 @@ function _safePathForCompare(p) {
15837
15941
  }
15838
15942
 
15839
15943
  function _findSessionWorktree(worktrees, session) {
15840
- const explicitPath = session.worktree_path || (_isAgentWorktreePath(session.cwd) ? session.cwd : '');
15841
- const sessionPath = _safePathForCompare(explicitPath || '');
15944
+ const sessionPath = _safePathForCompare(session.worktree_path || session.cwd || '');
15842
15945
  const branch = session.branch || '';
15843
- const exact = sessionPath
15844
- ? worktrees.find(wt => _safePathForCompare(wt.path || '') === sessionPath)
15845
- : null;
15846
- if (exact) return exact;
15847
- if (!session.worktree_path || !branch) return null;
15848
- const branchMatches = worktrees.filter(wt => wt && !wt.isMain && wt.branch === branch);
15946
+ const pathMatches = sessionPath
15947
+ ? worktrees
15948
+ .filter(wt => {
15949
+ const wtPath = _safePathForCompare(wt?.path || '');
15950
+ return wtPath && (sessionPath === wtPath || sessionPath.startsWith(wtPath + path.sep));
15951
+ })
15952
+ .sort((a, b) => String(b.path || '').length - String(a.path || '').length)
15953
+ : [];
15954
+ if (pathMatches.length > 0) return pathMatches[0];
15955
+ if (!branch) return null;
15956
+ const branchMatches = worktrees.filter(wt => wt && wt.branch === branch);
15849
15957
  return branchMatches.length === 1 ? branchMatches[0] : null;
15850
15958
  }
15851
15959
 
15852
15960
  function _worktreeFinishMessage(wt, branch) {
15853
15961
  const parts = [];
15854
- if ((wt.dirtyFiles || 0) > 0) parts.push(`${wt.dirtyFiles} dirty file(s)`);
15962
+ const trackedDirty = wt.trackedDirtyFiles != null
15963
+ ? Number(wt.trackedDirtyFiles || 0)
15964
+ : Math.max(0, Number(wt.dirtyFiles || 0) - Number(wt.untrackedFiles || 0));
15965
+ if (trackedDirty > 0) parts.push(`${trackedDirty} tracked dirty file(s)`);
15855
15966
  if ((wt.unmergedCommits || 0) > 0) parts.push(`${wt.unmergedCommits} unmerged commit(s)`);
15856
15967
  if ((wt.behind || 0) > 0) parts.push(`${wt.behind} behind main`);
15857
15968
  const detail = parts.length ? parts.join(', ') : (wt.summary || wt.state || 'unfinished work');
@@ -15872,9 +15983,12 @@ async function _maybeBroadcastWorktreeFinishGate(session, sessionId) {
15872
15983
  const wtBranch = wt.branch || branch;
15873
15984
  if (wtBranch === 'main' || wtBranch === 'master') return;
15874
15985
  const dirtyFiles = wt.dirtyFiles || 0;
15986
+ const trackedDirtyFiles = wt.trackedDirtyFiles != null
15987
+ ? Number(wt.trackedDirtyFiles || 0)
15988
+ : Math.max(0, Number(wt.dirtyFiles || 0) - Number(wt.untrackedFiles || 0));
15875
15989
  const unmergedCommits = wt.unmergedCommits || 0;
15876
15990
  const actionableState = ['ahead', 'diverged', 'dirty', 'detached'].includes(wt.state);
15877
- if (dirtyFiles === 0 && unmergedCommits === 0 && !actionableState) return;
15991
+ if (trackedDirtyFiles === 0 && unmergedCommits === 0 && !actionableState) return;
15878
15992
  broadcastToAll({
15879
15993
  type: 'worktree-finish-gate',
15880
15994
  sessionId,
@@ -15885,6 +15999,8 @@ async function _maybeBroadcastWorktreeFinishGate(session, sessionId) {
15885
15999
  ahead: wt.ahead || 0,
15886
16000
  behind: wt.behind || 0,
15887
16001
  dirtyFiles,
16002
+ trackedDirtyFiles,
16003
+ untrackedFiles: wt.untrackedFiles || 0,
15888
16004
  unmergedCommits,
15889
16005
  summary: wt.summary || '',
15890
16006
  recommendedAction: wt.recommendedAction || null,
@@ -15924,31 +16040,51 @@ let _sessionWorktreeStatusRefreshInFlight = false;
15924
16040
 
15925
16041
  function _sessionNeedsWorktreeStatus(session) {
15926
16042
  if (!session) return false;
15927
- const branch = session.branch || '';
15928
- if (branch === 'main' || branch === 'master') return false;
15929
- return !!(session.worktree_path || _isAgentWorktreePath(session.cwd));
16043
+ return !!(session.worktree_path || session.cwd);
15930
16044
  }
15931
16045
 
15932
16046
  function _normalizeSessionWorktreeStatus(session, wt) {
15933
16047
  if (!session || !wt) return null;
15934
16048
  const branch = wt.branch || session.branch || '';
15935
- if (!branch || branch === 'main' || branch === 'master' || wt.isMain) return null;
16049
+ if (!branch) return null;
16050
+ const isMain = wt.isMain === true || branch === 'main' || branch === 'master';
16051
+ const mainRemote = isMain && wt.mainRemote ? wt.mainRemote : null;
15936
16052
  const dirtyFiles = Number(wt.dirtyFiles || 0);
15937
- const unmergedCommits = Number(wt.unmergedCommits || 0);
15938
- const ahead = Number(wt.ahead || 0);
15939
- const behind = Number(wt.behind || 0);
15940
- return {
16053
+ const trackedDirtyFiles = wt.trackedDirtyFiles != null
16054
+ ? Number(wt.trackedDirtyFiles || 0)
16055
+ : Math.max(0, dirtyFiles - Number(wt.untrackedFiles || 0));
16056
+ const untrackedFiles = Number(wt.untrackedFiles || 0);
16057
+ const unpushedCommits = isMain ? Number(mainRemote?.ahead || wt.unpushedCommits || 0) : 0;
16058
+ const unmergedCommits = isMain ? 0 : Number(wt.unmergedCommits || 0);
16059
+ const ahead = isMain ? unpushedCommits : Number(wt.ahead || 0);
16060
+ const behind = isMain ? Number(mainRemote?.behind || wt.behind || 0) : Number(wt.behind || 0);
16061
+ const status = {
15941
16062
  branch,
15942
16063
  worktreeName: wt.worktreeName || path.basename(wt.path || session.worktree_path || session.cwd || branch),
15943
16064
  worktreePath: wt.path || session.worktree_path || session.cwd || null,
16065
+ isMain,
15944
16066
  state: wt.state || 'unknown',
15945
16067
  dirtyFiles,
16068
+ trackedDirtyFiles,
16069
+ untrackedFiles,
15946
16070
  unmergedCommits,
16071
+ unpushedCommits,
15947
16072
  ahead,
15948
16073
  behind,
15949
16074
  summary: wt.summary || '',
15950
- needsAttention: dirtyFiles > 0 || unmergedCommits > 0,
16075
+ needsAttention: trackedDirtyFiles > 0 || unmergedCommits > 0 || unpushedCommits > 0,
15951
16076
  };
16077
+ if (mainRemote) {
16078
+ status.mainRemote = {
16079
+ remote: mainRemote.remote || '',
16080
+ ahead: Number(mainRemote.ahead || 0),
16081
+ behind: Number(mainRemote.behind || 0),
16082
+ state: mainRemote.state || '',
16083
+ summary: mainRemote.summary || '',
16084
+ };
16085
+ if (!status.summary && mainRemote.summary) status.summary = mainRemote.summary;
16086
+ }
16087
+ return status;
15952
16088
  }
15953
16089
 
15954
16090
  function _sessionWorktreeStatusSnapshot(map) {
@@ -15957,12 +16093,17 @@ function _sessionWorktreeStatusSnapshot(map) {
15957
16093
  .map(([id, wt]) => [
15958
16094
  id,
15959
16095
  wt.branch,
16096
+ wt.isMain,
15960
16097
  wt.state,
15961
16098
  wt.dirtyFiles,
16099
+ wt.trackedDirtyFiles,
16100
+ wt.untrackedFiles,
15962
16101
  wt.unmergedCommits,
16102
+ wt.unpushedCommits,
15963
16103
  wt.ahead,
15964
16104
  wt.behind,
15965
16105
  wt.summary,
16106
+ wt.mainRemote?.state,
15966
16107
  wt.needsAttention,
15967
16108
  ]));
15968
16109
  }
@@ -16064,23 +16205,44 @@ function normalizeWalleClientModelSelection({ model, provider, session }) {
16064
16205
 
16065
16206
  function _applyWalleSessionModelPreference(session, pref) {
16066
16207
  if (!session || !pref || !pref.model_id) return false;
16067
- session.model_id = pref.model_id;
16068
- session.model_provider = pref.provider_type || session.model_provider || null;
16069
- session.model_registry_id = pref.registry_id || '';
16070
- session.model_provider_id = pref.provider_id || '';
16071
- session.model_pinned = pref.pinned !== false;
16208
+ if (session.type === 'walle') {
16209
+ session.model_id = pref.model_id;
16210
+ session.model_provider = pref.provider_type || session.model_provider || null;
16211
+ session.model_registry_id = pref.registry_id || '';
16212
+ session.model_provider_id = pref.provider_id || '';
16213
+ session.model_pinned = pref.pinned !== false;
16214
+ return true;
16215
+ }
16216
+ session.walle_model_id = pref.model_id;
16217
+ session.walle_model_provider = pref.provider_type || '';
16218
+ session.walle_model_registry_id = pref.registry_id || '';
16219
+ session.walle_model_provider_id = pref.provider_id || '';
16220
+ session.walle_model_pinned = pref.pinned !== false;
16072
16221
  return true;
16073
16222
  }
16074
16223
 
16224
+ function _isWalleModelConfigurableSession(session) {
16225
+ if (!session) return false;
16226
+ if (session.type === 'walle') return true;
16227
+ if (isWalleOwnedSession(session)) return true;
16228
+ try {
16229
+ const identity = session.id ? dbModule.getSessionIdentity?.(session.id) : null;
16230
+ if (identity?.ctm?.provider === 'walle') return true;
16231
+ if (identity?.primaryAgent?.provider === 'walle') return true;
16232
+ } catch {}
16233
+ return false;
16234
+ }
16235
+
16075
16236
  function _walleModelPayload(session, extra = {}) {
16237
+ const selection = _walleModelSelectionForActiveSession(session);
16076
16238
  return {
16077
16239
  type: 'walle-model',
16078
16240
  id: session.id,
16079
- model_id: session.model_id || null,
16080
- model_provider: session.model_provider || null,
16081
- model_registry_id: session.model_registry_id || '',
16082
- model_provider_id: session.model_provider_id || '',
16083
- model_pinned: !!session.model_pinned,
16241
+ model_id: selection.model_id || null,
16242
+ model_provider: selection.model_provider || null,
16243
+ model_registry_id: selection.model_registry_id || '',
16244
+ model_provider_id: selection.model_provider_id || '',
16245
+ model_pinned: !!selection.model_pinned,
16084
16246
  ...extra,
16085
16247
  };
16086
16248
  }
@@ -16094,7 +16256,7 @@ function _broadcastWalleModel(session, extra = {}) {
16094
16256
  }
16095
16257
 
16096
16258
  function _hydrateWalleSessionModelPreference(session, { broadcast = false } = {}) {
16097
- if (!session || session.type !== 'walle') return null;
16259
+ if (!_isWalleModelConfigurableSession(session)) return null;
16098
16260
  let pref = null;
16099
16261
  try {
16100
16262
  pref = dbModule.getSessionModelPreference?.(session.id) || null;
@@ -16108,17 +16270,26 @@ function _hydrateWalleSessionModelPreference(session, { broadcast = false } = {}
16108
16270
  }
16109
16271
 
16110
16272
  function _persistWalleSessionModelPreference(session, msg = {}) {
16111
- if (!session || session.type !== 'walle') return null;
16273
+ if (!_isWalleModelConfigurableSession(session)) return null;
16274
+ const nativeWalleSession = session.type === 'walle';
16112
16275
  const rawModel = msg.model_id || msg.model || '';
16113
16276
  const rawProvider = msg.model_provider || msg.provider || '';
16114
16277
  if (!rawModel) {
16115
16278
  try { dbModule.clearSessionModelPreference?.(session.id); } catch (e) { console.error('[walle-session] model preference clear error:', e.message); }
16116
- session.model_id = null;
16117
- session.model_provider = null;
16118
- session.model_registry_id = '';
16119
- session.model_provider_id = '';
16120
- session.model_pinned = false;
16121
- try { dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, null, 'walle', session.chatSessionId); } catch (e) { console.error('[ctm] addStartupTask (walle model clear) error:', e.message); }
16279
+ if (nativeWalleSession) {
16280
+ session.model_id = null;
16281
+ session.model_provider = null;
16282
+ session.model_registry_id = '';
16283
+ session.model_provider_id = '';
16284
+ session.model_pinned = false;
16285
+ try { dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, null, 'walle', session.chatSessionId); } catch (e) { console.error('[ctm] addStartupTask (walle model clear) error:', e.message); }
16286
+ } else {
16287
+ session.walle_model_id = null;
16288
+ session.walle_model_provider = null;
16289
+ session.walle_model_registry_id = '';
16290
+ session.walle_model_provider_id = '';
16291
+ session.walle_model_pinned = false;
16292
+ }
16122
16293
  _broadcastWalleModel(session, { model_source: 'session-preference-cleared' });
16123
16294
  broadcastSessionList(true);
16124
16295
  return null;
@@ -16168,30 +16339,32 @@ function _persistWalleSessionModelPreference(session, msg = {}) {
16168
16339
  }
16169
16340
 
16170
16341
  _applyWalleSessionModelPreference(session, pref);
16171
- try {
16172
- dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, session.model_id, 'walle', session.chatSessionId);
16173
- } catch (e) {
16174
- console.error('[ctm] addStartupTask (walle model preference) error:', e.message);
16175
- }
16176
- try {
16177
- dbModule.upsertSession(session.id, {
16178
- agentSessionId: session.chatSessionId,
16179
- provider: 'walle',
16180
- cwd: session.cwd || '',
16181
- projectPath: session.cwd || '',
16182
- title: session.label || '',
16183
- jsonlPath: session.jsonlPath || '',
16184
- model: session.model_id || '',
16185
- hostname: HOSTNAME,
16186
- });
16187
- } catch (e) {
16188
- console.error('[walle-session] model preference index update error:', e.message);
16342
+ if (nativeWalleSession) {
16343
+ try {
16344
+ dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, session.model_id, 'walle', session.chatSessionId);
16345
+ } catch (e) {
16346
+ console.error('[ctm] addStartupTask (walle model preference) error:', e.message);
16347
+ }
16348
+ try {
16349
+ dbModule.upsertSession(session.id, {
16350
+ agentSessionId: session.chatSessionId,
16351
+ provider: 'walle',
16352
+ cwd: session.cwd || '',
16353
+ projectPath: session.cwd || '',
16354
+ title: session.label || '',
16355
+ jsonlPath: session.jsonlPath || '',
16356
+ model: session.model_id || '',
16357
+ hostname: HOSTNAME,
16358
+ });
16359
+ } catch (e) {
16360
+ console.error('[walle-session] model preference index update error:', e.message);
16361
+ }
16189
16362
  }
16190
16363
  try {
16191
16364
  telemetry.track('walle_session_model_changed', {
16192
16365
  session_id: String(session.id || '').slice(0, 8),
16193
- provider_type: session.model_provider || '',
16194
- model_id: session.model_id || '',
16366
+ provider_type: nativeWalleSession ? (session.model_provider || '') : (session.walle_model_provider || ''),
16367
+ model_id: nativeWalleSession ? (session.model_id || '') : (session.walle_model_id || ''),
16195
16368
  source: msg.source || 'user',
16196
16369
  });
16197
16370
  } catch {}
@@ -16716,15 +16889,36 @@ function _sessionActivityIso(value, now = Date.now(), session = null, field = 'a
16716
16889
 
16717
16890
  function _walleModelSelectionForActiveSession(session) {
16718
16891
  const defaults = resolveWalleDefaultModelSelection({ brain: getWalleBrain(), env: process.env });
16892
+ let pref = null;
16893
+ try {
16894
+ pref = session?.id ? dbModule.getSessionModelPreference?.(session.id) || null : null;
16895
+ } catch (e) {
16896
+ console.error('[walle-session] model preference read error:', e.message);
16897
+ }
16898
+ if (pref?.model_id) {
16899
+ return {
16900
+ model_id: pref.model_id || '',
16901
+ model_provider: pref.provider_type || '',
16902
+ model_registry_id: pref.registry_id || '',
16903
+ model_provider_id: pref.provider_id || '',
16904
+ model_pinned: pref.pinned !== false,
16905
+ };
16906
+ }
16719
16907
  if (session?.type === 'walle') {
16720
16908
  return {
16721
16909
  model_id: session.model_id || defaults.model_id || '',
16722
16910
  model_provider: session.model_provider || defaults.model_provider || '',
16911
+ model_registry_id: session.model_registry_id || '',
16912
+ model_provider_id: session.model_provider_id || '',
16913
+ model_pinned: !!session.model_pinned,
16723
16914
  };
16724
16915
  }
16725
16916
  return {
16726
- model_id: defaults.model_id || session?.model_id || '',
16727
- model_provider: defaults.model_provider || session?.model_provider || '',
16917
+ model_id: session?.walle_model_id || defaults.model_id || session?.model_id || '',
16918
+ model_provider: session?.walle_model_provider || defaults.model_provider || session?.model_provider || '',
16919
+ model_registry_id: session?.walle_model_registry_id || '',
16920
+ model_provider_id: session?.walle_model_provider_id || '',
16921
+ model_pinned: !!session?.walle_model_pinned,
16728
16922
  };
16729
16923
  }
16730
16924
 
@@ -16757,9 +16951,29 @@ function _sessionPayload(s) {
16757
16951
  worktreeStatus,
16758
16952
  });
16759
16953
  const walleModel = walleOwned ? _walleModelSelectionForActiveSession(s) : null;
16954
+ const nativeWalleSession = s.type === 'walle';
16955
+ const walleMode = String(s.agentMode || '').trim().toLowerCase();
16956
+ const walleKind = String(s.agentKind || '').trim().toLowerCase();
16957
+ const walleTaskType = String(s.taskType || '').trim().toLowerCase();
16958
+ const walleChatSession = nativeWalleSession && (
16959
+ walleMode === 'chat'
16960
+ || walleTaskType === 'chat'
16961
+ || /wall-?e-chat|walle-chat/.test(walleKind)
16962
+ );
16760
16963
  return {
16761
16964
  id: s.id,
16762
16965
  type: s.type || 'pty',
16966
+ session_type: s.type || 'pty',
16967
+ runtime_type: s.type || 'pty',
16968
+ walle_owned: !!walleOwned,
16969
+ walleOwned: !!walleOwned,
16970
+ native_walle_session: nativeWalleSession,
16971
+ nativeWalleSession,
16972
+ walle_chat_session: walleChatSession,
16973
+ walleChatSession,
16974
+ agentMode: s.agentMode || null,
16975
+ agentKind: s.agentKind || (walleOwned && !nativeWalleSession ? 'walle-coding' : null),
16976
+ taskType: s.taskType || (walleOwned && !nativeWalleSession ? 'coding' : null),
16763
16977
  label: s.label,
16764
16978
  cmd: s.cmd,
16765
16979
  cwd: s.cwd,
@@ -16777,9 +16991,9 @@ function _sessionPayload(s) {
16777
16991
  fileSize: fileInfo.fileSize || 0,
16778
16992
  model_id: walleModel?.model_id || s.model_id,
16779
16993
  model_provider: walleModel?.model_provider || s.model_provider,
16780
- model_registry_id: s.model_registry_id || '',
16781
- model_provider_id: s.model_provider_id || '',
16782
- model_pinned: !!s.model_pinned,
16994
+ model_registry_id: walleModel?.model_registry_id || s.model_registry_id || '',
16995
+ model_provider_id: walleModel?.model_provider_id || s.model_provider_id || '',
16996
+ model_pinned: walleModel?.model_pinned != null ? !!walleModel.model_pinned : !!s.model_pinned,
16783
16997
  walle_model_id: walleModel?.model_id || null,
16784
16998
  walle_model_provider: walleModel?.model_provider || null,
16785
16999
  runtime_model_id: walleOwned ? (s.model_id || null) : null,
@@ -17660,6 +17874,10 @@ function apiSyncWorktree(req, res) {
17660
17874
  res.writeHead(409, WORKTREE_JSON_HEADERS);
17661
17875
  return res.end(JSON.stringify(result));
17662
17876
  }
17877
+ if (result.blocked) {
17878
+ res.writeHead(409, WORKTREE_JSON_HEADERS);
17879
+ return res.end(JSON.stringify(result));
17880
+ }
17663
17881
  res.writeHead(result.merged ? 200 : 500, WORKTREE_JSON_HEADERS);
17664
17882
  res.end(JSON.stringify(result));
17665
17883
  }).catch(e => {
@@ -17829,18 +18047,31 @@ function apiCreatePR(req, res) {
17829
18047
  function apiRestartCtm(req, res) {
17830
18048
  const reqUrl = new URL(req.url, 'http://localhost');
17831
18049
  const force = reqUrl.searchParams.get('force') === 'true';
17832
- const activeCount = sessions.size;
17833
- if (activeCount > 0 && !force) {
18050
+ const now = Date.now();
18051
+ const guard = summarizeRestartGuard(sessions.values(), {
18052
+ statusForSession: (session) => _standupLiveStatusForSession(session, now),
18053
+ isWaitingForInput: (session) => _isServerWaitingForInput(session.id, session, now),
18054
+ });
18055
+ if (guard.blockingCount > 0 && !force) {
18056
+ const noun = guard.blockingCount === 1 ? 'session' : 'sessions';
17834
18057
  res.writeHead(409, { 'Content-Type': 'application/json' });
17835
18058
  return res.end(JSON.stringify({
17836
18059
  ok: false,
17837
- error: `Blocked: ${activeCount} active session(s) would be killed. Use ?force=true to override, or use the dev instance (bash bin/dev.sh) for testing.`,
17838
- active_sessions: activeCount
18060
+ error: `Blocked: ${guard.blockingCount} running/waiting ${noun} would be interrupted. Use ?force=true to override, or wait for the blocking work to settle.`,
18061
+ active_sessions: guard.blockingCount,
18062
+ blocking_sessions: guard.blockers.slice(0, 10),
18063
+ restorable_sessions: guard.restorableCount,
18064
+ total_sessions: guard.totalCount,
17839
18065
  }));
17840
18066
  }
17841
18067
 
17842
18068
  res.writeHead(200, { 'Content-Type': 'application/json' });
17843
- res.end(JSON.stringify({ ok: true, message: 'CTM server restarting...' }));
18069
+ res.end(JSON.stringify({
18070
+ ok: true,
18071
+ message: 'CTM server restarting...',
18072
+ restorable_sessions: guard.restorableCount,
18073
+ total_sessions: guard.totalCount,
18074
+ }));
17844
18075
 
17845
18076
  // No bash watcher — launchd KeepAlive handles restart on non-zero exit.
17846
18077
  // (Detached bash kill-0 loops trigger Cortex XDR behavioral threat detection.)
@@ -18174,10 +18405,49 @@ function getCurrentVersion() {
18174
18405
  return readPackageVersion(path.join(__dirname, '..', 'package.json'));
18175
18406
  }
18176
18407
 
18408
+ function getAppBuildIdentity() {
18409
+ const root = path.join(__dirname, '..');
18410
+ const files = [
18411
+ 'package.json',
18412
+ 'claude-task-manager/package.json',
18413
+ 'claude-task-manager/server.js',
18414
+ 'claude-task-manager/public/index.html',
18415
+ 'claude-task-manager/public/m/index.html',
18416
+ 'claude-task-manager/public/m/app.css',
18417
+ 'claude-task-manager/public/m/app.js',
18418
+ 'claude-task-manager/public/m/sw.js',
18419
+ 'wall-e/package.json',
18420
+ 'create-walle/package.json',
18421
+ ];
18422
+ const hash = crypto.createHash('sha256');
18423
+ const fileState = [];
18424
+ for (const rel of files) {
18425
+ const full = path.join(root, rel);
18426
+ try {
18427
+ const stat = fs.statSync(full);
18428
+ const entry = `${rel}:${stat.size}:${Math.floor(stat.mtimeMs)}`;
18429
+ fileState.push(entry);
18430
+ hash.update(entry);
18431
+ hash.update('\n');
18432
+ } catch {
18433
+ const entry = `${rel}:missing`;
18434
+ fileState.push(entry);
18435
+ hash.update(entry);
18436
+ hash.update('\n');
18437
+ }
18438
+ }
18439
+ return {
18440
+ buildId: hash.digest('hex').slice(0, 16),
18441
+ files: fileState,
18442
+ };
18443
+ }
18444
+
18177
18445
  function getAppVersionInfo() {
18446
+ const build = getAppBuildIdentity();
18178
18447
  return {
18179
18448
  version: getCurrentVersion(),
18180
18449
  product: 'create-walle',
18450
+ buildId: build.buildId,
18181
18451
  components: {
18182
18452
  ctm: readPackageVersion(path.join(__dirname, 'package.json')),
18183
18453
  wallE: readPackageVersion(path.join(__dirname, '..', 'wall-e', 'package.json')),