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
@@ -1277,12 +1277,16 @@ function repairSafeRelinkSwaps(db, auditResult = null) {
1277
1277
  const projectA = rowA.project_path || stA.cwd || ctmRowA.cwd || '';
1278
1278
  const projectB = rowB.project_path || stB.cwd || ctmRowB.cwd || '';
1279
1279
  _updateColumns(db, 'startup_tasks', 'ctm_session_id', ctmA, {
1280
+ agent_session_id: desiredA,
1281
+ claude_session_id: desiredA,
1280
1282
  agent_project_dir: _dirForJsonl(rowA.jsonl_path),
1281
1283
  claude_project_dir: _dirForJsonl(rowA.jsonl_path),
1282
1284
  cwd: projectA,
1283
1285
  worktree_path: /\/\.(?:claude|walle)\/worktrees\//.test(projectA) ? projectA : null,
1284
1286
  });
1285
1287
  _updateColumns(db, 'startup_tasks', 'ctm_session_id', ctmB, {
1288
+ agent_session_id: desiredB,
1289
+ claude_session_id: desiredB,
1286
1290
  agent_project_dir: _dirForJsonl(rowB.jsonl_path),
1287
1291
  claude_project_dir: _dirForJsonl(rowB.jsonl_path),
1288
1292
  cwd: projectB,
@@ -1,7 +1,8 @@
1
1
  # Portkey Gateway Provider UX
2
2
 
3
3
  Date: 2026-05-17
4
- Status: implemented
4
+ Updated: 2026-05-20
5
+ Status: revised and implemented
5
6
 
6
7
  ## Context
7
8
 
@@ -14,16 +15,46 @@ Wall-E already has two separate ideas that should not be collapsed:
14
15
 
15
16
  Portkey belongs on a third axis: **connection / transport**. A provider instance can be "OpenAI via Portkey" or "Anthropic via Portkey" while still using the OpenAI or Anthropic runtime adapter.
16
17
 
17
- ## UX Model
18
+ ## Research Notes
19
+
20
+ Portkey's current public docs support the implementation shape:
21
+
22
+ - The OpenAI-compatible setup is still "change base URL to `https://api.portkey.ai/v1` and use a Portkey API key".
23
+ - The universal API supports OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages against the gateway, so Portkey should be modeled as transport rather than as a standalone upstream provider.
24
+ - The model listing endpoint is `GET /v1/models`, requires `x-portkey-api-key`, and can filter by `provider`/`ai_service`. Its response includes provider-qualified ids such as `@ai-provider-slug/gpt-5`, which CTM must preserve instead of filtering through the built-in catalog.
25
+
26
+ ## Revised UX Model
18
27
 
19
28
  The `#models` page should use this vocabulary:
20
29
 
21
- - **Provider**: upstream API/runtime family. Examples: OpenAI, Anthropic, Google Gemini.
30
+ - **Provider**: upstream API/runtime family. Examples: OpenAI, Anthropic, Google Gemini. Provider cards are the primary overview because CTM has few provider families.
22
31
  - **Connection**: how requests reach the provider. Examples: Direct API key, Portkey gateway, Claude CLI subscription, Codex CLI subscription, OAuth proxy.
23
32
  - **Route**: a concrete model plus provider instance. Examples: `gpt-5.5 · OpenAI · Direct`, `gpt-5.5 · OpenAI · Portkey prod`.
33
+ - **Gateway**: shared transport/account configuration that can be enabled per provider family. Portkey is the first gateway.
34
+ - **Model Catalog**: the long model inventory underneath provider cards. It is collapsed by default because the provider view is the decision surface.
24
35
 
25
36
  This keeps the user from seeing Portkey as a competing "model provider" next to OpenAI and Anthropic. It also explains why a Claude model can be reachable through Portkey while Claude CLI remains a separate subscription-backed route.
26
37
 
38
+ ## 2026-05-20 Product Decision
39
+
40
+ Portkey is enabled and disabled at the **provider** level, not the individual model level.
41
+
42
+ The provider card owns the access policy:
43
+
44
+ - `Auto`: prefer direct provider API when configured, otherwise use Portkey if the provider is gateway-only.
45
+ - `Prefer Direct`: use direct provider API when available; fall back to Portkey only when direct access is absent.
46
+ - `Prefer Portkey`: use the Portkey connection for that provider when available; fall back to direct only when no Portkey route exists.
47
+
48
+ The Gateway section owns account-level operations:
49
+
50
+ - connect/add a Portkey route
51
+ - sync all Portkey model catalogs
52
+ - apply Portkey to all providers with Portkey routes
53
+ - disable Portkey as the default by returning provider policies to `Auto`
54
+ - remove all Portkey gateway connections
55
+
56
+ The Model Catalog remains an inspection surface. It shows imported/discovered models, capabilities, pricing, source, and verification status, but the default access decision belongs on provider cards.
57
+
27
58
  ## `#models` Page Behavior
28
59
 
29
60
  ### Provider Cards
@@ -53,6 +84,32 @@ Each saved provider instance should display a compact connection chip:
53
84
 
54
85
  The chip is metadata, not the provider type.
55
86
 
87
+ Each cloud provider card also exposes an **Access Route** control:
88
+
89
+ - `Use Portkey` when a Portkey route exists for that provider.
90
+ - `Prefer Direct` for direct-first routing.
91
+ - `Auto` for direct-first with gateway fallback.
92
+ - `Enable Portkey` when the provider has no Portkey route yet.
93
+
94
+ This gives users a discoverable per-provider control without forcing route choices across hundreds of model rows.
95
+
96
+ ### Gateway Section
97
+
98
+ The Models page includes a first-class **Gateways** section above provider cards.
99
+
100
+ The Portkey gateway card displays:
101
+
102
+ - configured provider route count
103
+ - imported model count
104
+ - last successful sync or last sync error
105
+ - `Connect Portkey` / `Add Route`
106
+ - `Sync now`
107
+ - `Apply to all providers`
108
+ - `Disable as default`
109
+ - `Remove`
110
+
111
+ Portkey model discovery runs hourly in the Wall-E daemon, runs on startup after the daemon is listening, and can be triggered manually from `Sync now`. Sync must import raw Portkey model ids, including model ids not present in CTM's built-in supported-model catalog.
112
+
56
113
  ### Add Provider Dialog
57
114
 
58
115
  The dialog should ask for upstream first, then connection:
@@ -72,6 +129,8 @@ Advanced custom headers can still exist, but common Portkey fields should not re
72
129
 
73
130
  ### Model List
74
131
 
132
+ The Model Catalog section is collapsed by default. Searching models expands the catalog so search results remain visible.
133
+
75
134
  The model list should show duplicate model ids as separate routes only when necessary:
76
135
 
77
136
  - If a model exists once, show `GPT-5.5` with a subtle `OpenAI · Direct` chip.
@@ -81,13 +140,23 @@ The model list should show duplicate model ids as separate routes only when nece
81
140
 
82
141
  The saved value should prefer `model_registry.id`, not only the bare model id.
83
142
 
143
+ Gateway-discovered model rows use:
144
+
145
+ - `source = portkey`
146
+ - `gateway_type = portkey`
147
+ - `verification_status = verified` only when CTM's built-in catalog recognizes the model id; otherwise `unverified`
148
+ - `last_seen_at` from the latest Portkey sync
149
+
150
+ Catalog pruning must not delete gateway-discovered rows just because CTM's built-in catalog does not know the id yet.
151
+
84
152
  ## Conflict Rules
85
153
 
86
154
  1. Exact `model_registry.id` wins.
87
- 2. Provider-instance defaults win over provider-type defaults.
88
- 3. If a bare model id maps to exactly one enabled route, it is safe to resolve.
89
- 4. If a bare model id maps to multiple enabled routes, use the explicit default route if one exists; otherwise return an ambiguity error that names the available routes.
90
- 5. Never select the newest provider row as a silent tie-breaker when direct and Portkey routes share the same model id.
155
+ 2. Explicit task/model defaults win over provider route policy.
156
+ 3. Provider route policy resolves duplicate direct and Portkey rows for the same provider family.
157
+ 4. If a bare model id maps to exactly one enabled route, it is safe to resolve.
158
+ 5. If a bare model id maps to multiple enabled routes and provider route policy cannot disambiguate, return an ambiguity error that names the available routes.
159
+ 6. Never select the newest provider row as a silent tie-breaker when direct and Portkey routes share the same model id.
91
160
 
92
161
  ## Implementation Phases
93
162
 
@@ -117,38 +186,20 @@ Document the UX and routing model, including the `#models` page behavior and dup
117
186
 
118
187
  ## Verification Results
119
188
 
120
- Phase 4 used an isolated CTM instance instead of the existing primary server on port 3456:
121
-
122
- - CTM: `https://localhost:3466/?token=...#models`
123
- - Wall-E: `127.0.0.1:3467`
124
- - Data dir: `/tmp/ctm-portkey-test`
125
- - Mock Portkey gateway: `http://127.0.0.1:4577/v1`
126
-
127
- The seeded real-life scenario used two OpenAI connections with the same model id:
128
-
129
- - `openai-default:gpt-4o-mini` as direct OpenAI.
130
- - `openai-portkey-prod:gpt-4o-mini` as OpenAI via Portkey.
131
-
132
- The CTM `test-connection` route sent a live `GET /v1/models` request through Wall-E's OpenAI adapter to the mock gateway. The mock observed:
133
-
134
- - `Authorization: Bearer unit-portkey-key`
135
- - `x-portkey-config: cfg-prod`
136
- - `x-portkey-provider: openai`
137
-
138
- The browser verification captured desktop and mobile screenshots in `/tmp/portkey-models-desktop.png` and `/tmp/portkey-models-mobile.png`. It checked:
139
-
140
- - Duplicate direct and Portkey OpenAI routes render as separate connections.
141
- - Portkey route details show `config cfg-prod`.
142
- - Header `+ Add Provider` keeps the Provider selector visible.
143
- - Portkey mode exposes gateway fields and labels the key as `Portkey API Key`.
144
- - Anthropic defaults to `https://api.portkey.ai`; OpenAI defaults to `https://api.portkey.ai/v1`.
145
- - The Portkey provider-slug placeholder follows the selected provider.
146
- - The Portkey dialog itself fits a 390px mobile viewport.
189
+ The implemented provider-level gateway path is covered by:
147
190
 
148
- The mobile pass also observed an existing `#models` page horizontal overflow before the dialog opens, caused by the top bar and tier matrix. That is separate from the Portkey dialog and should be handled as a follow-up page-wide mobile layout pass.
191
+ - Portkey sync tests that import unknown gateway model ids, mark CTM-known ids as `verified`, mark unknown ids as `unverified`, and prove catalog pruning preserves `source = portkey` rows.
192
+ - Provider route-policy tests that prefer Portkey or direct connections per provider and fall back when the requested route is unavailable.
193
+ - Chat routing tests that resolve duplicate direct/Portkey bare model ids using the provider policy instead of raising avoidable ambiguity.
194
+ - Model admin HTTP tests for gateway summaries and provider route policy updates.
195
+ - Static UI tests for the Gateway section, provider Access Route controls, and collapsed-by-default Model Catalog.
196
+ - Playwright render tests for the Models page gateway card, provider route buttons, collapsed catalog expansion, and provider policy POST behavior.
197
+ - Full CTM regression tests.
149
198
 
150
199
  ## References
151
200
 
152
201
  - https://portkey.ai/docs/integrations/libraries/openai-compatible
202
+ - https://portkey.ai/docs/api-reference/inference-api/models/models
203
+ - https://portkey.ai/docs/product/ai-gateway/universal-api
153
204
  - https://portkey.ai/docs/integrations/libraries/claude-code
154
205
  - https://portkey.ai/docs/integrations/libraries/codex
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walle",
3
- "version": "0.9.21",
3
+ "version": "0.9.22",
4
4
  "private": true,
5
5
  "description": "Wall-E — your personal digital twin",
6
6
  "scripts": {
@@ -1430,8 +1430,19 @@ function handleWalleApi(req, res, url) {
1430
1430
  if (p === '/api/wall-e/alerts' && m === 'GET') {
1431
1431
  try {
1432
1432
  const { getServiceAlerts } = require('./skills/skill-planner');
1433
+ const { buildServiceHealth } = require('./lib/service-health');
1433
1434
  const alerts = getServiceAlerts({ includeVersionUpdate: true });
1434
- return jsonResponse(res, { alerts }), true;
1435
+ let activeProvider = process.env.WALLE_PROVIDER || '';
1436
+ let activeModel = process.env.WALLE_MODEL || '';
1437
+ try {
1438
+ activeProvider = brain?.getKv?.('walle_provider') || activeProvider;
1439
+ activeModel = brain?.getKv?.('walle_model') || activeModel;
1440
+ } catch {}
1441
+ const summary = buildServiceHealth(alerts, {
1442
+ activeProvider,
1443
+ activeModel,
1444
+ });
1445
+ return jsonResponse(res, { alerts, summary }), true;
1435
1446
  } catch (e) {
1436
1447
  return jsonResponse(res, { alerts: [], error: e.message }), true;
1437
1448
  }
@@ -2323,9 +2334,15 @@ function handleWalleApi(req, res, url) {
2323
2334
  if (!hasApiKey && (walleProvider === 'ollama' || walleProvider === 'mlx')) hasApiKey = true;
2324
2335
 
2325
2336
  let serviceAlerts = [];
2337
+ let serviceAlertSummary = null;
2326
2338
  try {
2327
2339
  const { getServiceAlerts } = require('./skills/skill-planner');
2340
+ const { buildServiceHealth } = require('./lib/service-health');
2328
2341
  serviceAlerts = getServiceAlerts({ includeVersionUpdate: true });
2342
+ serviceAlertSummary = buildServiceHealth(serviceAlerts, {
2343
+ activeProvider: walleProvider,
2344
+ activeModel: walleModel,
2345
+ });
2329
2346
  } catch {}
2330
2347
 
2331
2348
  let slackSocketMode = null;
@@ -2350,6 +2367,7 @@ function handleWalleApi(req, res, url) {
2350
2367
  walle_model: walleModel,
2351
2368
  has_api_key: hasApiKey,
2352
2369
  service_alerts: serviceAlerts,
2370
+ service_alert_summary: serviceAlertSummary,
2353
2371
  slack_socket_mode: slackSocketMode,
2354
2372
  }
2355
2373
  }), true;
@@ -9,6 +9,7 @@ const { createWriteAuditLog } = require('./db/write-audit');
9
9
  const { inferProviderFromModel, normalizeEvalProvider } = require('./eval/provider-normalizer');
10
10
  const { capabilitiesForOllamaModel } = require('./llm/ollama');
11
11
  const { findSupportedModel, supportedModelIdsForProvider } = require('./llm/supported-models');
12
+ const { isPortkeyProviderConfig } = require('./llm/portkey');
12
13
  const _brainEvents = new EventEmitter();
13
14
  _brainEvents.setMaxListeners(20);
14
15
  let _stripNoise;
@@ -37,7 +38,7 @@ let currentDbPath = null;
37
38
  let _daemonOwned = false; // When true, closeDb() is a no-op (daemon manages lifecycle)
38
39
 
39
40
  // --- Schema versioning via PRAGMA user_version ---
40
- const SCHEMA_VERSION = 14; // Bump on every migration addition
41
+ const SCHEMA_VERSION = 15; // Bump on every migration addition
41
42
 
42
43
  const MIGRATIONS = {
43
44
  1: (d) => {
@@ -365,6 +366,19 @@ const MIGRATIONS = {
365
366
  ON channel_message_events(delivery_id);
366
367
  `);
367
368
  },
369
+ 15: (d) => {
370
+ const addCol = (table, col, type) => {
371
+ try { d.prepare(`SELECT ${col} FROM ${table} LIMIT 0`).run(); } catch (_) {
372
+ d.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run();
373
+ }
374
+ };
375
+ addCol('model_registry', 'source', "TEXT DEFAULT 'catalog'");
376
+ addCol('model_registry', 'verification_status', "TEXT DEFAULT 'verified'");
377
+ addCol('model_registry', 'last_seen_at', 'TEXT');
378
+ addCol('model_registry', 'gateway_type', 'TEXT');
379
+ d.prepare("UPDATE model_registry SET source = 'catalog' WHERE source IS NULL OR source = ''").run();
380
+ d.prepare("UPDATE model_registry SET verification_status = 'verified' WHERE verification_status IS NULL OR verification_status = ''").run();
381
+ },
368
382
  };
369
383
 
370
384
  // Schema invariants — columns/tables that MUST exist after the named migration.
@@ -382,6 +396,7 @@ const SCHEMA_INVARIANTS = [
382
396
  { migration: 9, table: 'agent_runner_evaluations', column: 'runner_id' },
383
397
  { migration: 10, table: 'eval_benchmark_runs', column: 'dataset_version' },
384
398
  { migration: 14, table: 'channel_message_events', column: 'channel' },
399
+ { migration: 15, table: 'model_registry', column: 'source' },
385
400
  ];
386
401
 
387
402
  function _columnExists(d, table, column) {
@@ -1071,6 +1086,10 @@ function createTables() {
1071
1086
  speed_tier INTEGER DEFAULT 3,
1072
1087
  enabled INTEGER DEFAULT 1,
1073
1088
  is_fine_tuned INTEGER DEFAULT 0,
1089
+ source TEXT DEFAULT 'catalog',
1090
+ verification_status TEXT DEFAULT 'verified',
1091
+ last_seen_at TEXT,
1092
+ gateway_type TEXT,
1074
1093
  UNIQUE(provider_id, model_id)
1075
1094
  );
1076
1095
 
@@ -3104,6 +3123,90 @@ function pruneSlackInboundEvents(ttlMs = 48 * 60 * 60 * 1000) {
3104
3123
 
3105
3124
  // -- Model Provider CRUD --
3106
3125
 
3126
+ const VALID_PROVIDER_ROUTE_POLICIES = new Set(['auto', 'direct', 'portkey']);
3127
+
3128
+ function _modelProviderRoutePolicyKey(type) {
3129
+ return `model_provider_route_policy:${String(type || '').trim().toLowerCase()}`;
3130
+ }
3131
+
3132
+ function _modelProviderConnectionKind(row = {}) {
3133
+ if (row.auth_method && row.auth_method !== 'api_key') return row.auth_method;
3134
+ return isPortkeyProviderConfig({
3135
+ baseUrl: row.base_url || row.baseUrl,
3136
+ customHeaders: row.custom_headers || row.customHeaders,
3137
+ }) ? 'portkey' : 'direct';
3138
+ }
3139
+
3140
+ function _modelProviderHasCredential(row = {}) {
3141
+ const type = row.type || row.provider_type;
3142
+ if (!row) return false;
3143
+ if (type === 'ollama' || type === 'mlx') return true;
3144
+ if (row.auth_method && row.auth_method !== 'api_key') return true;
3145
+ if (row.api_key_encrypted) return true;
3146
+ if (type === 'anthropic' && process.env.ANTHROPIC_API_KEY) return true;
3147
+ if (type === 'openai' && process.env.OPENAI_API_KEY) return true;
3148
+ if (type === 'google' && (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY)) return true;
3149
+ if (type === 'deepseek' && process.env.DEEPSEEK_API_KEY) return true;
3150
+ if (type === 'moonshot' && process.env.MOONSHOT_API_KEY) return true;
3151
+ return false;
3152
+ }
3153
+
3154
+ function _modelProviderRouteRank(row = {}, policy = 'auto') {
3155
+ const kind = _modelProviderConnectionKind(row);
3156
+ const isPortkey = kind === 'portkey';
3157
+ const hasCredential = _modelProviderHasCredential(row);
3158
+ let policyRank = 50;
3159
+ if (policy === 'portkey') policyRank = isPortkey ? 0 : 10;
3160
+ else if (policy === 'direct') policyRank = isPortkey ? 10 : 0;
3161
+ else policyRank = isPortkey ? 10 : 0;
3162
+ const credentialRank = hasCredential ? 0 : 20;
3163
+ const defaultRank = /-(default|auto)$/.test(String(row.id || '')) ? 0 : 2;
3164
+ return policyRank + credentialRank + defaultRank;
3165
+ }
3166
+
3167
+ function sortModelProvidersByRoutePolicy(rows = [], policy = 'auto') {
3168
+ const normalizedPolicy = VALID_PROVIDER_ROUTE_POLICIES.has(policy) ? policy : 'auto';
3169
+ return [...(rows || [])].sort((a, b) => {
3170
+ const ar = _modelProviderRouteRank(a, normalizedPolicy);
3171
+ const br = _modelProviderRouteRank(b, normalizedPolicy);
3172
+ if (ar !== br) return ar - br;
3173
+ const au = Date.parse(a.updated_at || '') || 0;
3174
+ const bu = Date.parse(b.updated_at || '') || 0;
3175
+ if (bu !== au) return bu - au;
3176
+ return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
3177
+ });
3178
+ }
3179
+
3180
+ function setProviderRoutePolicy({ type, policy }) {
3181
+ const providerType = String(type || '').trim().toLowerCase();
3182
+ const routePolicy = String(policy || 'auto').trim().toLowerCase();
3183
+ if (!providerType) throw new Error('Provider type required');
3184
+ if (!VALID_PROVIDER_ROUTE_POLICIES.has(routePolicy)) {
3185
+ throw new Error(`Invalid provider route policy: ${routePolicy}`);
3186
+ }
3187
+ setKv(_modelProviderRoutePolicyKey(providerType), routePolicy);
3188
+ return { type: providerType, policy: routePolicy };
3189
+ }
3190
+
3191
+ function getProviderRoutePolicy(type) {
3192
+ const providerType = String(type || '').trim().toLowerCase();
3193
+ if (!providerType) return 'auto';
3194
+ const policy = getKv(_modelProviderRoutePolicyKey(providerType));
3195
+ return VALID_PROVIDER_ROUTE_POLICIES.has(policy) ? policy : 'auto';
3196
+ }
3197
+
3198
+ function getPreferredModelProviderForType(type) {
3199
+ const providerType = String(type || '').trim().toLowerCase();
3200
+ if (!providerType) return null;
3201
+ const rows = getDb().prepare(`
3202
+ SELECT *
3203
+ FROM model_providers
3204
+ WHERE type = ? AND enabled = 1
3205
+ `).all(providerType);
3206
+ if (!rows.length) return null;
3207
+ return sortModelProvidersByRoutePolicy(rows, getProviderRoutePolicy(providerType))[0] || null;
3208
+ }
3209
+
3107
3210
  function upsertModelProvider({ id, name, type, baseUrl, apiKeyEncrypted, customHeaders, enabled }) {
3108
3211
  if (!id || !name || !type) throw new Error('Provider requires id, name, type');
3109
3212
  getDb().prepare(`
@@ -3294,14 +3397,31 @@ function disableModelProviderByType(type) {
3294
3397
 
3295
3398
  // -- Model Registry CRUD --
3296
3399
 
3297
- function upsertModelRegistryEntry({ id, providerId, modelId, displayName, capabilities, costPer1mInput, costPer1mOutput, maxContextTokens, maxOutputTokens, speedTier, enabled, isFineTuned }) {
3400
+ function upsertModelRegistryEntry({
3401
+ id,
3402
+ providerId,
3403
+ modelId,
3404
+ displayName,
3405
+ capabilities,
3406
+ costPer1mInput,
3407
+ costPer1mOutput,
3408
+ maxContextTokens,
3409
+ maxOutputTokens,
3410
+ speedTier,
3411
+ enabled,
3412
+ isFineTuned,
3413
+ source,
3414
+ verificationStatus,
3415
+ lastSeenAt,
3416
+ gatewayType,
3417
+ }) {
3298
3418
  if (!id || !providerId || !modelId || !displayName) throw new Error('Registry entry requires id, providerId, modelId, displayName');
3299
3419
  const caps = (Array.isArray(capabilities) || (capabilities && typeof capabilities === 'object'))
3300
3420
  ? JSON.stringify(capabilities)
3301
3421
  : (capabilities || '[]');
3302
3422
  getDb().prepare(`
3303
- INSERT INTO model_registry (id, provider_id, model_id, display_name, capabilities, cost_per_1m_input, cost_per_1m_output, max_context_tokens, max_output_tokens, speed_tier, enabled, is_fine_tuned)
3304
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3423
+ INSERT INTO model_registry (id, provider_id, model_id, display_name, capabilities, cost_per_1m_input, cost_per_1m_output, max_context_tokens, max_output_tokens, speed_tier, enabled, is_fine_tuned, source, verification_status, last_seen_at, gateway_type)
3424
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3305
3425
  ON CONFLICT(id) DO UPDATE SET
3306
3426
  provider_id = excluded.provider_id,
3307
3427
  model_id = excluded.model_id,
@@ -3313,8 +3433,29 @@ function upsertModelRegistryEntry({ id, providerId, modelId, displayName, capabi
3313
3433
  max_output_tokens = excluded.max_output_tokens,
3314
3434
  speed_tier = excluded.speed_tier,
3315
3435
  enabled = excluded.enabled,
3316
- is_fine_tuned = excluded.is_fine_tuned
3317
- `).run(id, providerId, modelId, displayName, caps, costPer1mInput || null, costPer1mOutput || null, maxContextTokens || null, maxOutputTokens || null, speedTier || 3, enabled !== undefined ? (enabled ? 1 : 0) : 1, isFineTuned ? 1 : 0);
3436
+ is_fine_tuned = excluded.is_fine_tuned,
3437
+ source = COALESCE(excluded.source, model_registry.source, 'catalog'),
3438
+ verification_status = COALESCE(excluded.verification_status, model_registry.verification_status, 'verified'),
3439
+ last_seen_at = COALESCE(excluded.last_seen_at, model_registry.last_seen_at),
3440
+ gateway_type = COALESCE(excluded.gateway_type, model_registry.gateway_type)
3441
+ `).run(
3442
+ id,
3443
+ providerId,
3444
+ modelId,
3445
+ displayName,
3446
+ caps,
3447
+ costPer1mInput || null,
3448
+ costPer1mOutput || null,
3449
+ maxContextTokens || null,
3450
+ maxOutputTokens || null,
3451
+ speedTier || 3,
3452
+ enabled !== undefined ? (enabled ? 1 : 0) : 1,
3453
+ isFineTuned ? 1 : 0,
3454
+ source || null,
3455
+ verificationStatus || null,
3456
+ lastSeenAt || null,
3457
+ gatewayType || null,
3458
+ );
3318
3459
  }
3319
3460
 
3320
3461
  function getModelRegistryEntry(id) {
@@ -3684,6 +3825,7 @@ function pruneUnsupportedCloudModelRegistryRowsForDb(d, { dryRun = false } = {})
3684
3825
  JOIN model_providers mp ON mr.provider_id = mp.id
3685
3826
  WHERE mp.type IN ('anthropic', 'openai', 'google', 'deepseek', 'moonshot')
3686
3827
  AND COALESCE(mr.is_fine_tuned, 0) = 0
3828
+ AND COALESCE(mr.source, 'catalog') IN ('catalog', 'builtin')
3687
3829
  `).all();
3688
3830
  const stale = rows.filter((row) => {
3689
3831
  const allowed = allowedByType.get(row.provider_type);
@@ -4626,6 +4768,10 @@ module.exports = {
4626
4768
  listModelProviders,
4627
4769
  listEnabledProviders,
4628
4770
  getProviderByType,
4771
+ setProviderRoutePolicy,
4772
+ getProviderRoutePolicy,
4773
+ getPreferredModelProviderForType,
4774
+ sortModelProvidersByRoutePolicy,
4629
4775
  setProviderAuthMethod,
4630
4776
  getProviderAuthMethod,
4631
4777
  saveSetupProvider,
@@ -112,6 +112,32 @@ function looksLikePrematureToolFollowupReply(text) {
112
112
  return concise && progressCue && (sequenceCue || executionVerb);
113
113
  }
114
114
 
115
+ function _chatToolCallHistoryForActionGuard(toolCalls = []) {
116
+ return (toolCalls || [])
117
+ .map((call) => {
118
+ const name = call?.name || call?.tool || '';
119
+ if (!name) return null;
120
+ let inputHash = '';
121
+ try {
122
+ inputHash = JSON.stringify(call.input || call.args || {}).slice(0, 500);
123
+ } catch {
124
+ inputHash = String(call.input || call.args || '').slice(0, 500);
125
+ }
126
+ return { name, inputHash };
127
+ })
128
+ .filter(Boolean);
129
+ }
130
+
131
+ function _chatNoActionContinuation(args) {
132
+ try {
133
+ const { getNoActionContinuation } = require('./coding-orchestrator');
134
+ return getNoActionContinuation(args);
135
+ } catch (err) {
136
+ console.warn('[chat] Action completion guard unavailable:', err.message);
137
+ return null;
138
+ }
139
+ }
140
+
115
141
  function _hasExplicitWeatherLocation(message) {
116
142
  const text = String(message || '').trim();
117
143
  if (!text) return false;
@@ -734,6 +760,20 @@ function _ambiguousModelRoute(input, rows, explicitProvider) {
734
760
  };
735
761
  }
736
762
 
763
+ function _preferredRouteRowForBareModel(rows = [], explicitProvider) {
764
+ if (!rows.length) return null;
765
+ const type = explicitProvider || rows[0].provider_type;
766
+ const policy = (type && typeof brain.getProviderRoutePolicy === 'function')
767
+ ? brain.getProviderRoutePolicy(type)
768
+ : 'auto';
769
+ if (typeof brain.sortModelProvidersByRoutePolicy !== 'function') return null;
770
+ const sorted = brain.sortModelProvidersByRoutePolicy(
771
+ rows.map((row) => ({ ...row, type: row.provider_type || row.type })),
772
+ policy,
773
+ );
774
+ return sorted[0] || null;
775
+ }
776
+
737
777
  function _createChatProvider(type, config = {}, opts = {}) {
738
778
  if (typeof opts.providerFactory === 'function') return opts.providerFactory(type, config);
739
779
  return createClient(type, config);
@@ -1462,6 +1502,8 @@ function resolveModelSelection(model, explicitProvider) {
1462
1502
  const defaultRegistryId = (brain.getKv?.('walle_model_registry_id') || (explicitProvider ? brain.getKv?.(`walle_model_registry_${explicitProvider}`) : null) || '').trim();
1463
1503
  const defaultRow = defaultRegistryId ? rows.find((row) => row.id === defaultRegistryId) : null;
1464
1504
  if (defaultRow) return _registryRouteFromRow(defaultRow, raw, explicitProvider);
1505
+ const preferredRow = _preferredRouteRowForBareModel(rows, explicitProvider);
1506
+ if (preferredRow) return _registryRouteFromRow(preferredRow, raw, explicitProvider);
1465
1507
  return _ambiguousModelRoute(raw, rows, explicitProvider);
1466
1508
  }
1467
1509
  } catch {}
@@ -2868,6 +2910,7 @@ async function chat(message, opts = {}) {
2868
2910
  let forceFinalNoTools = false;
2869
2911
  let toolLimitNoticeSent = false;
2870
2912
  let postToolProgressContinuationCount = 0;
2913
+ let actionCompletionContinuationCount = 0;
2871
2914
 
2872
2915
  // Adaptive limits based on intent classification
2873
2916
  const INTENT_LIMITS = {
@@ -3266,6 +3309,48 @@ async function chat(message, opts = {}) {
3266
3309
  console.log('[chat] Premature post-tool progress text — continuing tool loop');
3267
3310
  continue;
3268
3311
  }
3312
+
3313
+ if ((wallECodingMode || isCodingActionRequest()) && !forceFinalNoTools) {
3314
+ let hasTurnBudgetForActionContinuation = turn + 1 < allowedTurns;
3315
+ if (!hasTurnBudgetForActionContinuation) {
3316
+ hasTurnBudgetForActionContinuation = extendCodingTurnBudget('action completion guard');
3317
+ }
3318
+ const toolsAvailableForActionContinuation = Array.isArray(toolsForTurn)
3319
+ && toolsForTurn.length > 0
3320
+ && toolCallCount < MAX_TOOL_CALLS
3321
+ && hasTurnBudgetForActionContinuation;
3322
+ const actionContinuation = _chatNoActionContinuation({
3323
+ prompt: routingMessage || message,
3324
+ content: finalText,
3325
+ toolCallHistory: _chatToolCallHistoryForActionGuard(allToolCalls),
3326
+ toolsAvailable: toolsAvailableForActionContinuation,
3327
+ nudges: actionCompletionContinuationCount,
3328
+ maxNudges: wallECodingMode ? 3 : 1,
3329
+ cwd: effectiveCwd || opts.cwd || process.cwd(),
3330
+ });
3331
+ if (actionContinuation?.action === 'continue') {
3332
+ actionCompletionContinuationCount += 1;
3333
+ const assistantHistoryContent = [];
3334
+ if (response.reasoningContent && typeof response.reasoningContent === 'string') {
3335
+ assistantHistoryContent.push({ type: 'reasoning', text: response.reasoningContent });
3336
+ }
3337
+ if (finalText) assistantHistoryContent.push({ type: 'text', text: finalText });
3338
+ messages.push({
3339
+ role: 'assistant',
3340
+ content: assistantHistoryContent.length > 0 ? assistantHistoryContent : finalText,
3341
+ });
3342
+ messages.push({
3343
+ role: 'user',
3344
+ content: `${actionContinuation.message}\n` +
3345
+ 'If the target project is outside the current working directory, do not run broad filesystem scans. Use start_coding with cwd set to the explicit target project directory, or use project-scoped file tools against that directory. For website/UI/UX requests, finish only after concrete edits and verification evidence.',
3346
+ });
3347
+ console.log('[chat] Action completion guard — continuing tool loop:', actionContinuation.reason);
3348
+ continue;
3349
+ }
3350
+ if (actionContinuation?.action === 'fail') {
3351
+ finalText = `I could not complete the requested code change: ${actionContinuation.reason}`;
3352
+ }
3353
+ }
3269
3354
  finalResponseMeta = {
3270
3355
  model: response.model || selectedModel,
3271
3356
  provider: response.provider || usedProvider || targetProviderType,