@milpa/comal 0.2.2 → 0.2.4

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 (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
package/src/tools.mjs CHANGED
@@ -1,14 +1,18 @@
1
+ import { childEnvironment } from './child-env.mjs';
1
2
  import { readFile, writeFile, rename, link, unlink, mkdir, realpath, stat, lstat, readdir, opendir, open } from 'node:fs/promises';
2
3
  import { constants } from 'node:fs';
4
+ import { holdsComalState } from './permissions.mjs';
3
5
  import path from 'node:path';
4
6
  import { spawn } from 'node:child_process';
5
7
  import { createHash, randomUUID } from 'node:crypto';
6
8
  import { House } from './house.mjs';
7
9
  import { readOnlyBashCheck } from './read-only-check.mjs';
10
+ import { BASH_TIMEOUTS, bashTimeoutSchema, bashDescription } from './bash-timeouts.mjs';
8
11
  import { auditExecution } from './audit.mjs';
9
12
  import { digest } from './operations.mjs';
10
13
  import { recoverFileEffects } from './file-recovery.mjs';
11
14
  import { SourceOwnership } from './source-ownership.mjs';
15
+ import { imageSignature, HUMAN_IMAGE_LIMIT } from './image-attachments.mjs';
12
16
  import { bindChromeSourceSyntax, chromeSourceSyntaxAdvice } from './chrome-source-syntax.mjs';
13
17
  import {SourceProposals,sourceDefinitions} from './source-proposals.mjs';
14
18
  import { registerNativeSourceRequests, nativeSourceRequestCapability, consumeNativeSourceRequest, settleNativeSourceRequest } from './source-requests.mjs';
@@ -29,7 +33,7 @@ export const toolDefinitions = [
29
33
  schema('ListDirectory', 'List one workspace directory without shell. Returns bounded names/types, not file contents; omits internal state. Use Glob for recursive matches.', { path: str('Workspace directory; defaults to .'), limit: { type: 'integer', minimum: 1, maximum: 1000 } }, []),
30
34
  schema('FileInfo', 'Inspect workspace file size/type and media signature without shell. By default reads at most 32 signature bytes. Set hash:true for bounded binary-safe full-file SHA-256 evidence (up to 256 MiB, 10 seconds); emits a file_read observation but grants no text inspection for editing. Does not decode media: send images/video directly to AnalyzeMedia.', { file_path: str('Workspace-relative or absolute path'), hash: { type: 'boolean', description: 'Stream the complete regular file to compute SHA-256 without returning contents.' }, allow_missing:{type:'boolean',description:'Return explicit absent metadata for a missing safe path; never treat a dangling symbolic link as absence.'}, max_bytes:{type:'integer',minimum:1,maximum:268435456,description:'Optional stricter hash byte limit; never increases the 256 MiB bound.'} }, ['file_path']),
31
35
  ...sourceDefinitions,
32
- schema('Bash', 'Run a shell command from the workspace after permission. This is NOT an OS sandbox.', { command: str('Shell command'), timeout: { type: 'integer', minimum: 1, maximum: 120000 } }, ['command']),
36
+ schema('Bash', bashDescription(), { command: str('Shell command'), timeout: bashTimeoutSchema() }, ['command']),
33
37
  ];
34
38
 
35
39
  function validate(name, input) {
@@ -47,9 +51,9 @@ function validate(name, input) {
47
51
  }
48
52
 
49
53
  export class WorkspaceTools {
50
- constructor({ cwd, policy, store, onPermission = () => {}, house = new House({ cwd }), actor = { id: 'local-agent', kind: 'agent' }, sessionId, record, select, frontier, capture, unavailable, sourceLockDirectory, nativeSourceRequests = false }) {
54
+ constructor({ cwd, policy, store, onPermission = () => {}, house = new House({ cwd }), actor = { id: 'local-agent', kind: 'agent' }, sessionId, record, select, frontier, capture, unavailable, sourceLockDirectory, nativeSourceRequests = false, imageReads }) {
51
55
  this.cwd = cwd; this.policy = policy; this.onPermission = onPermission; this.reads = new Map(); this.house = house;
52
- this.store = store;
56
+ this.store = store; this.imageReads = imageReads;
53
57
  this.sourceOwnership = new SourceOwnership({cwd,store,directory:sourceLockDirectory});
54
58
  this.sourceProposals=new SourceProposals(this);this.nativeSettlers=new Set();
55
59
  for (const definition of toolDefinitions) if (!house.registry.get(definition.name)) nativeOperations.add(house.registry.add({ ...definition, run: (input, options) => sourceDefinitions.some(d=>d.name===definition.name)?(options.workspace||this).sourceProposals.execute(definition.name,input,options):(options.workspace || this).#run(definition.name, input, options) }));
@@ -66,18 +70,28 @@ export class WorkspaceTools {
66
70
  }
67
71
  setContext(context) { this.executor.setContext(context); }
68
72
  getMode() { return this.executor.getMode(); }
73
+ grantedByHuman(name) { return Boolean(this.executor.grantedByHuman?.(name)); }
69
74
  snapshot() { return this.executor.snapshot(); }
70
75
  execute(name, input, options) { return this.executor.execute(name, input, options); }
71
76
  isNativeOperation(name){return nativeOperations.has(this.house.registry.get(name));}
72
77
  nativeSourceRequestCapability(name){return this.executor.nativeSourceRequestCapability(name);}
73
78
  registerSettlement(handler){this.nativeSettlers.add(handler);return()=>this.nativeSettlers.delete(handler);}
74
79
  async settleOperation(outcome) { const results=await Promise.allSettled([settleNativeSourceRequest(this,outcome),this.sourceOwnership.settle(outcome),this.sourceProposals.settle(outcome),...[...this.nativeSettlers].map(fn=>fn(outcome))]);const failed=results.find(r=>r.status==='rejected');if(failed)throw failed.reason; }
75
- recoverInterrupted({signal,session}={}) { return this.store && this.house.hasRecorder ? recoverFileEffects({store:this.store,house:this.house,executor:this.executor,sessionId:typeof this.recoverySessionId==='function'?this.recoverySessionId():this.recoverySessionId,actor:this.recoveryActor,signal,session}) : undefined; }
76
- inside(file) { const relative = path.relative(this.cwd, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
77
- async resolve(file, { create = false } = {}) {
80
+ async recoverInterrupted({signal,session}={}) {
81
+ if(!this.store||!this.house.hasRecorder)return;
82
+ if(this.codeRunSessionRecovery){await this.codeRunSessionRecovery.recover({signal});session=undefined;}
83
+ return recoverFileEffects({store:this.store,house:this.house,executor:this.executor,sessionId:typeof this.recoverySessionId==='function'?this.recoverySessionId():this.recoverySessionId,actor:this.recoveryActor,signal,session});
84
+ }
85
+ inside(file, root = this.cwd) { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
86
+ /** --add-dir: directorio de solo lectura que contiene `file`, o null. */
87
+ readRootOf(file) { return (this.policy?.readRoots || []).find(root => this.inside(file, root)) || null; }
88
+ /** /add-dir: read+write directory the human opened that contains `file`, or null. */
89
+ workRootOf(file) { return (this.policy?.workRoots || []).find(root => this.inside(file, root)) || null; }
90
+ async resolve(file, { create = false, read = false } = {}) {
78
91
  if (!file || file.includes('\0')) throw new Error('Invalid path.');
79
92
  const candidate = path.resolve(this.cwd, file);
80
- if (!this.inside(candidate)) throw new Error('Path outside the workspace.');
93
+ const readable = read && !create; // las raíces de --add-dir solo valen para leer
94
+ if (!this.inside(candidate) && !this.workRootOf(candidate) && !(readable && this.readRootOf(candidate))) throw new Error('Path outside the workspace.');
81
95
  let canonical;
82
96
  try { canonical = await realpath(candidate); }
83
97
  catch (e) {
@@ -88,10 +102,14 @@ export class WorkspaceTools {
88
102
  catch (err) { if (err.code !== 'ENOENT') throw err; suffix.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); }
89
103
  }
90
104
  }
91
- if (!this.inside(canonical)) throw new Error('The symbolic link leaves the workspace.');
105
+ const workRoot = this.workRootOf(canonical);
106
+ if (!this.inside(canonical) && !workRoot && !(readable && this.readRootOf(candidate) && this.inside(canonical, this.readRootOf(candidate)))) throw new Error('The symbolic link leaves the workspace.');
92
107
  if (this.sourceOwnership.contains(canonical)) throw new Error('Native source ownership state is protected.');
93
108
  const first = path.relative(this.cwd, canonical).split(path.sep)[0];
94
109
  if (['.comal', '.harness'].includes(first)) throw new Error('Comal internal state is protected.');
110
+ // An added directory may be another Comal workspace: its state stays off-limits too.
111
+ if (workRoot && holdsComalState(workRoot, canonical)) throw new Error('Comal internal state is protected.');
112
+ if (workRoot && this.policy?.isPrivate?.(canonical)) throw new Error('Path protected: private Comal location or a path an active plugin protects.');
95
113
  return canonical;
96
114
  }
97
115
  async text(file) {
@@ -135,7 +153,7 @@ export class WorkspaceTools {
135
153
  async #run(name, input, { signal, actor, sessionId, invocation_id, revalidate, nativeSourceRequest, observe = () => {}, recordWork = async () => {} } = {}) {
136
154
  validate(name, input); signal?.throwIfAborted();
137
155
  if (name === 'ListDirectory') {
138
- const dir = await this.resolve(input.path || '.'), entries = []; let truncated = false, bytes = 0;
156
+ const dir = await this.resolve(input.path || '.', { read: true }), entries = []; let truncated = false, bytes = 0;
139
157
  for await (const entry of await opendir(dir)) {
140
158
  signal?.throwIfAborted();
141
159
  if (['.git', '.comal', '.harness', '.analysis'].includes(entry.name)) continue;
@@ -150,7 +168,7 @@ export class WorkspaceTools {
150
168
  return JSON.stringify({ path: relative, entries, truncated });
151
169
  }
152
170
  if (name === 'FileInfo') {
153
- const file = await this.resolve(input.file_path,{create:Boolean(input.allow_missing)});let info;
171
+ const file = await this.resolve(input.file_path,{create:Boolean(input.allow_missing),read:true});let info;
154
172
  try{info=await stat(file,input.hash?{bigint:true}:undefined);}catch(error){
155
173
  if(error.code!=='ENOENT'||!input.allow_missing)throw error;
156
174
  // ENOENT through a dangling link is not an absent entry.
@@ -184,7 +202,22 @@ export class WorkspaceTools {
184
202
  return JSON.stringify(result);
185
203
  }
186
204
  if (name === 'Read') {
187
- const file = await this.resolve(input.file_path), text = await this.text(file);
205
+ const file = await this.resolve(input.file_path, { read: true });
206
+ // With a vision-capable model, Read of a PNG/JPEG/WebP returns the image as an
207
+ // MCP-style image block. Its text projection keeps only a digest reference, and
208
+ // the vision pipeline attaches the receipt's pixels to the next request.
209
+ if (typeof this.imageReads === 'function' && await this.imageReads()) {
210
+ const info = await stat(file);
211
+ if (info.isFile() && info.size > 0 && info.size <= HUMAN_IMAGE_LIMIT) {
212
+ const bytes = await readFile(file), mimeType = imageSignature(bytes);
213
+ if (mimeType) {
214
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
215
+ observe({ kind: 'file_read', file_path: path.relative(this.cwd, file), sha256, media_type: mimeType });
216
+ return JSON.stringify({ file_path: path.relative(this.cwd, file), media_type: mimeType, bytes: bytes.length, sha256, content: [{ type: 'image', mimeType, data: bytes.toString('base64') }], note: 'Image read natively; the pixels reach the model in the next request as recorded evidence.' });
217
+ }
218
+ }
219
+ }
220
+ const text = await this.text(file);
188
221
  this.reads.set(file, hash(text));
189
222
  observe({ kind: 'file_read', file_path: path.relative(this.cwd, file), sha256: hash(text) });
190
223
  const offset = input.offset ?? 1, count = input.limit ?? 400;
@@ -243,7 +276,7 @@ export class WorkspaceTools {
243
276
  }
244
277
  if (name === 'Bash') {
245
278
  const check = readOnlyBashCheck(input.command);
246
- if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal, observe });
279
+ if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? BASH_TIMEOUTS.defaultMs, signal, observe });
247
280
  const files = new Map();
248
281
  for (const name of check.files) { const file = await this.resolve(name); files.set(name, { file, before: hash(await this.text(file)) }); }
249
282
  let output = '';
@@ -251,7 +284,7 @@ export class WorkspaceTools {
251
284
  if (step.kind === 'echo') output += step.text + '\n';
252
285
  else {
253
286
  let text;
254
- try { text = await runProcess('/usr/bin/grep', [...step.flags, '--', step.pattern, files.get(step.file).file], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal }); }
287
+ try { text = await runProcess('/usr/bin/grep', [...step.flags, '--', step.pattern, files.get(step.file).file], { cwd: this.cwd, timeout: input.timeout ?? BASH_TIMEOUTS.defaultMs, signal }); }
255
288
  catch (error) { if (error.message.startsWith('Command exited with code 1:')) throw Object.assign(new Error(`${error.message} Pattern ${JSON.stringify(step.pattern)} did not match in ${JSON.stringify(step.file)}. Implement the criterion behavior; a text match is only a basic check.`), { code: 'CHECK_FAILED' }); throw error; }
256
289
  if (text !== '(no output)') output += text;
257
290
  }
@@ -260,24 +293,46 @@ export class WorkspaceTools {
260
293
  return output || '(no output)';
261
294
  }
262
295
  if (name === 'Grep') {
263
- const target = await this.resolve(input.path || '.');
296
+ const target = await this.resolve(input.path || '.', { read: true });
264
297
  return runProcess('rg', ['--line-number', '--no-heading', '--color', 'never', '--max-count', '100', '--glob', '!.comal/**', '--glob', '!.harness/**', '--glob', '!.analysis/**', ...(input.glob ? ['--glob', input.glob] : []), '--', input.pattern, target], { cwd: this.cwd, timeout: 10000, signal, allowNoMatch: true });
265
298
  }
266
299
  if (name === 'Glob') {
267
- if (path.isAbsolute(input.pattern) || input.pattern.split('/').includes('..')) throw new Error('Glob must be relative to the workspace.');
268
- const pattern = input.pattern.replace(/^\.\//, ''), matcher = globRegex(pattern), files = []; let visited = 0;
300
+ // --add-dir: un patrón absoluto bajo un directorio de solo lectura recorre ese directorio y devuelve rutas absolutas.
301
+ // A relative pattern whose literal prefix (before the first wildcard) resolves under an --add-dir becomes absolute.
302
+ let requested = input.pattern;
303
+ if (!path.isAbsolute(requested) && requested.split('/').includes('..')) {
304
+ const parts = requested.split('/'), wild = parts.findIndex(part => /[*?[{]/.test(part)), literal = wild < 0 ? parts : parts.slice(0, wild), rest = wild < 0 ? [] : parts.slice(wild);
305
+ if (!rest.includes('..') && rest.length) requested = [path.resolve(this.cwd, ...literal), ...rest].join('/');
306
+ }
307
+ const root = path.isAbsolute(requested) ? [...(this.policy?.workRoots || []), ...(this.policy?.readRoots || [])].find(dir => requested.startsWith(dir + '/')) : null;
308
+ if ((path.isAbsolute(requested) && !root) || requested.split('/').includes('..')) throw new Error('Glob must be relative to the workspace or under an added directory (/add-dir or --add-dir).');
309
+ const base = root || this.cwd, show = file => root ? file : path.relative(this.cwd, file).split(path.sep).join('/');
310
+ const pattern = (root ? requested.slice(root.length + 1) : requested).replace(/^\.\//, ''), matcher = globRegex(pattern), files = []; let visited = 0;
311
+ // Enter only directories that can still lead to a match; `**` matches any depth below it.
312
+ const segments = pattern.split('/').map(part => part.includes('**') ? null : globRegex(part));
313
+ const canEnter = parts => { for (let i = 0; i < parts.length; i++) { if (i >= segments.length) return false; if (!segments[i]) return true; if (!segments[i].test(parts[i])) return false; } return parts.length < segments.length; };
269
314
  const walk = async (dir, depth = 0) => {
270
315
  if (depth > 30) return;
271
316
  for (const entry of await readdir(dir, { withFileTypes: true })) {
272
317
  signal?.throwIfAborted();
273
318
  if (++visited > 20000) throw new Error('Glob exceeded 20000 entries; use Grep or a smaller workspace.');
274
319
  if (['.git', '.comal', '.harness', '.analysis', 'node_modules'].includes(entry.name)) continue;
275
- const file = path.join(dir, entry.name), relative = path.relative(this.cwd, file).split(path.sep).join('/');
276
- if (entry.isDirectory()) await walk(file, depth + 1);
277
- else if (entry.isFile() && matcher.test(relative)) files.push(relative);
320
+ const file = path.join(dir, entry.name), relative = path.relative(base, file).split(path.sep).join('/');
321
+ if (entry.isDirectory()) { if (canEnter(relative.split('/'))) await walk(file, depth + 1); }
322
+ else if (entry.isFile() && matcher.test(relative)) files.push(show(file));
278
323
  }
279
324
  };
280
- await walk(this.cwd);
325
+ try { await walk(base); }
326
+ catch (error) {
327
+ if (!/20000 entries/.test(error.message)) throw error;
328
+ // A pattern that must search the whole tree falls back to ripgrep's file list: it honours
329
+ // .gitignore (like Grep), so generated trees such as evidence/ or dist/ do not exhaust it.
330
+ const listed = await runProcess('rg', ['--files', '--hidden', '--no-require-git', '--color', 'never', '--glob', '!.git/**', '--glob', '!.comal/**', '--glob', '!.harness/**', '--glob', '!.analysis/**', '--glob', '!**/node_modules/**', '.'], { cwd: base, signal, allowNoMatch: true, timeout: 30000 })
331
+ .catch(() => { throw error; });
332
+ const matched = listed.split('\n').map(line => line.replace(/^\.\//, '')).filter(relative => relative && relative !== '(no output)' && matcher.test(relative)).map(relative => show(path.join(base, relative))).sort();
333
+ const note = '\n[Searched with ripgrep: paths ignored by .gitignore were skipped' + (matched.length > 1000 ? `; limited to 1000 of ${matched.length} matches` : '') + '.]';
334
+ return (matched.slice(0, 1000).join('\n') || '(no matches)') + note;
335
+ }
281
336
  return files.sort().slice(0, 1000).join('\n') || '(no matches)';
282
337
  }
283
338
  }
@@ -348,26 +403,40 @@ function globRegex(pattern) {
348
403
  export function runProcess(command, args, { cwd, timeout = 30000, signal, allowNoMatch = false, observe = () => {} }) {
349
404
  return new Promise((resolve, reject) => {
350
405
  signal?.throwIfAborted();
351
- const child = spawn(command, args, { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
406
+ const child = spawn(command, args, { cwd, env: childEnvironment(), detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
352
407
  observe({kind:'process_started',pid:child.pid,process_group:child.pid});
353
- let output = '', truncated = false, failure, killTimer;
408
+ // Keep the start and the end: test runners and builds print their summary last.
409
+ let head = '', tail = '', omitted = 0, failure, killTimer;
354
410
  const terminate = reason => {
355
411
  if (failure) return; failure = reason;
356
412
  try { process.kill(-child.pid, 'SIGTERM'); } catch {}
357
413
  killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 200);
358
414
  };
359
415
  const receive = chunk => {
360
- const available = LIMIT - output.length;
361
- if (available > 0) output += chunk.toString().slice(0, available);
362
- if (chunk.length > available) truncated = true;
416
+ let text = chunk.toString();
417
+ if (head.length < LIMIT / 2) { const taken = text.slice(0, LIMIT / 2 - head.length); head += taken; text = text.slice(taken.length); }
418
+ if (text) { tail += text; if (tail.length > LIMIT / 2) { omitted += tail.length - LIMIT / 2; tail = tail.slice(-LIMIT / 2); } }
363
419
  };
420
+ const collected = () => omitted ? `${head}\n[Output limited to 64 KiB: ${omitted} characters omitted between the start and the end]\n${tail}` : head + tail;
364
421
  child.stdout.on('data', receive); child.stderr.on('data', receive);
365
422
  const timer = setTimeout(() => terminate(new Error(`Command exceeded ${timeout} ms.`)), timeout);
366
423
  const abort = () => terminate(new Error('Comando cancelado.'));
367
424
  signal?.addEventListener('abort', abort, { once: true });
368
425
  const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
369
426
  child.once('error', err => { cleanup(); clearTimeout(killTimer); reject(err); });
427
+ // A child that left the process group (setsid, nohup &, daemons) can hold the output pipe after
428
+ // the shell exits, so 'close' never comes. After the shell exits, give the pipes a short grace,
429
+ // then stop reading and settle; such a child keeps running and is reported.
430
+ let settled = false, detached = false;
431
+ child.once('exit', (code, terminationSignal) => {
432
+ setTimeout(() => {
433
+ if (settled) return; detached = true;
434
+ child.stdout.destroy(); child.stderr.destroy();
435
+ child.emit('close', code, terminationSignal);
436
+ }, 500);
437
+ });
370
438
  child.once('close', async (code, terminationSignal) => {
439
+ if (settled) return; settled = true;
371
440
  cleanup();
372
441
  if(failure){
373
442
  // Wait for the bounded escalation before recording a terminal receipt.
@@ -378,8 +447,8 @@ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowN
378
447
  observe({kind:'process_exit',exit_code:code,termination_signal:terminationSignal || null,interrupted:Boolean(failure)});
379
448
  // Leave the short kill timer alive after timeout to stop remaining descendants.
380
449
  if (failure) reject(failure);
381
- else if (code !== 0 && !(allowNoMatch && code === 1)) reject(Object.assign(new Error(`Command exited with code ${code}: ${output}`),{code:'PROCESS_EXIT_NONZERO'}));
382
- else resolve(output + (truncated ? '\n[Output limited to 64 KiB]' : '') || '(no output)');
450
+ else if (code !== 0 && !(allowNoMatch && code === 1)) reject(Object.assign(new Error(`Command exited with code ${code}: ${collected()}`),{code:'PROCESS_EXIT_NONZERO'}));
451
+ else resolve((collected() || '(no output)') + (detached ? '\n[The shell exited but a process it started left its process group and still holds the output; Comal stopped reading. It keeps running: stop it when done. To serve pages for browser tests use WorkspaceServe instead of a background server.]' : ''));
383
452
  });
384
453
  });
385
454
  }
@@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto';
13
13
  import { connectionOptions } from './connection-options.mjs';
14
14
  import { ConnectionTransitions, connectionAction, connectionFailureReason, CONNECTION_REASON_HINTS } from './connection-transition.mjs';
15
15
 
16
- const IMMEDIATE = new Set(['raw', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'swarm', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'autonomy', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider','config','max-tokens','context-window', 'exit', 'quit']);
16
+ const IMMEDIATE = new Set(['visual', 'raw', 'timing', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'swarm', 'relay', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'autonomy', 'rename', 'add-dir', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider','config','max-tokens','context-window','autocompact', 'exit', 'quit']);
17
17
 
18
18
  export class InteractiveController {
19
19
  constructor(ui, backend) { this.ui = ui; this.backend = backend; this.closed = false; this.switching = false; this.draftTimer = null; this.pendingDraft = null; }
@@ -174,6 +174,7 @@ export class InteractiveController {
174
174
  }
175
175
  if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
176
176
  if (command === 'help') { this.ui.showHelp(); return; }
177
+ if (command === 'timing') { const value = /^(?:(on|off))?$/.exec(arg.trim()); if (!value) throw new Error(t('Use /timing [on|off].')); this.ui.setTiming?.(value[1] ? value[1] === 'on' : undefined); return; }
177
178
  if (command === 'raw') { if (arg.trim()) throw new Error(t('Use /raw without arguments.')); this.ui.toggleRaw?.(); return; }
178
179
  if (command==='models' || ['model','login','provider','effort'].includes(command) && !arg.trim()) return this.chooseConnection(command,arg);
179
180
  if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
@@ -202,6 +203,8 @@ export class InteractiveController {
202
203
  if (!strip) throw new Error(t('Use /swarm or /swarm strip [on|off].'));
203
204
  this.ui.setSwarmStrip(strip[1] ? strip[1] === 'on' : undefined); return;
204
205
  }
206
+ // /relay: tarjetas humanas del host (proyecto, confianza, arranque, plan, preguntas, final).
207
+ if (command === 'relay') { const output = await this.backend.control('relay', arg); if (output) this.showOutput(command, arg, output); return; }
205
208
  if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
206
209
  if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
207
210
  if (['max-tokens', 'context-window'].includes(command) && arg.trim() || command === 'config' && /^(?:set|save)\b/.test(arg.trim())) await this.requireIdle();
@@ -6,23 +6,24 @@ import { graphemes, safeText } from './text.mjs';
6
6
 
7
7
  const COMMANDS = [
8
8
  ['cloud', '[login|status|logout]', 'Connect your Comal account for Artifacts and Remote Control'],
9
- ['remote-control', '[start|status|stop]', 'Open this local session on the web'], ['rc', '[start|status|stop]', 'Remote Control shortcut'],
9
+ ['remote-control', '[start|status|stop|relay on|off|device CODE|scan|forget|permissions on|off]', 'Open this local session on the web'], ['rc', '[start|status|stop|relay on|off|device CODE|scan|forget|permissions on|off]', 'Remote Control shortcut'],
10
10
  ['help', '', "Help and keyboard"], ['status', '', "Session, provider and budget"],
11
11
  ['tools', '', "Tools available at this time"], ['house', '', "Receipts and interrupted operations"],
12
12
  ['operations', '', "Contracts and effects of operations"], ['sessions', '', "Open the session selector"],
13
13
  ['resume', 'UUID', "Resume a saved session"], ['clear', '', "Create a new conversation"],
14
14
  ['copy', '', "Copy the last complete response"], ['select', '', "Select text with the terminal"],
15
15
  ['raw', '', "Toggle the exact JSON of results and permissions"],
16
- ['compact', '', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
16
+ ['compact', '[focus TEXT]', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
17
17
  ['state', '', "Request and current job status"], ['audit', '', "Verify ledger integrity"],
18
18
  ['board', '', "Open the task board"], ['work', 'resume|recover|review', "Continue work, recover Chrome or review a native failure locally"],
19
- ['tasks', '', "Open the task board"], ['swarm', '[strip [on|off]]', "Open the open-relay swarm panel"], ['model', 'ID', "Check or change the model"],
19
+ ['tasks', '', "Open the task board"], ['swarm', '[strip [on|off]]', "Open the open-relay swarm panel"], ['relay', '[status|new [NAME]|start|stop|unlink|hub ROOT]', "Create or open the swarm project of this repo and start a swarm (human cards)"], ['timing', '[on|off]', "Show or hide response timing after each answer"], ['model', 'ID', "Check or change the model"],
20
20
  ['models', '', "Choose a model from the current provider"],
21
21
  ['effort', "LEVEL", "Check or change the effort announced by the model"],
22
22
  ['capabilities', '[refresh]', "Support and provenance of capabilities"],
23
23
  ['permissions', "[MODE|allow|ask|deny|remove RULE]", "Show or change the permission mode and saved rules"], ['autonomy', "[observe|assist|edit|autonomous]", "Show or set the autonomy level"], ['goal', "[condition|status|pause|resume|clear]", "Define or control a persistent goal"],
24
24
  ['queue', '[pause|resume|clear|edit N]', "Manage queued messages"],
25
- ['rename', "NAME", "Change the visible name of this session"], ['table', "FILE", "Display a CSV, TSV, JSON, or Markdown table"],
25
+ ['rename', "NAME", "Change the visible name of this session"], ['add-dir', "[PATH]", "Open another directory for reading and writing, or list the open ones"], ['table', "FILE", "Display a CSV, TSV, JSON, or Markdown table"],
26
+ ['visual', '[status [C]|approve C FIXTURE STATE|confirm C CRITERION|revoke C reference|criterion KEY]', "Visual acceptance: approve references and confirm criteria"],
26
27
  ['mcp', "[action]", "View or configure MCP servers"], ['plugins', "[action]", "View plugins and control their lifecycle"], ['chrome-workflow', '[auto|raw]', 'Chrome entry workflow saved for this project'],
27
28
  ['agent', "[explore|general|background] TASK", "Delegate a task to a subagent"], ['agents', '[UUID|status|wait|stop|resume|background|continue] [RUN]', "View and control child runs"],
28
29
  ['search', "QUERY", "Search the web"], ['list-agents', '', "Discover other live sessions"],
@@ -30,7 +31,7 @@ const COMMANDS = [
30
31
  ['providers', '', "View profiles and login providers"], ['provider', "PROFILE", "Switch to the configured profile"],
31
32
  ['login', "PROVIDER [--key]", "Authenticate a provider; keys go in a hidden field"], ['logout', "PROVIDER", "Remove Comal credentials"],
32
33
  ['config', '[set KEY VALUE|save]', "Connection, limits, permissions and config files"],
33
- ['max-tokens', 'N', "Output reserve for this and later sessions"], ['context-window', 'auto|N', "Context window for this and later sessions"],
34
+ ['max-tokens', 'N', "Output reserve for this and later sessions"], ['autocompact', 'N|Nk|auto|off|default', 'Compaction window per model (N, Nk, auto, off)'], ['context-window', 'auto|N', "Context window for this and later sessions"],
34
35
  ['exit', '', "Save and exit"], ['quit', '', "Save and exit"],
35
36
  ].map(([name, args, description]) => Object.freeze({ name, args, description }));
36
37
  export const COMMAND_CATALOG = Object.freeze(COMMANDS);
@@ -84,7 +85,7 @@ const COMMAND_KEYWORDS = Object.freeze({
84
85
  operations: "contracts effects tools catalog catalog contratos efectos herramientas catálogo catalogo",
85
86
  sessions: "sessions history resume recover sesiones historial retomar recuperar", resume: "sessions history resume recover continue sesiones historial retomar recuperar continuar",
86
87
  compact: "memory projection projection derive summary context memoria proyección proyeccion derivar resumen contexto",
87
- board: "kanban pending tasks progress kanban pendientes tareas progreso", swarm: "open-relay relay swarm kanban agents questions plans enjambre agentes preguntas planes", work: "kanban checkpoint continue resume kanban checkpoint continuar retomar",
88
+ board: "kanban pending tasks progress kanban pendientes tareas progreso", timing: "time elapsed latency reasoning duration tiempo latencia razonamiento duración", swarm: "open-relay relay swarm kanban agents questions plans enjambre agentes preguntas planes", relay: "open-relay relay-hub swarm project start objective team agents enjambre proyecto arrancar objetivo equipo agentes", work: "kanban checkpoint continue resume kanban checkpoint continuar retomar",
88
89
  effort: 'esfuerzo razonamiento pensar thinking', capabilities: "capabilities vision vision video images images support capacidades visión vision video imágenes imagenes soporte",
89
90
  permissions: "modes security permissions authorize plan automatic automatic modos seguridad permisos autorizar plan automático automatico",
90
91
  goal: "goal autonomous autonomous continue persistent objetivo autónomo autonomo continuar persistente", queue: "queue messages pending pause reorder cola mensajes pendientes pausar reordenar",
@@ -121,8 +122,15 @@ export function commandCatalog(context = {}) {
121
122
 
122
123
  function argumentItems(command, args, context) {
123
124
  const first = args[0] || '', position = args.length - 1;
125
+ if (command === 'rc' || command === 'remote-control') {
126
+ const durations = () => named(['30m', '2h', '8h'], 'How long the arm lasts (30 min by default, 8 h at most)');
127
+ if (position === 1 && first === 'relay') return named(['on', 'off'], 'on [30m|2h|8h] arms in memory for this /rc session; off disarms');
128
+ if (position === 1 && first === 'device') return [...named(['scan'], 'Read the pairing QR with the camera (at most 30 s); you confirm the code'), ...named(['forget'], 'Unpair the M5Stack')];
129
+ if (position === 1 && first === 'permissions') return named(['on', 'off'], 'on [30m|2h|8h] lets the paired M5Stack allow once or deny; off disarms');
130
+ if (position === 2 && (first === 'relay' || first === 'permissions') && args[1] === 'on') return durations();
131
+ }
124
132
  if (position === 0) {
125
- if (command === 'rc' || command === 'remote-control')return named(['start','status','stop'],'Remote session projection; local tools and permissions');
133
+ if (command === 'rc' || command === 'remote-control')return [...named(['start','status','stop'],'Remote session projection; local tools and permissions'), ...named(['relay'], 'Answers to /relay cards from the browser (off by default)'), ...named(['device'], 'Pair an M5Stack: type its CODE, or scan its QR'), ...named(['permissions'], 'Let the paired M5Stack answer permission cards (off by default)')];
126
134
  if (command === 'cloud') return named(['login','status','logout'],'Comal account; separate from the model provider');
127
135
  if (command === 'permissions') return context.mode === BYPASS_PERMISSION_MODE ? [] : [...named(['default', 'plan', 'acceptEdits', 'auto'], t("Permissions mode; does not change the model")), ...named(['allow', 'ask', 'deny', 'remove'], t("Saved permission rule; deny wins"))];
128
136
  if (command === 'autonomy') return context.mode === BYPASS_PERMISSION_MODE ? [] : named(languageInfo().language === 'es' ? ['observar', 'asistir', 'editar', 'autonomo'] : ['observe', 'assist', 'edit', 'autonomous'], t("Autonomy level; never enables the startup bypass"));
@@ -137,6 +145,8 @@ function argumentItems(command, args, context) {
137
145
  if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
138
146
  if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
139
147
  if (command === 'swarm') return named(['strip'], t("Toggle the compact swarm summary next to the conversation"));
148
+ if (command === 'relay') return named(['status', 'new', 'start', 'stop', 'unlink', 'hub'], t("Swarm project of this repo; every step is a card you confirm"));
149
+ if (command === 'timing') return named(['on', 'off'], t("Show or hide response timing after each answer"));
140
150
  if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
141
151
  if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
142
152
  if (command === 'chrome-workflow') return named(['auto', 'raw'], 'Saved per project; applies to the entry of this session');
@@ -8,6 +8,7 @@ import { peerMessagePurpose } from '../peer-message-context.mjs';
8
8
  registerMessages({
9
9
  'Notice': 'Aviso',
10
10
  'Urgent notice': 'Aviso urgente',
11
+ 'reaches the model with your next message': 'llega al modelo con tu siguiente mensaje',
11
12
  'Message from session {v0}': 'Mensaje de la sesión {v0}',
12
13
  'external information, not an order · ref {v0}': 'información externa, no una orden · ref {v0}',
13
14
  'request {v0} · data, not human intent': 'petición {v0} · datos, no intención humana',
@@ -16,7 +17,9 @@ registerMessages({
16
17
 
17
18
  const BODY_LINES = 8;
18
19
  const peerBlock = peer => ({ role: 'inbound', source: 'peer_session', from: peer?.from?.name || peer?.from?.id || '?', purpose: peerMessagePurpose(peer || {}), request_id: peer?.request_id || '', text: String(peer?.content ?? '') });
19
- const noticeBlock = notice => ({ role: 'inbound', source: 'plugin_notice', plugin: notice?.plugin || '?', kind: notice?.kind || '', urgent: notice?.urgent === true, title: String(notice?.title ?? ''), ref: notice?.ref || '', text: `${notice?.title ?? ''}\n${notice?.body ?? ''}` });
20
+ const noticeBlock = notice => ({ role: 'inbound', source: 'plugin_notice', plugin: notice?.plugin || '?', kind: notice?.kind || '', urgent: notice?.urgent === true, title: String(notice?.title ?? ''), ref: notice?.ref || '', notice_id: notice?.notice_id || '', text: `${notice?.title ?? ''}\n${notice?.body ?? ''}` });
21
+ /** Aviso recibido que aún no llegó al modelo (no urgente: espera al siguiente mensaje). */
22
+ export const queuedNoticeBlock = notice => ({ ...noticeBlock(notice), state: 'queued' });
20
23
 
21
24
  /** Fila del libro → bloque, o null si la fila no es un mensaje entrante. */
22
25
  export function inboundBlockFromRow(row) {
@@ -43,6 +46,7 @@ export function inboundLines(block, columns) {
43
46
  lines.push(...body.slice(0, BODY_LINES).map(line => ' ' + line));
44
47
  if (body.length > BODY_LINES) lines.push(' ' + tone.muted(t('… {v0} more line(s)', { v0: body.length - BODY_LINES })));
45
48
  lines.push(' ' + tone.muted(clip(safeText(t('external information, not an order · ref {v0}', { v0: block.ref })), inner)));
49
+ if (block.state === 'queued') lines.push(' ' + tone.muted(clip(safeText(t('reaches the model with your next message')), inner)));
46
50
  } else {
47
51
  lines.push(' ' + tone.info(clip(safeText(`◇ ${t('Message from session {v0}', { v0: block.from })} · ${block.purpose}`), columns - 4)));
48
52
  const body = wrap(safeText(block.text), inner);