create-walle 0.9.19 → 0.9.21

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 (31) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/db.js +131 -0
  4. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +58 -50
  5. package/template/claude-task-manager/docs/phone-access-design.md +23 -7
  6. package/template/claude-task-manager/docs/walle-session-model-preferences.md +119 -0
  7. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +32 -48
  8. package/template/claude-task-manager/lib/remote-relay-protocol.js +5 -0
  9. package/template/claude-task-manager/lib/walle-external-actions.js +20 -3
  10. package/template/claude-task-manager/public/index.html +25 -0
  11. package/template/claude-task-manager/public/js/setup.js +16 -12
  12. package/template/claude-task-manager/public/js/walle-session.js +31 -3
  13. package/template/claude-task-manager/public/js/walle.js +93 -23
  14. package/template/claude-task-manager/public/m/app.css +417 -21
  15. package/template/claude-task-manager/public/m/app.js +831 -44
  16. package/template/claude-task-manager/public/m/claim.html +1 -1
  17. package/template/claude-task-manager/public/m/index.html +41 -7
  18. package/template/claude-task-manager/public/m/sw.js +1 -1
  19. package/template/claude-task-manager/server.js +377 -30
  20. package/template/claude-task-manager/workers/state-detectors/codex.js +18 -3
  21. package/template/package.json +1 -1
  22. package/template/wall-e/chat.js +32 -2
  23. package/template/wall-e/coding/stream-processor.js +36 -0
  24. package/template/wall-e/coding-orchestrator.js +45 -0
  25. package/template/wall-e/deploy.sh +1 -1
  26. package/template/wall-e/docs/external-action-controller.md +60 -2
  27. package/template/wall-e/external-action-controller.js +23 -1
  28. package/template/wall-e/external-action-gateway.js +163 -0
  29. package/template/wall-e/fly.toml +1 -0
  30. package/template/wall-e/tools/local-tools.js +122 -4
  31. package/template/website/index.html +2 -2
@@ -221,6 +221,7 @@ const {
221
221
  buildPendingExternalActionNotExecutedReply,
222
222
  extractLatestPendingExternalActions,
223
223
  externalActionCompletionClaim,
224
+ hasBlockedExternalActionGatewayToolResult,
224
225
  hasExecutedExternalActionToolResult,
225
226
  } = require('./lib/walle-external-actions');
226
227
  const { persistWalleSessionConversation } = require('./lib/walle-session-cache');
@@ -506,6 +507,15 @@ function _isBusyStatusPtyChunk(session, data) {
506
507
  return !!detector.isBusyStatusChunk(filtered);
507
508
  }
508
509
 
510
+ function _hasBufferedBusyStatusPtyEvidence(session, data) {
511
+ const providerId = session?._providerId || _providerIdFromCmd(session?.cmd || '');
512
+ const detector = getStateDetector(providerId);
513
+ if (typeof detector.textHasCodexBusyStatus !== 'function') return false;
514
+ const buffer = String(session?._busyStatusEvidenceBuffer || '') + String(data || '');
515
+ if (session) session._busyStatusEvidenceBuffer = buffer.slice(-512);
516
+ return !!detector.textHasCodexBusyStatus(buffer);
517
+ }
518
+
509
519
  // Tab activation/resize can trigger delayed TUI repaint frames. Those frames
510
520
  // should redraw the terminal, but they must not manufacture fresh activity.
511
521
  const CTM_UI_REFRESH_ACTIVITY_SUPPRESS_MS = 7000;
@@ -2747,6 +2757,7 @@ function _sendRemoteJson(res, status, payload) {
2747
2757
  }
2748
2758
 
2749
2759
  const REMOTE_TERMINAL_SUBMIT_DELAY_MS = 100;
2760
+ const REMOTE_TERMINAL_BUSY_STATUS_TTL_MS = 90 * 1000;
2750
2761
 
