create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -2,8 +2,9 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const path = require('node:path');
5
- const { checkPermission } = require('../tools/permission-checker');
5
+ const { checkPermission, registerEscalation } = require('../tools/permission-checker');
6
6
  const { analyzeShellCommand, initParser: initShellParser, bashArityPrefix } = require('../tools/shell-analyzer');
7
+ const shellSandbox = require('./shell-sandbox');
7
8
 
8
9
  // Shell builtins that only navigate; skipped when choosing the approval card's
9
10
  // display pattern so a leading `cd` doesn't label the request as "cd *".
@@ -52,13 +53,14 @@ class PermissionService {
52
53
  this.pending = _sharedPending;
53
54
  }
54
55
 
55
- async authorize({ sessionId = '', tool, input = {}, cwd = '', projectRoot = '', mode = '', metadata = {}, timeoutMs, headless = false } = {}) {
56
+ async authorize({ sessionId = '', tool, input = {}, cwd = '', projectRoot = '', mode = '', goal = '', metadata = {}, timeoutMs, headless = false } = {}) {
56
57
  const permissionContextId = metadata.permissionContextId || metadata.permission_context_id || createPermissionContextId();
57
58
  const requests = await buildToolPermissionRequests(tool, input, {
58
59
  cwd,
59
60
  projectRoot: projectRoot || cwd,
60
61
  sessionId,
61
62
  mode,
63
+ goal,
62
64
  metadata: { ...metadata, permissionContextId },
63
65
  });
64
66
  if (requests.length === 0) return { decision: 'allow', source: 'default', reason: 'No permission required', permissionContextId };
@@ -82,10 +84,19 @@ class PermissionService {
82
84
  }
83
85
 
84
86
  if (headless || (timeoutMs ?? this.timeoutMs) === 0) {
87
+ // With the OS shell sandbox on, an unlisted shell command is not a dead-end:
88
+ // let it through to the runner, which executes it CONTAINED (writes bounded to
89
+ // the workspace). The hard-denylist already returned 'deny' above, so this only
90
+ // reaches non-destructive commands.
91
+ const sandboxableShell = (request.permission === 'bash' || request.tool === 'run_shell')
92
+ && shellSandbox.shellSandboxEnabled()
93
+ && shellSandbox.sandboxAvailable();
85
94
  return {
86
- decision: this.headlessPolicy === 'allow' ? 'allow' : 'deny',
87
- source: 'headless',
88
- reason: `Permission required for ${request.permission}/${request.pattern}`,
95
+ decision: (this.headlessPolicy === 'allow' || sandboxableShell) ? 'allow' : 'deny',
96
+ source: sandboxableShell ? 'sandbox' : 'headless',
97
+ reason: sandboxableShell
98
+ ? `Sandboxed run_shell for ${request.permission}/${request.pattern}`
99
+ : `Permission required for ${request.permission}/${request.pattern}`,
89
100
  request,
90
101
  permissionContextId,
91
102
  };
@@ -93,6 +104,7 @@ class PermissionService {
93
104
 
94
105
  const reply = await this.ask({
95
106
  ...request,
107
+ check: request.check,
96
108
  sessionId,
97
109
  metadata: { ...metadata, ...request.metadata, evaluated },
98
110
  permissionContextId,
@@ -104,7 +116,7 @@ class PermissionService {
104
116
  return { decision: 'allow', source: 'permissions', reason: 'Permission checks passed', permissionContextId };
105
117
  }
106
118
 
107
- ask({ sessionId = '', permission, pattern = '*', patterns = null, tool = '', fingerprint = '', metadata = {}, permissionContextId = '', timeoutMs } = {}) {
119
+ ask({ sessionId = '', permission, pattern = '*', patterns = null, tool = '', fingerprint = '', metadata = {}, permissionContextId = '', timeoutMs, check = null } = {}) {
108
120
  if (!permission) throw new Error('permission is required');
109
121
  const id = `perm_${crypto.randomUUID().slice(0, 8)}`;
110
122
  const contextId = permissionContextId || metadata.permissionContextId || metadata.permission_context_id || createPermissionContextId();
@@ -124,12 +136,31 @@ class PermissionService {
124
136
  fingerprint: fingerprint || metadata.fingerprint || '',
125
137
  permissionContextId: contextId,
126
138
  metadata: { ...metadata, permissionContextId: contextId },
139
+ // The original check payload, kept so a later rule change (an "always"
140
+ // rule added here OR an allow rule added in CTM's Permission Manager) can
141
+ // RE-EVALUATE this parked request and auto-resolve it — instead of leaving
142
+ // it hung until timeout. See _reEvaluatePending().
143
+ check: check || null,
127
144
  createdAt: Date.now(),
128
145
  timer,
129
146
  resolve,
130
147
  };
131
148
  this.pending.set(id, entry);
132
149
  this._emit('permission.asked', publicEntry(entry));
150
+ // Register the park with CTM (best-effort, non-blocking) so it ALSO appears
151
+ // in the shared "Pending" tab and survives reload. Resolving it there calls
152
+ // back this request id to unpark; a failure is harmless (the local ask + the
153
+ // durable transcript card already surface it).
154
+ registerEscalation({
155
+ requestId: id,
156
+ tool,
157
+ command: (metadata && metadata.command) || '',
158
+ pattern,
159
+ projectPath: (metadata && metadata.projectRoot) || '',
160
+ sessionId,
161
+ reason: (metadata && metadata.evaluated && metadata.evaluated.reason) || '',
162
+ riskLevel: (metadata && metadata.evaluated && metadata.evaluated.riskLevel) || 'medium',
163
+ }).catch(() => {});
133
164
  });
134
165
  }
135
166
 
@@ -139,7 +170,7 @@ class PermissionService {
139
170
  const decision = reply === 'once' || reply === 'always' ? 'allow' : 'deny';
140
171
  this._resolveEntry(entry, { decision, reply, message, source: 'user' });
141
172
  if (reply === 'always') {
142
- const rule = this.rulesStore.addAlways({
173
+ this.rulesStore.addAlways({
143
174
  projectRoot: entry.metadata.projectRoot || '',
144
175
  permission: entry.permission,
145
176
  pattern: entry.pattern,
@@ -147,11 +178,58 @@ class PermissionService {
147
178
  fingerprint: entry.fingerprint || entry.metadata.fingerprint || '',
148
179
  metadata: { ...entry.metadata, message },
149
180
  });
150
- this._resolveCompatibleAlways(entry, rule, message);
181
+ // Cascade: the new rule may also cover OTHER parked requests in this
182
+ // session (e.g. a prefix rule `du *` covers every pending `du …`). Re-run
183
+ // the full decision against each one and auto-resolve the now-allowed ones,
184
+ // instead of only the exact fingerprint match the old path handled.
185
+ void this._reEvaluatePending({ sessionId: entry.sessionId });
151
186
  }
152
187
  return true;
153
188
  }
154
189
 
190
+ /**
191
+ * Re-evaluate every still-parked request (optionally scoped to one session)
192
+ * and resolve any that a freshly-added rule now allows. This is the self-heal
193
+ * for the "parked forever even though the approver would allow it" class:
194
+ * called after a local "always" reply, and exported as reEvaluatePending() so
195
+ * CTM can call it the instant a matching Permission Manager rule is added.
196
+ * Each pending entry carries its original `check`, so the decision is the same
197
+ * one authorize() makes — no duplicated policy logic.
198
+ */
199
+ async _reEvaluatePending({ sessionId = '' } = {}) {
200
+ for (const entry of [...this.pending.values()]) {
201
+ if (sessionId && entry.sessionId !== sessionId) continue;
202
+ let allow = false;
203
+ // (1) An "always" rule now covering this request (prefix-matched, no
204
+ // fingerprint filter so it cascades beyond the exact command).
205
+ try {
206
+ const rule = this.rulesStore.findAlways({
207
+ projectRoot: entry.metadata?.projectRoot || '',
208
+ permission: entry.permission,
209
+ pattern: entry.pattern,
210
+ tool: entry.tool,
211
+ });
212
+ if (rule) allow = true;
213
+ } catch { /* store unavailable — fall through to a fresh evaluation */ }
214
+ // (2) A static/CTM rule now allowing it (e.g. a new Permission Manager
215
+ // allow rule, or the read-only allowlist after a config reload).
216
+ if (!allow && entry.check) {
217
+ try {
218
+ const evaluated = await this.evaluatePermission(entry.check);
219
+ if (evaluated && evaluated.decision === 'allow') allow = true;
220
+ } catch { /* evaluation failed — leave parked, never auto-deny here */ }
221
+ }
222
+ if (allow && this.pending.has(entry.id)) {
223
+ this._resolveEntry(entry, { decision: 'allow', reply: 'always', source: 'cascade' });
224
+ }
225
+ }
226
+ }
227
+
228
+ /** Public trigger for the CTM bridge: a rule was added in CTM; re-check parks. */
229
+ reEvaluatePending(opts = {}) {
230
+ return this._reEvaluatePending(opts);
231
+ }
232
+
155
233
  list({ sessionId = '' } = {}) {
156
234
  const out = [];
157
235
  for (const entry of this.pending.values()) {
@@ -168,18 +246,6 @@ class PermissionService {
168
246
  }
169
247
  }
170
248
 
171
- _resolveCompatibleAlways(sourceEntry, rule, message) {
172
- for (const entry of [...this.pending.values()]) {
173
- if (entry.id === sourceEntry.id) continue;
174
- if (entry.permission !== sourceEntry.permission) continue;
175
- if (entry.tool !== sourceEntry.tool) continue;
176
- if ((entry.metadata.projectRoot || '') !== (sourceEntry.metadata.projectRoot || '')) continue;
177
- if (entry.pattern !== sourceEntry.pattern) continue;
178
- if ((entry.fingerprint || '') !== (sourceEntry.fingerprint || '')) continue;
179
- this._resolveEntry(entry, { decision: 'allow', reply: 'always', message, source: 'always', rule });
180
- }
181
- }
182
-
183
249
  _resolveEntry(entry, result) {
184
250
  if (entry.timer) clearTimeout(entry.timer);
185
251
  this.pending.delete(entry.id);
@@ -204,7 +270,7 @@ class PermissionService {
204
270
  }
205
271
  }
206
272
 
207
- async function buildToolPermissionRequests(tool, input = {}, { cwd = '', projectRoot = '', sessionId = '', mode = '', metadata = {} } = {}) {
273
+ async function buildToolPermissionRequests(tool, input = {}, { cwd = '', projectRoot = '', sessionId = '', mode = '', goal = '', metadata = {} } = {}) {
208
274
  const root = path.resolve(projectRoot || cwd || process.cwd());
209
275
  const base = { sessionId, tool, projectRoot: root, metadata: { ...metadata, projectRoot: root } };
210
276
  if (!tool) return [];
@@ -225,7 +291,7 @@ async function buildToolPermissionRequests(tool, input = {}, { cwd = '', project
225
291
  permission: 'bash',
226
292
  pattern,
227
293
  metadata: { ...base.metadata, command, commandTokens },
228
- check: { tool: 'run_shell', command, commandTokens, commandClauses, projectPath: root, sessionId, mode },
294
+ check: { tool: 'run_shell', command, commandTokens, commandClauses, projectPath: root, sessionId, mode, goal },
229
295
  }], input);
230
296
  }
231
297
 
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Track the set of working directories a single coding session has touched.
4
+ //
5
+ // Wall-E resolves a coding turn's cwd per-request (coding-run-controller), so one
6
+ // long-lived session reused across two unrelated repos silently follows whichever
7
+ // directory it was last pointed at. Asked about a file built earlier in a DIFFERENT
8
+ // directory, the agent globbed its current cwd and missed it (session c3f3af97:
9
+ // built a site in one repo, was later reused for a different app repo, then "show
10
+ // me the web page file path" returned the current repo's files). Mirrors how
11
+ // OpenCode/Claude Code keep a session grounded: surface every root the session has
12
+ // worked in, every turn, instead of relying on a cwd-scoped search.
13
+ //
14
+ // Stored in brain KV (synchronous, survives restarts) keyed by the CTM chat
15
+ // session id — the stable key that groups sequential runTurn calls into one
16
+ // logical coding session (NOT the per-run transcript id).
17
+
18
+ const KEY_PREFIX = 'coding_session_workspaces:';
19
+ const ANCHOR_KEY_PREFIX = 'coding_session_anchor:';
20
+ const MAX_WORKSPACES = 12;
21
+
22
+ function readWorkspaces(brain, sessionKey) {
23
+ if (!brain || !sessionKey || typeof brain.getKv !== 'function') return [];
24
+ try {
25
+ const raw = brain.getKv(KEY_PREFIX + sessionKey);
26
+ if (!raw) return [];
27
+ const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
28
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === 'string' && v) : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ // Append cwd to the session's recorded set (dedup, most-recent-wins cap) and
35
+ // persist. Returns the updated set. Never throws — a brain hiccup must not break a
36
+ // coding turn; the worst case is the prompt missing the extra-directory hint.
37
+ function recordSessionWorkspace(brain, sessionKey, cwd) {
38
+ const existing = readWorkspaces(brain, sessionKey);
39
+ if (!cwd) return existing;
40
+ if (existing.includes(cwd)) return existing;
41
+ const updated = [...existing, cwd].slice(-MAX_WORKSPACES);
42
+ if (brain && sessionKey && typeof brain.setKv === 'function') {
43
+ try { brain.setKv(KEY_PREFIX + sessionKey, JSON.stringify(updated)); } catch { /* best-effort */ }
44
+ }
45
+ return updated;
46
+ }
47
+
48
+ function readSessionAnchor(brain, sessionKey) {
49
+ if (!brain || !sessionKey || typeof brain.getKv !== 'function') return '';
50
+ try {
51
+ const raw = brain.getKv(ANCHOR_KEY_PREFIX + sessionKey);
52
+ return typeof raw === 'string' ? raw : '';
53
+ } catch {
54
+ return '';
55
+ }
56
+ }
57
+
58
+ // The session's anchor is the FIRST workspace root it was grounded in — set ONCE and
59
+ // never changed, so a coding session has a stable "home" project (the OpenCode/Claude
60
+ // one-session-per-project model) even when a later turn is pointed at a different
61
+ // directory. Returns the anchor (existing or newly set), or '' when none is available.
62
+ function recordSessionAnchor(brain, sessionKey, root) {
63
+ const existing = readSessionAnchor(brain, sessionKey);
64
+ if (existing) return existing;
65
+ if (!root) return '';
66
+ const anchor = String(root);
67
+ if (brain && sessionKey && typeof brain.setKv === 'function') {
68
+ try { brain.setKv(ANCHOR_KEY_PREFIX + sessionKey, anchor); } catch { /* best-effort */ }
69
+ }
70
+ return anchor;
71
+ }
72
+
73
+ module.exports = {
74
+ recordSessionWorkspace,
75
+ readWorkspaces,
76
+ recordSessionAnchor,
77
+ readSessionAnchor,
78
+ KEY_PREFIX,
79
+ ANCHOR_KEY_PREFIX,
80
+ MAX_WORKSPACES,
81
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ // OS sandbox for the coding agent's run_shell. Wall-E runs shell commands itself
4
+ // (execAsync/spawn '/bin/bash' in tools/local-tools.js), so to run a command CONTAINED
5
+ // — instead of dead-ending an unlisted command at the headless-deny gate — we wrap the
6
+ // spawn with the host's OS sandbox: macOS Seatbelt (`sandbox-exec`) or Linux bubblewrap
7
+ // (`bwrap`). The containment is "auto-allow contained" (Claude Code model): reads,
8
+ // process-exec, and NETWORK are allowed; filesystem WRITES are bounded to the workspace
9
+ // roots. The truly destructive denylist (sudo/dd/mkfs/…) is still hard-denied upstream —
10
+ // a filesystem sandbox can't neutralize privilege/device/system ops.
11
+ //
12
+ // Pure + dependency-free (system binaries only); detection accepts injected platform/probe
13
+ // so it is unit-testable. Returns null when no sandbox backend exists → callers fall back
14
+ // to today's behavior (no false safety).
15
+
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const fs = require('node:fs');
19
+ const { execFileSync } = require('node:child_process');
20
+
21
+ function _hasBin(bin) {
22
+ if (bin === 'sandbox-exec' && fs.existsSync('/usr/bin/sandbox-exec')) return true;
23
+ for (const which of ['/usr/bin/which', 'which']) {
24
+ try {
25
+ execFileSync(which, [bin], { stdio: 'ignore' });
26
+ return true;
27
+ } catch { /* try next */ }
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function detectBackend({ platform = process.platform, hasBin = _hasBin } = {}) {
33
+ if (platform === 'darwin') return hasBin('sandbox-exec') ? 'seatbelt' : null;
34
+ if (platform === 'linux') return hasBin('bwrap') ? 'bubblewrap' : null;
35
+ return null;
36
+ }
37
+
38
+ function sandboxAvailable(opts = {}) {
39
+ return detectBackend(opts) !== null;
40
+ }
41
+
42
+ // Opt-in kill switch (default OFF). When on AND a backend is available, run_shell
43
+ // commands run contained instead of dead-ending at the headless-deny gate.
44
+ function shellSandboxEnabled(env = process.env) {
45
+ const v = String((env && env.WALLE_SHELL_SANDBOX) || '').toLowerCase();
46
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes';
47
+ }
48
+
49
+ // Seatbelt string literals are double-quoted; escape backslash and quote so a path can
50
+ // never break out of the profile.
51
+ function _seatbeltQuote(p) {
52
+ return String(p).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
53
+ }
54
+
55
+ function buildSeatbeltProfile({ writableRoots = [] } = {}) {
56
+ const roots = (Array.isArray(writableRoots) ? writableRoots : [])
57
+ .filter(Boolean)
58
+ .map((r) => path.resolve(String(r)));
59
+ const writeAllows = roots.map((r) => `(allow file-write* (subpath "${_seatbeltQuote(r)}"))`);
60
+ return [
61
+ '(version 1)',
62
+ '(allow default)', // reads, process-exec, network all allowed
63
+ '(deny file-write*)', // ...writes denied by default
64
+ ...writeAllows, // ...except under the workspace roots
65
+ '(allow file-write* (regex #"^/dev/"))', // tty / null / stdout / stderr
66
+ ].join('\n');
67
+ }
68
+
69
+ function defaultWritableRoots(cwd) {
70
+ const roots = [];
71
+ if (cwd) roots.push(path.resolve(String(cwd)));
72
+ roots.push(os.tmpdir());
73
+ return roots;
74
+ }
75
+
76
+ // Add the canonical (realpath) form of each root. macOS symlinks /var -> /private/var
77
+ // and /tmp -> /private/tmp; Seatbelt matches the REAL path, so a subpath of /var/folders
78
+ // would deny a write whose canonical path is /private/var/folders. Include both.
79
+ function _expandRootsWithRealpath(roots) {
80
+ const out = [];
81
+ const seen = new Set();
82
+ for (const r of roots) {
83
+ const abs = path.resolve(String(r));
84
+ if (!seen.has(abs)) { seen.add(abs); out.push(abs); }
85
+ try {
86
+ const real = fs.realpathSync(abs);
87
+ if (real && real !== abs && !seen.has(real)) { seen.add(real); out.push(real); }
88
+ } catch { /* root may not exist yet — keep the resolved form */ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Build the argv that runs `command` (a /bin/bash -c string) inside the sandbox. Returns
94
+ // null when no backend is available so the caller can fall back to an unsandboxed spawn.
95
+ function wrapShellArgv({ command, cwd, writableRoots, platform = process.platform, hasBin = _hasBin } = {}) {
96
+ const backend = detectBackend({ platform, hasBin });
97
+ if (!backend) return null;
98
+ const requested = Array.isArray(writableRoots) && writableRoots.length
99
+ ? writableRoots.filter(Boolean)
100
+ : defaultWritableRoots(cwd);
101
+ const roots = _expandRootsWithRealpath(requested);
102
+ const cmd = String(command || '');
103
+
104
+ if (backend === 'seatbelt') {
105
+ const profile = buildSeatbeltProfile({ writableRoots: roots });
106
+ return ['sandbox-exec', '-p', profile, '/bin/bash', '-c', cmd];
107
+ }
108
+
109
+ // bubblewrap: read-only root, the workspace roots bound read-write, /dev + /proc, and
110
+ // a shared network namespace (writes contained, network allowed).
111
+ const argv = ['bwrap', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc'];
112
+ for (const r of roots) argv.push('--bind', r, r);
113
+ argv.push('--share-net', '--die-with-parent', '/bin/bash', '-c', cmd);
114
+ return argv;
115
+ }
116
+
117
+ module.exports = {
118
+ detectBackend,
119
+ sandboxAvailable,
120
+ shellSandboxEnabled,
121
+ buildSeatbeltProfile,
122
+ defaultWritableRoots,
123
+ wrapShellArgv,
124
+ };
@@ -93,15 +93,19 @@ async function* streamFromChat(provider, request) {
93
93
  }
94
94
 
95
95
  class StreamProcessor extends EventEmitter {
96
- constructor({ provider, model, transcript, toolExecutor, snapshotService, permissionService, artifactStore, now, emitLaneEvents = true, headless = false } = {}) {
96
+ constructor({ provider, model, transcript, toolExecutor, prepareToolCall, snapshotService, permissionService, artifactStore, now, emitLaneEvents = true, headless = false, goal = '' } = {}) {
97
97
  super();
98
98
  if (!provider) throw new Error('provider is required');
99
99
  this.provider = provider;
100
100
  this.model = model;
101
101
  this.transcript = transcript || null;
102
102
  this.toolExecutor = toolExecutor || null;
103
+ this.prepareToolCall = typeof prepareToolCall === 'function' ? prepareToolCall : null;
103
104
  this.snapshotService = snapshotService || null;
104
105
  this.permissionService = permissionService || null;
106
+ // Coding task forwarded to the permission layer for CTM's goal-aligned
107
+ // auto-approver (best-effort context, never a gate on its own).
108
+ this.goal = goal || '';
105
109
  this.now = now || (() => new Date().toISOString());
106
110
  this.artifactStore = artifactStore === false ? null : (artifactStore || new ArtifactStore({ now: this.now }));
107
111
  this.emitLaneEvents = emitLaneEvents !== false;
@@ -149,6 +153,9 @@ class StreamProcessor extends EventEmitter {
149
153
  hadEdit: false,
150
154
  verified: false,
151
155
  events: [],
156
+ // Turn abort signal — forwarded to tool execution so user Stop kills an
157
+ // in-flight run_shell child instead of just abandoning the promise.
158
+ signal,
152
159
  };
153
160
 
154
161
  await this._runtimeThread(sessionId, cwd, {
@@ -415,6 +422,7 @@ class StreamProcessor extends EventEmitter {
415
422
  async _runTool(call, state, { sessionId, cwd }) {
416
423
  const toolStartedAt = Date.now();
417
424
  try {
425
+ call = await this._prepareToolCall(call, state, { sessionId, cwd });
418
426
  await this._runtimeItem(sessionId, cwd, {
419
427
  threadId: state.threadId,
420
428
  turnId: state.turnId,
@@ -533,6 +541,10 @@ class StreamProcessor extends EventEmitter {
533
541
  type: 'permission_request',
534
542
  sessionId,
535
543
  permId: entry.id,
544
+ // toolCallId correlates this approval to the exact tool task in the
545
+ // CTM WORK-bar registry, so it flips THAT tool to awaiting_permission
546
+ // (not a racy "most-recent pending" guess).
547
+ toolCallId: call.id,
536
548
  tool: call.name,
537
549
  command: (call.input && call.input.command) || '',
538
550
  args: Array.isArray(call.input && call.input.args) ? call.input.args : [],
@@ -549,6 +561,7 @@ class StreamProcessor extends EventEmitter {
549
561
  input: call.input,
550
562
  cwd,
551
563
  projectRoot: cwd,
564
+ goal: this.goal,
552
565
  headless: this.headless,
553
566
  metadata: { toolCallId: call.id },
554
567
  });
@@ -578,6 +591,7 @@ class StreamProcessor extends EventEmitter {
578
591
  type: 'permission_resolved',
579
592
  sessionId,
580
593
  permId: askedPermId,
594
+ toolCallId: call.id,
581
595
  tool: call.name,
582
596
  decision: permission.decision,
583
597
  source: permission.source || '',
@@ -623,7 +637,7 @@ class StreamProcessor extends EventEmitter {
623
637
  confidence: Confidence.HIGH,
624
638
  data: { toolCallId: call.id, name: call.name },
625
639
  });
626
- let result = await this.toolExecutor(call, { sessionId, cwd, model: state.model, provider: state.provider });
640
+ let result = await this.toolExecutor(call, { sessionId, cwd, model: state.model, provider: state.provider, signal: state.signal });
627
641
  await this._recordDiagnostics(sessionId, cwd, call, result);
628
642
  const typedArtifacts = this._storeTypedArtifacts(result, { sessionId, cwd, call });
629
643
  const routed = this._routeToolOutput(result, { sessionId, cwd, call });
@@ -742,6 +756,53 @@ class StreamProcessor extends EventEmitter {
742
756
  }
743
757
  }
744
758
 
759
+ async _prepareToolCall(call, state, { sessionId, cwd }) {
760
+ if (!this.prepareToolCall) return call;
761
+ try {
762
+ const prepared = await this.prepareToolCall(call, { sessionId, cwd, model: state.model, provider: state.provider });
763
+ const nextCall = normalizeToolCall(prepared?.call || call);
764
+ if (!nextCall.id) nextCall.id = call.id;
765
+ const index = state.toolCalls.findIndex((item) => String(item.id) === String(call.id));
766
+ if (index >= 0) state.toolCalls[index] = nextCall;
767
+ if (prepared?.decision && prepared.decision.action && prepared.decision.action !== 'skip') {
768
+ await this._record(sessionId, cwd, 'tool', {
769
+ state: 'prepared',
770
+ toolCallId: nextCall.id,
771
+ name: nextCall.name,
772
+ input: nextCall.input,
773
+ actionMemory: {
774
+ decision: prepared.decision,
775
+ candidateCount: prepared.candidateCount || 0,
776
+ },
777
+ });
778
+ this._emit({
779
+ type: 'action_memory',
780
+ sessionId,
781
+ toolCallId: nextCall.id,
782
+ tool: nextCall.name,
783
+ decision: prepared.decision,
784
+ candidateCount: prepared.candidateCount || 0,
785
+ });
786
+ }
787
+ return nextCall;
788
+ } catch (err) {
789
+ await this._record(sessionId, cwd, 'tool', {
790
+ state: 'prepare_failed',
791
+ toolCallId: call.id,
792
+ name: call.name,
793
+ error: err.message,
794
+ });
795
+ this._emit({
796
+ type: 'action_memory',
797
+ sessionId,
798
+ toolCallId: call.id,
799
+ tool: call.name,
800
+ error: err.message,
801
+ });
802
+ return call;
803
+ }
804
+ }
805
+
745
806
  _routeToolOutput(result, { sessionId, call }) {
746
807
  if (!this.artifactStore?.routeToolOutput) return { result, artifact: null };
747
808
  return this.artifactStore.routeToolOutput(result, {
@@ -23,6 +23,7 @@ class ToolExecutionController {
23
23
  model = '',
24
24
  mode = '',
25
25
  runtimeMode = '',
26
+ goal = '',
26
27
  headless = false,
27
28
  benchmark = false,
28
29
  handlePermissions = true,
@@ -43,6 +44,9 @@ class ToolExecutionController {
43
44
  this.model = model;
44
45
  this.mode = mode;
45
46
  this.runtimeMode = runtimeMode;
47
+ // The coding task / user request — forwarded to the permission layer so CTM's
48
+ // goal-aligned auto-approver can judge by goal-alignment. Best-effort context.
49
+ this.goal = goal || '';
46
50
  this.headless = Boolean(headless);
47
51
  this.benchmark = Boolean(benchmark);
48
52
  this.handlePermissions = handlePermissions !== false;
@@ -85,6 +89,7 @@ class ToolExecutionController {
85
89
  cwd: input.cwd || base.cwd,
86
90
  projectRoot: base.projectRoot,
87
91
  mode: base.mode,
92
+ goal: base.goal || this.goal,
88
93
  headless: this.headless || this.benchmark,
89
94
  metadata: { toolCallId },
90
95
  });
@@ -167,6 +172,7 @@ class ToolExecutionController {
167
172
  model: ctx.model || this.model,
168
173
  mode: ctx.mode || this.mode,
169
174
  runtimeMode: ctx.runtimeMode || this.runtimeMode,
175
+ goal: ctx.goal || this.goal,
170
176
  llmCtx: ctx.llmCtx || {
171
177
  sessionId: ctx.sessionId || this.sessionId,
172
178
  cwd: ctx.cwd || this.cwd,
@@ -177,6 +183,11 @@ class ToolExecutionController {
177
183
  },
178
184
  interactive: ctx.interactive,
179
185
  onTodos: ctx.onTodos,
186
+ // Abort signal (user Stop / turn cancel) + progress heartbeat, forwarded to
187
+ // long-running tools (run_shell) so Stop kills the child and a slow command
188
+ // shows live motion. Best-effort; both may be undefined.
189
+ signal: ctx.signal,
190
+ onProgress: ctx.onProgress,
180
191
  };
181
192
  }
182
193
 
@@ -192,7 +203,7 @@ class ToolExecutionController {
192
203
  if (name === 'glob' && !input.directory) input.directory = base.cwd;
193
204
  if (name === 'grep_files' && !input.directory) input.directory = base.cwd;
194
205
  if (name === 'run_shell') {
195
- input.timeout_ms = input.timeout_ms || 30000;
206
+ input.timeout_ms = input.timeout_ms || Number(process.env.WALLE_SHELL_TIMEOUT_MS) || 30000;
196
207
  input.cwd = input.cwd || base.cwd;
197
208
  }
198
209
  return input;
@@ -244,6 +255,8 @@ class ToolExecutionController {
244
255
  provider: base.provider,
245
256
  llmCtx: base.llmCtx,
246
257
  lifecycleHooks: this.lifecycleHooks,
258
+ signal: base.signal,
259
+ onProgress: base.onProgress,
247
260
  });
248
261
  }
249
262
 
@@ -163,7 +163,7 @@ class ToolRegistry {
163
163
 
164
164
  const local = this.localTools || safeLocalTools();
165
165
  if (local?.executeLocalTool) {
166
- const result = await local.executeLocalTool(name, args);
166
+ const result = await local.executeLocalTool(name, args, ctx);
167
167
  if (result !== null && result !== undefined) return result;
168
168
  }
169
169
  return invalidTool(name, `Unknown tool: ${name}`);
@@ -100,6 +100,9 @@ class CodingTranscript {
100
100
  record.modelId = opts.model || opts.modelId || this.defaults.modelId || '';
101
101
  record.modelProvider = opts.provider || opts.modelProvider || this.defaults.modelProvider || '';
102
102
  record.tokens = opts.tokens || {};
103
+ // Claude-Code-parity per-message usage breakdown (input/output/cache_* tokens),
104
+ // mirroring its JSONL `message.usage`. `tokens` stays for backward compat.
105
+ record.usage = opts.usage || null;
103
106
  record.cost = Number.isFinite(opts.cost) ? opts.cost : null;
104
107
  return this.append(record);
105
108
  }