2751
2762
  function _remoteTerminalSubmissionText(text) {
2752
2763
  const normalized = String(text || '').replace(/\r\n?/g, '\n');
@@ -2754,32 +2765,120 @@ function _remoteTerminalSubmissionText(text) {
2754
2765
  }
2755
2766
 
2756
2767
  async function _sendRemoteTerminalSubmission(req, sessionId, text) {
2768
+ const initialSession = sessions.get(sessionId);
2769
+ const blocker = _remoteTerminalSubmissionBlocker(sessionId, initialSession);
2770
+ if (blocker) {
2771
+ recordSessionDiagnostic(sessionId, 'remote-terminal-submit-blocked', blocker);
2772
+ try {
2773
+ telemetry.track('remote_terminal_submit_blocked', {
2774
+ session_id: String(sessionId || '').slice(0, 8),
2775
+ error: blocker.error || 'blocked',
2776
+ reason: blocker.reason || '',
2777
+ });
2778
+ } catch {}
2779
+ return blocker;
2780
+ }
2781
+ initialSession._remoteTerminalSubmissionInFlight = true;
2757
2782
  const data = _remoteTerminalSubmissionText(text);
2758
- handleInput({ auth: req.ctmAuth }, { id: sessionId, data });
2759
- recordSessionDiagnostic(sessionId, 'remote-terminal-submit-text', {
2760
- chars: data.length,
2761
- enterDelayMs: REMOTE_TERMINAL_SUBMIT_DELAY_MS,
2762
- });
2763
- await new Promise((resolve) => setTimeout(resolve, REMOTE_TERMINAL_SUBMIT_DELAY_MS));
2764
- const session = sessions.get(sessionId);
2783
+ try {
2784
+ handleInput({ auth: req.ctmAuth }, { id: sessionId, data });
2785
+ recordSessionDiagnostic(sessionId, 'remote-terminal-submit-text', {
2786
+ chars: data.length,
2787
+ enterDelayMs: REMOTE_TERMINAL_SUBMIT_DELAY_MS,
2788
+ });
2789
+ await new Promise((resolve) => setTimeout(resolve, REMOTE_TERMINAL_SUBMIT_DELAY_MS));
2790
+ const session = sessions.get(sessionId);
2791
+ if (!_isLiveTerminalSession(session)) {
2792
+ recordSessionDiagnostic(sessionId, 'remote-terminal-submit-enter-skipped', { reason: 'session_not_live' });
2793
+ return {
2794
+ ok: false,
2795
+ error: 'session_not_live',
2796
+ message: 'The text reached the terminal, but the session stopped before CTM could press Enter.',
2797
+ };
2798
+ }
2799
+ // Keep terminal submit as its own input frame. Codex/Claude-style TUIs run
2800
+ // real line editors; CTM's local queue/review flows already use this same
2801
+ // text-then-Enter contract so the editor can first commit the pasted text.
2802
+ handleInput({ auth: req.ctmAuth }, { id: sessionId, data: '\r' });
2803
+ recordSessionDiagnostic(sessionId, 'remote-terminal-submit-enter', { enterDelayMs: REMOTE_TERMINAL_SUBMIT_DELAY_MS });
2804
+ return { ok: true, delivered: true, submitted: true };
2805
+ } finally {
2806
+ const latest = sessions.get(sessionId);
2807
+ if (latest) latest._remoteTerminalSubmissionInFlight = false;
2808
+ }
2809
+ }
2810
+
2811
+ function _isLiveTerminalSession(session) {
2812
+ return !!(session && session.ptyProcess && session.status !== 'exited' && session.status !== 'closed');
2813
+ }
2814
+
2815
+ function _remoteTerminalAgentType(session) {
2816
+ if (!session || session.type === 'walle') return '';
2817
+ return normalizeAgentType(session.agentType || session._providerId || '') ||
2818
+ detectAgentType(session.cmd || '') ||
2819
+ _providerIdFromCmd(session.cmd || '') ||
2820
+ 'shell';
2821
+ }
2822
+
2823
+ function _remoteTerminalRequiresReadyPrompt(session) {
2824
+ return new Set(['claude', 'claude-code', 'codex', 'opencode', 'gemini', 'gemini-cli']).has(_remoteTerminalAgentType(session));
2825
+ }
2826
+
2827
+ function _remoteTerminalSubmissionBlocker(sessionId, session, now = Date.now()) {
2765
2828
  if (!_isLiveTerminalSession(session)) {
2766
- recordSessionDiagnostic(sessionId, 'remote-terminal-submit-enter-skipped', { reason: 'session_not_live' });
2767
2829
  return {
2768
2830
  ok: false,
2769
2831
  error: 'session_not_live',
2770
- message: 'The text reached the terminal, but the session stopped before CTM could press Enter.',
2832
+ message: 'This session is not accepting terminal input.',
2771
2833
  };
2772
2834
  }
2773
- // Keep terminal submit as its own input frame. Codex/Claude-style TUIs run
2774
- // real line editors; CTM's local queue/review flows already use this same
2775
- // text-then-Enter contract so the editor can first commit the pasted text.
2776
- handleInput({ auth: req.ctmAuth }, { id: sessionId, data: '\r' });
2777
- recordSessionDiagnostic(sessionId, 'remote-terminal-submit-enter', { enterDelayMs: REMOTE_TERMINAL_SUBMIT_DELAY_MS });
2778
- return { ok: true, delivered: true, submitted: true };
2779
- }
2835
+ if (session._remoteTerminalSubmissionInFlight) {
2836
+ return {
2837
+ ok: false,
2838
+ error: 'session_input_in_flight',
2839
+ message: 'Another phone reply is already being delivered to this session.',
2840
+ };
2841
+ }
2842
+ if (!_remoteTerminalRequiresReadyPrompt(session)) return null;
2780
2843
 
2781
- function _isLiveTerminalSession(session) {
2782
- return !!(session && session.ptyProcess && session.status !== 'exited' && session.status !== 'closed');
2844
+ const waiting = _isServerWaitingForInput(sessionId, session, now);
2845
+ const waitingReason = _standupWaitingReason(sessionId, session);
2846
+ if (waiting && _standupIsBlockingWaitingReason(waitingReason)) {
2847
+ return {
2848
+ ok: false,
2849
+ error: 'session_waiting_for_approval',
2850
+ reason: waitingReason,
2851
+ message: 'This session is waiting for an approval or choice. Respond to that prompt before sending a new message.',
2852
+ };
2853
+ }
2854
+ if (waiting) return null;
2855
+
2856
+ let busState = '';
2857
+ try {
2858
+ busState = statusHooks._bus && typeof statusHooks._bus.getState === 'function'
2859
+ ? statusHooks._bus.getState(sessionId)
2860
+ : '';
2861
+ } catch {}
2862
+ if (busState === 'busy') {
2863
+ return {
2864
+ ok: false,
2865
+ error: 'session_busy',
2866
+ reason: 'status_bus_busy',
2867
+ message: 'The agent is still working. CTM kept this phone reply queued instead of typing into a busy terminal.',
2868
+ };
2869
+ }
2870
+
2871
+ const busyAt = Number(session._lastBusyStatusAt || 0);
2872
+ if (busyAt && (now - busyAt) < REMOTE_TERMINAL_BUSY_STATUS_TTL_MS) {
2873
+ return {
2874
+ ok: false,
2875
+ error: 'session_busy',
2876
+ reason: 'terminal_busy_status',
2877
+ busyAgeMs: now - busyAt,
2878
+ message: 'The terminal still shows the agent working. CTM kept this phone reply queued instead of typing into a busy terminal.',
2879
+ };
2880
+ }
2881
+ return null;
2783
2882
  }
2784
2883
 
2785
2884
  function _remoteHostedPairingReady(status) {
@@ -2999,15 +3098,46 @@ async function handleRemoteApi(req, res, url) {
2999
3098
  if (session.type !== 'walle') return { ok: false, error: 'not_walle_session' };
3000
3099
  if (!text.trim()) return { ok: false, error: 'message_required' };
3001
3100
  const attachments = Array.isArray(body.attachments) ? body.attachments : [];
3101
+ const explicitModel = body.model_id || body.model || '';
3102
+ const explicitProvider = body.model_provider || body.provider || '';
3002
3103
  await handleWalleMessage({ auth: req.ctmAuth }, {
3003
3104
  id: sessionId,
3004
3105
  text,
3005
3106
  attachments,
3006
3107
  echoUser: true,
3007
3108
  displayText: text,
3109
+ model: explicitModel || session.model_id || '',
3110
+ provider: explicitProvider || session.model_provider || '',
3111
+ model_registry_id: body.model_registry_id || body.modelRegistryId || session.model_registry_id || '',
3112
+ modelPinned: body.modelPinned === true || body.model_pinned === true || !!explicitModel || !!session.model_pinned,
3113
+ allowProviderFallback: explicitModel ? false : !session.model_pinned,
3008
3114
  });
3009
3115
  return { ok: true, delivered: true };
3010
3116
  },
3117
+ setWalleModel: async (sessionId, body = {}) => {
3118
+ const session = sessions.get(sessionId);
3119
+ if (!session) return { ok: false, error: 'session_not_found' };
3120
+ if (session.type !== 'walle') return { ok: false, error: 'not_walle_session' };
3121
+ const pref = _persistWalleSessionModelPreference(session, {
3122
+ model_id: body.model_id || body.model || '',
3123
+ model_provider: body.model_provider || body.provider || '',
3124
+ model_registry_id: body.model_registry_id || body.modelRegistryId || '',
3125
+ model_provider_id: body.model_provider_id || body.provider_id || '',
3126
+ scope: body.scope || 'session',
3127
+ source: 'phone',
3128
+ pinned: body.pinned !== false,
3129
+ });
3130
+ return {
3131
+ ok: true,
3132
+ 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,
3138
+ cleared: !pref,
3139
+ };
3140
+ },
3011
3141
  },
3012
3142
  });
3013
3143
  _sendRemoteJson(res, result.ok ? 200 : 400, result);
@@ -11846,6 +11976,24 @@ if (ws.readyState === 1) ws.send(JSON.stringify(_streamEventWsPayload(_ctmIdForS
11846
11976
  if (msg.id && msg.model_id) {
11847
11977
  const session = sessions.get(msg.id);
11848
11978
  if (session) {
11979
+ if (session.type === 'walle') {
11980
+ try {
11981
+ _persistWalleSessionModelPreference(session, {
11982
+ ...msg,
11983
+ source: msg.source || 'user',
11984
+ pinned: msg.pinned !== false,
11985
+ });
11986
+ } catch (e) {
11987
+ if (ws.readyState === 1) {
11988
+ ws.send(JSON.stringify({
11989
+ type: 'toast',
11990
+ level: 'error',
11991
+ message: `Could not save Wall-E model preference: ${e.message}`,
11992
+ }));
11993
+ }
11994
+ }
11995
+ return;
11996
+ }
11849
11997
  session.model_id = msg.model_id;
11850
11998
  session.model_provider = msg.model_provider || null;
11851
11999
  // Persist so preference survives restart
@@ -11999,8 +12147,8 @@ async function dispatchQueuedWalleMessage(sessionId, text, options = {}) {
11999
12147
  source: 'queue',
12000
12148
  model: session.model_id || '',
12001
12149
  provider: session.model_provider || '',
12002
- modelPinned: false,
12003
- allowProviderFallback: true,
12150
+ modelPinned: !!session.model_pinned,
12151
+ allowProviderFallback: !session.model_pinned,
12004
12152
  });
12005
12153
  }
12006
12154
 
@@ -12554,6 +12702,8 @@ function checkIdleNotify(sessionId, session, data) {
12554
12702
  session._waitingForInputAt = waitingAt;
12555
12703
  session._lastNonResolvingWaitingInputAt = 0;
12556
12704
  session._lastWaitingResolveInputAt = 0;
12705
+ session._lastBusyStatusAt = 0;
12706
+ session._lastBusyStatusSource = reason ? `waiting:${reason}` : 'waiting';
12557
12707
  _markTerminalLifecycleChanged(session, `waiting-for-input:${reason}`);
12558
12708
  }
12559
12709
  // Provider hooks are useful, but a visible prompt is stronger evidence
@@ -13197,15 +13347,32 @@ function handleCreate(ws, msg) {
13197
13347
  if (agentType === 'walle') {
13198
13348
  // Override auto-generated shell label if user didn't provide one
13199
13349
  if (!msg.label) label = 'Wall-E session';
13350
+ let model_registry_id = msg.model_registry_id || msg.modelRegistryId || '';
13351
+ let model_provider_id = msg.model_provider_id || msg.provider_id || '';
13352
+ let model_pinned = false;
13353
+ try {
13354
+ const pref = dbModule.getSessionModelPreference?.(id);
13355
+ if (pref?.model_id) {
13356
+ model_id = pref.model_id;
13357
+ model_provider = pref.provider_type || model_provider || null;
13358
+ model_registry_id = pref.registry_id || model_registry_id || '';
13359
+ model_provider_id = pref.provider_id || model_provider_id || '';
13360
+ model_pinned = pref.pinned !== false;
13361
+ }
13362
+ } catch (e) {
13363
+ console.error('[walle-session] model preference restore error:', e.message);
13364
+ }
13200
13365
  if (!model_id) {
13201
13366
  const defaults = resolveWalleDefaultModelSelection({ brain: getWalleBrain(), env: process.env });
13202
13367
  model_id = defaults.model_id || null;
13203
13368
  model_provider = defaults.model_provider || null;
13369
+ model_pinned = false;
13204
13370
  }
13205
13371
  const agentMode = msg.agentMode || msg.agent_mode || 'coding';
13206
13372
  const agentKind = msg.agentKind || msg.agent_kind || (agentMode === 'coding' ? 'walle-coding' : 'walle-chat');
13207
13373
  const taskType = msg.taskType || msg.task_type || (agentMode === 'coding' ? 'coding' : 'chat');
13208
13374
  const chatSessionId = msg.chatSessionId || `walle-${id}`;
13375
+ const initialMessage = String(msg.initialMessage || msg.initial_message || '').trim();
13209
13376
  const jsonlPath = _walleTranscriptPathForSession(id, chatSessionId);
13210
13377
  const createResult = walleTranscript.createSession(jsonlPath, {
13211
13378
  reset: !msg._isRestore,
@@ -13231,6 +13398,9 @@ function handleCreate(ws, msg) {
13231
13398
  chatSessionId, jsonlPath,
13232
13399
  abortController: null,
13233
13400
  model_id: model_id || null, model_provider: model_provider || null,
13401
+ model_registry_id: model_registry_id || '',
13402
+ model_provider_id: model_provider_id || '',
13403
+ model_pinned,
13234
13404
  _walleLastTranscriptUuid: createResult?.uuid || walleTranscript.readLastUuid(jsonlPath) || null,
13235
13405
  };
13236
13406
  sessions.set(id, session);
@@ -13269,9 +13439,35 @@ function handleCreate(ws, msg) {
13269
13439
  sessionType: 'walle',
13270
13440
  model_id: session.model_id || null,
13271
13441
  model_provider: session.model_provider || null,
13442
+ model_registry_id: session.model_registry_id || '',
13443
+ model_provider_id: session.model_provider_id || '',
13444
+ model_pinned: !!session.model_pinned,
13272
13445
  }));
13273
13446
  }
13274
13447
  broadcastSessionList(true);
13448
+ if (initialMessage) {
13449
+ setImmediate(() => {
13450
+ handleWalleMessage(ws, {
13451
+ type: 'walle-message',
13452
+ id,
13453
+ text: initialMessage,
13454
+ echoUser: true,
13455
+ source: 'new-session',
13456
+ agentMode,
13457
+ agentKind,
13458
+ taskType,
13459
+ }).catch((e) => {
13460
+ console.error('[walle-session] initial prompt failed:', e.message);
13461
+ if (ws && ws.readyState === 1) {
13462
+ ws.send(JSON.stringify({
13463
+ type: 'walle-error',
13464
+ id,
13465
+ error: e.message || 'Wall-E initial prompt failed',
13466
+ }));
13467
+ }
13468
+ });
13469
+ });
13470
+ }
13275
13471
  return;
13276
13472
  }
13277
13473
 
@@ -14245,8 +14441,13 @@ function handleCreate(ws, msg) {
14245
14441
  const activeChunk = _isActivePtyChunk(session, data);
14246
14442
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
14247
14443
  const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
14444
+ const busyStatusEvidenceChunk = busyStatusChunk || _hasBufferedBusyStatusPtyEvidence(session, data);
14248
14445
  const waitingForInput = _isServerWaitingForInput(id, session);
14249
14446
  const suppressedUiRefreshChunk = _suppressCtmUiRefreshActivity(session, statusOnlyChunk);
14447
+ if (busyStatusEvidenceChunk) {
14448
+ session._lastBusyStatusAt = Date.now();
14449
+ session._lastBusyStatusSource = suppressedUiRefreshChunk ? 'pty-ui-refresh' : 'pty';
14450
+ }
14250
14451
  const activityChunk = activeChunk && !suppressedUiRefreshChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
14251
14452
  if (busyStatusChunk && waitingForInput && !suppressedUiRefreshChunk) _markServerSessionResumedFromPty(id, session, 'codex-working-status');
14252
14453
  if (activityChunk) {
@@ -14968,8 +15169,13 @@ function apiAttachSession(req, res) {
14968
15169
  const activeChunk = _isActivePtyChunk(session, data);
14969
15170
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
14970
15171
  const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
15172
+ const busyStatusEvidenceChunk = busyStatusChunk || _hasBufferedBusyStatusPtyEvidence(session, data);
14971
15173
  const waitingForInput = _isServerWaitingForInput(tabId, session);
14972
15174
  const suppressedUiRefreshChunk = _suppressCtmUiRefreshActivity(session, statusOnlyChunk);
15175
+ if (busyStatusEvidenceChunk) {
15176
+ session._lastBusyStatusAt = Date.now();
15177
+ session._lastBusyStatusSource = suppressedUiRefreshChunk ? 'attached-pty-ui-refresh' : 'attached-pty';
15178
+ }
14973
15179
  const activityChunk = activeChunk && !suppressedUiRefreshChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
14974
15180
  if (busyStatusChunk && waitingForInput && !suppressedUiRefreshChunk) _markServerSessionResumedFromPty(tabId, session, 'codex-working-status');
14975
15181
  if (activityChunk) {
@@ -15856,6 +16062,144 @@ function normalizeWalleClientModelSelection({ model, provider, session }) {
15856
16062
  return { model: selectedModel, provider: selectedProvider, note };
15857
16063
  }
15858
16064
 
16065
+ function _applyWalleSessionModelPreference(session, pref) {
16066
+ 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;
16072
+ return true;
16073
+ }
16074
+
16075
+ function _walleModelPayload(session, extra = {}) {
16076
+ return {
16077
+ type: 'walle-model',
16078
+ 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,
16084
+ ...extra,
16085
+ };
16086
+ }
16087
+
16088
+ function _broadcastWalleModel(session, extra = {}) {
16089
+ if (!session || !session.clients) return;
16090
+ const payload = JSON.stringify(_walleModelPayload(session, extra));
16091
+ for (const client of session.clients) {
16092
+ if (client.readyState === 1) client.send(payload);
16093
+ }
16094
+ }
16095
+
16096
+ function _hydrateWalleSessionModelPreference(session, { broadcast = false } = {}) {
16097
+ if (!session || session.type !== 'walle') return null;
16098
+ let pref = null;
16099
+ try {
16100
+ pref = dbModule.getSessionModelPreference?.(session.id) || null;
16101
+ } catch (e) {
16102
+ console.error('[walle-session] model preference read error:', e.message);
16103
+ return null;
16104
+ }
16105
+ if (!_applyWalleSessionModelPreference(session, pref)) return null;
16106
+ if (broadcast) _broadcastWalleModel(session, { model_source: 'session-preference' });
16107
+ return pref;
16108
+ }
16109
+
16110
+ function _persistWalleSessionModelPreference(session, msg = {}) {
16111
+ if (!session || session.type !== 'walle') return null;
16112
+ const rawModel = msg.model_id || msg.model || '';
16113
+ const rawProvider = msg.model_provider || msg.provider || '';
16114
+ if (!rawModel) {
16115
+ 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); }
16122
+ _broadcastWalleModel(session, { model_source: 'session-preference-cleared' });
16123
+ broadcastSessionList(true);
16124
+ return null;
16125
+ }
16126
+
16127
+ const selection = normalizeWalleClientModelSelection({
16128
+ model: rawModel,
16129
+ provider: rawProvider,
16130
+ session,
16131
+ });
16132
+ if (selection.note.normalized) {
16133
+ recordSessionDiagnostic(session.id, 'walle-model-preference-normalized', selection.note);
16134
+ console.warn('[walle-session] normalized session model preference:', selection.note);
16135
+ }
16136
+
16137
+ const modelId = selection.model;
16138
+ const providerType = selection.provider || rawProvider || '';
16139
+ if (!providerType) {
16140
+ recordSessionDiagnostic(session.id, 'walle-model-preference-provider-missing', {
16141
+ model_id: modelId,
16142
+ registry_id: msg.model_registry_id || msg.modelRegistryId || '',
16143
+ source: msg.source || 'model-change',
16144
+ });
16145
+ }
16146
+
16147
+ let pref = null;
16148
+ try {
16149
+ pref = dbModule.upsertSessionModelPreference?.({
16150
+ ctmSessionId: session.id,
16151
+ agentType: 'walle',
16152
+ providerType,
16153
+ providerId: msg.provider_id || msg.model_provider_id || '',
16154
+ modelId,
16155
+ registryId: msg.model_registry_id || msg.modelRegistryId || '',
16156
+ scope: msg.scope || 'session',
16157
+ source: msg.source || 'user',
16158
+ pinned: msg.pinned !== false,
16159
+ }) || null;
16160
+ } catch (e) {
16161
+ console.error('[walle-session] model preference persist error:', e.message);
16162
+ recordSessionDiagnostic(session.id, 'walle-model-preference-persist-failed', {
16163
+ error: e.message,
16164
+ model_id: modelId,
16165
+ provider_type: providerType,
16166
+ });
16167
+ throw e;
16168
+ }
16169
+
16170
+ _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);
16189
+ }
16190
+ try {
16191
+ telemetry.track('walle_session_model_changed', {
16192
+ session_id: String(session.id || '').slice(0, 8),
16193
+ provider_type: session.model_provider || '',
16194
+ model_id: session.model_id || '',
16195
+ source: msg.source || 'user',
16196
+ });
16197
+ } catch {}
16198
+ _broadcastWalleModel(session, { model_source: 'session-preference' });
16199
+ broadcastSessionList(true);
16200
+ return pref;
16201
+ }
16202
+
15859
16203
  async function handleWalleMessage(ws, msg) {
15860
16204
  const session = sessions.get(msg.id);
15861
16205
  if (!session || session.type !== 'walle') return;
@@ -15873,27 +16217,24 @@ async function handleWalleMessage(ws, msg) {
15873
16217
  return;
15874
16218
  }
15875
16219
  session._walleCancelRequested = false;
16220
+ if (!session.model_id) {
16221
+ _hydrateWalleSessionModelPreference(session, { broadcast: true });
16222
+ }
15876
16223
  if (!session.model_id) {
15877
16224
  const defaults = resolveWalleDefaultModelSelection({ brain: getWalleBrain(), env: process.env });
15878
16225
  if (defaults.model_id) {
15879
16226
  session.model_id = defaults.model_id;
15880
16227
  session.model_provider = defaults.model_provider || session.model_provider || null;
16228
+ session.model_pinned = false;
15881
16229
  try {
15882
16230
  dbModule.addStartupTask(session.id, session.label, '', [], session.cwd, session.model_id, 'walle', session.chatSessionId);
15883
16231
  } catch (e) {
15884
16232
  console.error('[ctm] addStartupTask (walle default model) error:', e.message);
15885
16233
  }
15886
- broadcastToSession({
15887
- type: 'walle-model',
15888
- id: session.id,
15889
- model_id: session.model_id,
15890
- model_provider: session.model_provider,
15891
- });
16234
+ broadcastToSession(_walleModelPayload(session, { model_source: 'global-default' }));
15892
16235
  broadcastSessionList(true);
15893
16236
  }
15894
16237
  }
15895
- const legacyExplicitModel = !!(msg.model && msg.model !== session.model_id);
15896
- const modelPinned = msg.modelPinned === true || msg.modelExplicit === true || legacyExplicitModel;
15897
16238
  const selection = normalizeWalleClientModelSelection({
15898
16239
  model: msg.model,
15899
16240
  provider: msg.provider,
@@ -15901,6 +16242,9 @@ async function handleWalleMessage(ws, msg) {
15901
16242
  });
15902
16243
  const selectedModel = selection.model;
15903
16244
  const selectedProvider = selection.provider;
16245
+ const legacyExplicitModel = !!(msg.model && selectedModel !== session.model_id);
16246
+ const sessionPinnedModel = !!session.model_pinned && !!session.model_id && selectedModel === session.model_id;
16247
+ const modelPinned = msg.modelPinned === true || msg.modelExplicit === true || legacyExplicitModel || sessionPinnedModel;
15904
16248
  if (selection.note.normalized) {
15905
16249
  recordSessionDiagnostic(session.id, 'walle-model-selection-normalized', selection.note);
15906
16250
  console.warn('[walle-session] normalized client model selection:', selection.note);
@@ -16111,9 +16455,9 @@ async function handleWalleMessage(ws, msg) {
16111
16455
  }
16112
16456
  if (!result) throw new Error('Wall-E chat ended without a final response');
16113
16457
  if (session._walleCancelRequested) throw _createWalleCancelledError();
16114
- if (pendingExternalActions.length
16115
- && externalActionApprovals.length === 0
16458
+ if (externalActionApprovals.length === 0
16116
16459
  && externalActionCompletionClaim(result.reply)
16460
+ && (pendingExternalActions.length || hasBlockedExternalActionGatewayToolResult(turnToolCalls))
16117
16461
  && !hasExecutedExternalActionToolResult(turnToolCalls)) {
16118
16462
  result = {
16119
16463
  ...result,
@@ -16433,6 +16777,9 @@ function _sessionPayload(s) {
16433
16777
  fileSize: fileInfo.fileSize || 0,
16434
16778
  model_id: walleModel?.model_id || s.model_id,
16435
16779
  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,
16436
16783
  walle_model_id: walleModel?.model_id || null,
16437
16784
  walle_model_provider: walleModel?.model_provider || null,
16438
16785
  runtime_model_id: walleOwned ? (s.model_id || null) : null,
@@ -41,12 +41,26 @@ function isCodexStatusRedraw(data) {
41
41
  return (
42
42
  stripped.length <= 160 &&
43
43
  (CODEX_STATUS_FRAGMENT_RE.test(stripped) ||
44
- CODEX_BUSY_STATUS_LINE_RE.test(stripped) ||
45
- CODEX_BUSY_COMPACT_STATUS_LINE_RE.test(stripped.replace(/^[\s•◦·∙●○]+\s*/, ''))) &&
44
+ textHasCodexBusyStatus(stripped)) &&
46
45
  isCursorAddressedRedraw(data)
47
46
  );
48
47
  }
49
48
 
49
+ function normalizeCodexStatusLine(text) {
50
+ return String(text || '').replace(/^[\s•◦·∙●○]+/u, '').trim();
51
+ }
52
+
53
+ function isCodexBusyStatusLine(text) {
54
+ const compact = normalizeCodexStatusLine(text);
55
+ return CODEX_BUSY_STATUS_LINE_RE.test(compact) ||
56
+ CODEX_BUSY_COMPACT_STATUS_LINE_RE.test(compact);
57
+ }
58
+
59
+ function textHasCodexBusyStatus(text) {
60
+ const clean = stripAnsi(text).replace(/\r/g, '\n');
61
+ return clean.split('\n').some((line) => isCodexBusyStatusLine(line));
62
+ }
63
+
50
64
  module.exports = {
51
65
  ...baseDetector,
52
66
  id: 'codex',
@@ -68,7 +82,8 @@ module.exports = {
68
82
  return isCodexStatusRedraw(data);
69
83
  },
70
84
  isBusyStatusChunk(data) {
71
- return isCodexStatusRedraw(data) && hasCodexBusyStatusFragment(stripAnsi(data).trim());
85
+ return isCodexStatusRedraw(data) && textHasCodexBusyStatus(data);
72
86
  },
73
87
  hasCodexBusyStatusFragment,
88
+ textHasCodexBusyStatus,
74
89
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walle",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "private": true,
5
5
  "description": "Wall-E — your personal digital twin",
6
6
  "scripts": {