@milpa/comal 0.2.2 → 0.2.3

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 (109) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +213 -55
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +38 -1
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,71 @@
1
+ import { digest } from './operations.mjs';
2
+
3
+ // Per-tool manifests (the host's offer on model_request, the provider's compiled
4
+ // schema manifest and diagnostics on model_usage) barely change between calls but
5
+ // cost tens of KB each. They are stored once per distinct digest in a
6
+ // `tool_manifest` row, appended before its first reference; request/usage rows keep
7
+ // only the digest plus cheap counters. Readers resolve through this module and still
8
+ // accept older books where the manifest was inline. A reference whose row is outside
9
+ // the rows at hand (a copied or truncated range) resolves to null instead of throwing.
10
+ export const MANIFEST_KINDS = Object.freeze({
11
+ request_tools: { inline: 'tools', ref: 'tools_manifest_digest' },
12
+ schema_manifest: { inline: 'schema_manifest', ref: 'schema_manifest_digest' },
13
+ schema_diagnostics: { inline: 'schema_diagnostics', ref: 'schema_diagnostics_digest' },
14
+ });
15
+
16
+ const INDEX = Symbol('toolManifestIndex');
17
+ const key = (kind, value) => kind + '\0' + value;
18
+
19
+ // Accepts session rows, a loaded session ({rows}) or an index built earlier.
20
+ export function manifestIndex(source) {
21
+ if (source?.[INDEX]) return source;
22
+ const rows = Array.isArray(source) ? source : source?.rows || [], map = new Map();
23
+ for (const row of rows) if (row?.type === 'tool_manifest' && MANIFEST_KINDS[row.kind] && Array.isArray(row.entries) && !map.has(key(row.kind, row.digest))) map.set(key(row.kind, row.digest), row.entries);
24
+ return { [INDEX]: true, map };
25
+ }
26
+
27
+ export function resolveManifest(source, row, kind) {
28
+ const spec = MANIFEST_KINDS[kind];
29
+ if (!spec || !row) return null;
30
+ if (row[spec.inline] !== undefined) return row[spec.inline];
31
+ const ref = row[spec.ref];
32
+ if (typeof ref !== 'string') return null;
33
+ return manifestIndex(source).map.get(key(kind, ref)) ?? null;
34
+ }
35
+
36
+ export const requestTools = (source, row) => resolveManifest(source, row, 'request_tools');
37
+ export const usageSchemaManifest = (source, row) => resolveManifest(source, row, 'schema_manifest');
38
+ export const usageSchemaDiagnostics = (source, row) => resolveManifest(source, row, 'schema_diagnostics');
39
+
40
+ // Returns rows whose request/usage records carry their manifests inline again, for
41
+ // readers written against the old shape. Rows are copied only when hydrated. For a range
42
+ // of a book, pass the whole book (or its manifestIndex) as `source`: the manifest row may
43
+ // lie before the range.
44
+ export function inlineManifests(rows, source = rows) {
45
+ const index = manifestIndex(source);
46
+ return rows.map(row => {
47
+ if (row?.type !== 'model_request' && row?.type !== 'model_usage') return row;
48
+ let copy = row;
49
+ for (const [kind, spec] of Object.entries(MANIFEST_KINDS)) {
50
+ if (row[spec.inline] !== undefined || typeof row[spec.ref] !== 'string') continue;
51
+ const entries = index.map.get(key(kind, row[spec.ref]));
52
+ if (entries !== undefined) { if (copy === row) copy = { ...row }; copy[spec.inline] = entries; }
53
+ }
54
+ return copy;
55
+ });
56
+ }
57
+
58
+ // Appends the manifest row unless the session (or this process, for rows appended
59
+ // after `rows` was loaded) already holds that kind+digest. Returns the digest, or null
60
+ // when there is nothing to store.
61
+ export async function ensureManifest({ store, sessionId, rows = [], kind, entries, seen }) {
62
+ if (!MANIFEST_KINDS[kind]) throw new Error('Unknown tool manifest kind: ' + kind);
63
+ if (!Array.isArray(entries)) return null;
64
+ const value = digest(entries), id = key(kind, value);
65
+ if (seen?.has(id)) return value;
66
+ if (!rows.some(row => row?.type === 'tool_manifest' && row.kind === kind && row.digest === value)) {
67
+ await store.append(sessionId, { type: 'tool_manifest', kind, digest: value, count: entries.length, entries });
68
+ }
69
+ seen?.add(id);
70
+ return value;
71
+ }
@@ -2,11 +2,16 @@ import { workProjection, workDigest } from './work-plan.mjs';
2
2
  import { digest } from './operations.mjs';
3
3
  import { checkOperationCandidates } from './work-check-contracts.mjs';
4
4
  import { searchToolCatalog } from './tool-catalog.mjs';
5
+ import { VISUAL_AGENT_OPERATIONS } from './visual/names.mjs';
5
6
 
6
7
  const BROWSER = ['new_page','list_pages','navigate_page','list_console_messages','list_network_requests','take_snapshot'];
7
8
  const CAPTURE = ['take_screenshot'];
8
9
  const ADMIN = ['WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog'];
9
- const SCOPED = {'workspace.source':['SourceDraft','SourceRetain','SourceRead','SourcePublish'],'sessions.peers':['ListAgents','SendMessage'],'provider.features':['ProviderCapabilities','ProviderSearch','AnalyzeMedia'],'workspace.inspect':['ListDirectory','FileInfo'],'workspace.git':['GitInspect','GitIntegrationPrepare','GitIntegrationRevise','GitIntegrationVerify','GitIntegrationApply','GitIntegrationStatus','GitIntegrationEvidence']};
10
+ // A task capability asks for a pack only when it names one: an offered pack, mcp:SERVER or plugin:ID.
11
+ // visual:COMPONENT is served by visual.accept; any other capability is not a pack.
12
+ const requiredPacks = (capabilities = [], offers = []) => [...new Set(capabilities.map(capability => capability.startsWith('visual:') ? 'visual.accept'
13
+ : offers.some(offer => offer.id === capability) || /^(mcp|plugin):./.test(capability) ? capability : null).filter(Boolean))];
14
+ const SCOPED = {'workspace.source':['SourceDraft','SourceRetain','SourceRead','SourcePublish'],'sessions.peers':['ListAgents','SendMessage'],'provider.features':['ProviderCapabilities','ProviderSearch','AnalyzeMedia'],'workspace.inspect':['ListDirectory','FileInfo'],'visual.accept':[...VISUAL_AGENT_OPERATIONS],'workspace.serve':['WorkspaceServe'],'workspace.git':['GitInspect','GitIntegrationPrepare','GitIntegrationRevise','GitIntegrationVerify','GitIntegrationApply','GitIntegrationStatus','GitIntegrationEvidence']};
10
15
  const TEMPORARY=new Set([...ADMIN,...Object.values(SCOPED).flat()]);
11
16
  // Native artifact binding needs the tiny metadata/hash contract on the initial
12
17
  // surface. Search/acquire may still describe it; a lease never grants permission.
@@ -26,7 +31,7 @@ export class ToolPacks {
26
31
  return [{ id: 'browser.verify', available: browser.length === BROWSER.length, count: browser.length, purpose: 'DOM, console, network and navigation; execution still judged.' },
27
32
  { id: 'browser.capture', available: all.some(op => op.name === 'mcp__chrome-devtools__take_screenshot'), count: all.filter(op => this.inPack(op,'browser.capture')).length, purpose: 'Screenshot pixels in accepted receipts. Vision adapter sends only to a capable model; capture is not a visual verdict. Omit filePath to request inline image content.' },
28
33
  { id: 'house.audit', available: all.some(op=>ADMIN.includes(op.name)), count: all.filter(op=>ADMIN.includes(op.name)).length, purpose: 'Full forensic views; projected state and EvidenceRead already suffice for normal work.' },
29
- ...Object.entries(SCOPED).map(([id,names])=>({id,available:all.some(op=>names.includes(op.name)),count:all.filter(op=>names.includes(op.name)).length,purpose:id==='workspace.source'?'Retain rejected source or append plan-free data, inspect it, then publish via native Write/Edit with frozen versions and current permissions. No authority from storage.':id==='sessions.peers'?'Discover/message live sessions by provider capability; no authority transfer.':id==='workspace.inspect'?'List directory and inspect file size/media signature without shell.':id==='workspace.git'?'Inspect source and, where registered, combine owned Agent offers, verify declared checks and CAS reserved refs. Native permissions still apply; human checkout preserved.':'Current provider native search/image/video tools where supported.'})),
34
+ ...Object.entries(SCOPED).map(([id,names])=>({id,available:all.some(op=>names.includes(op.name)),count:all.filter(op=>names.includes(op.name)).length,purpose:id==='workspace.source'?'Retain rejected source or append plan-free data, inspect it, then publish via native Write/Edit with frozen versions and current permissions. No authority from storage.':id==='sessions.peers'?'Discover/message live sessions by provider capability; no authority transfer.':id==='workspace.inspect'?'List directory and inspect file size/media signature without shell.':id==='workspace.serve'?'Serve a workspace folder read-only on 127.0.0.1 for browser and extension tests, instead of a Bash server; closes with the session.':id==='visual.accept'?'Visual acceptance of UI components in visual/components: house captures vs human-approved references (pixels or independent vision judge). VisualVerify is the WorkPlan check of UI tasks.':id==='workspace.git'?'Inspect source and, where registered, combine owned Agent offers, verify declared checks and CAS reserved refs. Native permissions still apply; human checkout preserved.':'Current provider native search/image/video tools where supported.'})),
30
35
  ...[...servers].map(([server, tools]) => ({ id: 'mcp:' + server, available: true, count: tools.length, purpose: 'Search or select up to 12 exact names with ToolPack; omit tools only when count <=12.' })),
31
36
  ...[...plugins].map(([id, tools]) => ({ id: 'plugin:' + id, available: true, count: tools.length, purpose: tools[0].origin.discovery?.description || 'Search or select deferred plugin operations; acquisition grants no permission.' }))];
32
37
  }
@@ -48,7 +53,7 @@ export class ToolPacks {
48
53
  if (!scopeValid || !contractValid) { expired.push(lease.pack); expiredDetails.push({pack:lease.pack,task_id:lease.task_id || null,reason:!scopeValid?'scope_changed':'contracts_changed'}); continue; }
49
54
  lease.tools.forEach(tool => { visible.add(tool.name); if(!pinned.has(tool.name)) tokens.set(tool.name,(tokens.get(tool.name)||'')+':'+lease.audit.sha256); }); active.push({ id: lease.pack, tools: lease.tools.map(tool => tool.name), task_id: lease.task_id || null });
50
55
  }
51
- const view = { visible, tokens, turn, work, state: { offers: this.offers(actor), required: work?.current?.capabilities || [], active, pinned: this.pinned(sessionId,actor).filter(name => byName.has(name)), expired, expired_details:expiredDetails, acquisition_grants_permission: false } };
56
+ const view = { visible, tokens, turn, work, state: { offers: this.offers(actor), required: requiredPacks(work?.current?.capabilities, this.offers(actor)), active, pinned: this.pinned(sessionId,actor).filter(name => byName.has(name)), expired, expired_details:expiredDetails, acquisition_grants_permission: false } };
52
57
  this.views.set(this.key(sessionId,actor), view); return view.state;
53
58
  }
54
59
  recoveryCall(operation, actor) {
@@ -59,7 +64,7 @@ export class ToolPacks {
59
64
  unavailable(operation, context) {
60
65
  if (this.visible(operation,context.sessionId,context.actor)) return;
61
66
  const recovery = this.recoveryCall(operation,context.actor); if (!recovery) return;
62
- return {code:'TOOL_PACK_REQUIRED',message:`Herramienta no disponible: pack no adquirido o expirado para esta etapa/actor. No se ejecutó; no es una denegación del permiso de la operación. Ejecuta recovery_call y espera el siguiente catálogo: ${JSON.stringify(recovery)}. Los permisos se juzgan por separado.`};
67
+ return {code:'TOOL_PACK_REQUIRED',message:`Herramienta no disponible: pack no adquirido o expirado para esta etapa/actor. No se ejecutó; no es una denegación del permiso de la operación y los permisos se juzgan por separado. Ejecuta recovery_call y espera el siguiente catálogo: ${JSON.stringify({recovery_call:recovery})}`};
63
68
  }
64
69
  async notifyChanges(sessionId, actor) {
65
70
  const state = await this.prepare(sessionId,actor), session = await this.store.load(sessionId), view = this.views.get(this.key(sessionId,actor));
@@ -122,7 +127,11 @@ export class ToolPacks {
122
127
  finally { clearTimeout(timer); controller.abort(); }
123
128
  }
124
129
  options.signal?.throwIfAborted(); await this.prepare(options.sessionId, options.actor);
125
- return { ...searchToolCatalog(this.catalogue(options.actor), { ...input, inPack: (op, pack) => this.inPack(op, pack), acquireCall: name => this.recoveryCall(name, options.actor) }), servers: states().filter(item => !selected || item.name === selected).slice(0, 12), ...(refresh ? { refresh } : {}) };
130
+ // Tools the query names exactly that this request already offers need no search or acquisition.
131
+ const named = new Set(input.query.split(/[^A-Za-z0-9_]+/).filter(Boolean));
132
+ const availableNow = this.house.registry.catalogue({ actor: options.actor, surface: 'agent' }).map(op => op.name).filter(name => named.has(name) && this.visible(name, options.sessionId, options.actor));
133
+ const now = availableNow.length ? { available_now: availableNow, next_action: `${availableNow.join(', ')} ya está disponible en esta petición: llámala directamente, sin adquirir nada.` } : {};
134
+ return { ...searchToolCatalog(this.catalogue(options.actor), { ...input, inPack: (op, pack) => this.inPack(op, pack), acquireCall: name => this.recoveryCall(name, options.actor) }), ...now, servers: states().filter(item => !selected || item.name === selected).slice(0, 12), ...(refresh ? { refresh } : {}) };
126
135
  }
127
136
  definition() {
128
137
  return { name: 'ToolPack', description: 'Discover/acquire/release temporary schemas (browser.verify, browser.capture, mcp:SERVER, plugin:ID, house.audit, sessions.peers, provider.features, workspace.inspect, workspace.git). search with query returns <=5 ranked canonical tools and exact acquire_call; search does not acquire. list with pack returns its tool names; list without pack returns offers only. browser.capture supplies inline screenshots; browser.verify supplies navigation/DOM snapshot/console/network ONLY. For interaction acquire mcp:chrome-devtools with tools:["mcp__chrome-devtools__click"] or ["mcp__chrome-devtools__evaluate_script"] before use. Acquired schemas appear NEXT request. Select <=12 canonical names or unique short tool names scoped to that pack; the host returns canonical names. Missing server uses separate MCP config/connection judgment. No permission grant.',
@@ -132,7 +141,14 @@ export class ToolPacks {
132
141
  const { sessionId, actor } = options;
133
142
  await this.prepare(sessionId,actor);
134
143
  if (input.action === 'search') return JSON.stringify(await this.search(input, options));
135
- if (input.action === 'list') return JSON.stringify({ ...this.views.get(this.key(sessionId,actor)).state, ...(input.pack ? { tools: this.catalogue(actor).filter(op => this.inPack(op,input.pack)).map(op => op.name) } : {}) });
144
+ if (input.action === 'list') {
145
+ const state = this.views.get(this.key(sessionId,actor)).state;
146
+ if (!input.pack) return JSON.stringify(state);
147
+ const tools = this.catalogue(actor).filter(op => this.inPack(op,input.pack)).map(op => op.name);
148
+ // A plan capability such as visual:COMPONENT is not a pack; say so instead of an empty list.
149
+ const known = tools.length || (state.offers || []).some(offer => offer.id === input.pack);
150
+ return JSON.stringify({ ...state, tools, ...(known ? {} : { unknown_pack: input.pack, next_action: `No hay un pack ${input.pack}. Usa uno de offers[].id (${(state.offers || []).map(offer => offer.id).join(', ')}); las capabilities de una tarea del plan no son packs.` }) });
151
+ }
136
152
  if (!input.pack) throw new Error('ToolPack requiere pack.');
137
153
  if (input.action === 'release') {
138
154
  await options.recordWork({ type: 'tool_pack_lease', source: 'capability_host', action: 'release', pack: input.pack });
@@ -24,7 +24,13 @@ const schemas = (node, path = '') => {
24
24
 
25
25
  // A transport may remove dialect metadata only under an explicit identical dialect, never assertions.
26
26
  // Missing capability information is explicitly unverified passthrough, not an invented guarantee.
27
- export function compileToolSchemas(tools, { provider = 'unknown', capabilities = {}, catalogue = [] } = {}) {
27
+ // Exception, opted in by an adapter whose API rejects them (Anthropic: "input_schema does not support
28
+ // oneOf, allOf, or anyOf at the top level"): top-level combinators leave the transport schema, are
29
+ // recorded as a validation-changing transform, and are restated in the description. The host contract
30
+ // (Ajv over the canonical schema) keeps enforcing them; the provider only loses a hint.
31
+ const TOP_LEVEL_COMBINATORS = ['allOf', 'anyOf', 'oneOf'];
32
+ const HOISTED_NOTE_LIMIT = 1500;
33
+ export function compileToolSchemas(tools, { provider = 'unknown', capabilities = {}, catalogue = [], hoistTopLevelCombinators = false } = {}) {
28
34
  const policy = normalizeToolSchemaCapabilities(capabilities.tool_schema), diagnostics = [], manifest = [];
29
35
  const compiled = tools.map(tool => {
30
36
  if (!tool?.input_schema || typeof tool.input_schema !== 'object' || Array.isArray(tool.input_schema)) throw Object.assign(new Error('Definición de herramienta sin schema de entrada válido; no se envió al proveedor.'), { code: 'TOOL_SCHEMA_UNSUPPORTED', details: { provider, diagnostics: [{ name: tool?.name || null, status: 'unsupported', rejected: [{ path: '/', reason: 'missing_or_invalid_input_schema' }] }], executed: false } });
@@ -40,11 +46,17 @@ export function compileToolSchemas(tools, { provider = 'unknown', capabilities =
40
46
  if (policy.transport_dialect !== transport.$schema) rejected.push({ keyword: '$schema', path: '/$schema', reason: 'cannot_remove_dialect_without_identical_transport_dialect' });
41
47
  else { delete transport.$schema; transforms.push({ path: '/$schema', action: 'omit_identical_dialect_annotation', changes_validation: false }); }
42
48
  }
49
+ let description = tool.description;
50
+ if (hoistTopLevelCombinators) {
51
+ const hoisted = Object.fromEntries(TOP_LEVEL_COMBINATORS.filter(keyword => Object.hasOwn(transport, keyword)).map(keyword => [keyword, transport[keyword]]));
52
+ for (const keyword of Object.keys(hoisted)) { delete transport[keyword]; transforms.push({ path: '/' + keyword, action: 'hoist_top_level_combinator_to_host', changes_validation: true, enforced_by: 'host_contract' }); }
53
+ if (Object.keys(hoisted).length) { const note = JSON.stringify(hoisted); description = `${description || ''}\nInput must also satisfy (host-validated): ${note.length > HOISTED_NOTE_LIMIT ? note.slice(0, HOISTED_NOTE_LIMIT) + '…' : note}`; }
54
+ }
43
55
  const status = rejected.length ? 'unsupported' : policy.supported_keywords || policy.unsupported_keywords || policy.dialects ? 'compatible_with_declared_capabilities' : 'unverified_passthrough';
44
56
  diagnostics.push({ name: tool.name, provider, status, transforms, rejected, authority: 'transport_compatibility_only' });
45
57
  const contract = catalogue.find(item => item.name === tool.name);
46
58
  manifest.push({ name: tool.name, provider, upstream_schema_digest: contract?.origin?.schema_provenance?.upstream_digest || null, canonical_schema_digest: contract?.input_schema ? digest(contract.input_schema) : null, offered_schema_digest: digest(canonical), transport_schema_digest: digest(transport), status });
47
- return { ...tool, input_schema: transport };
59
+ return { ...tool, ...(description !== tool.description ? { description } : {}), input_schema: transport };
48
60
  });
49
61
  if (diagnostics.some(item => item.status === 'unsupported')) throw Object.assign(new Error('El proveedor no admite el schema de herramientas; no se enviaron ni debilitaron restricciones. ' + diagnostics.filter(item => item.status === 'unsupported').map(item => item.name).join(', ')), { code: 'TOOL_SCHEMA_UNSUPPORTED', details: { provider, diagnostics, manifest, executed: false } });
50
62
  return { tools: compiled, diagnostics, manifest };
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]', 'Open this local session on the web'], ['rc', '[start|status|stop|relay 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,9 @@ 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 (position === 1 && (command === 'rc' || command === 'remote-control') && first === 'relay') return named(['on', 'off'], 'on [30m|2h|8h] arms in memory for this /rc session; off disarms');
124
126
  if (position === 0) {
125
- if (command === 'rc' || command === 'remote-control')return named(['start','status','stop'],'Remote session projection; local tools and permissions');
127
+ 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)')];
126
128
  if (command === 'cloud') return named(['login','status','logout'],'Comal account; separate from the model provider');
127
129
  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
130
  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 +139,8 @@ function argumentItems(command, args, context) {
137
139
  if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
138
140
  if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
139
141
  if (command === 'swarm') return named(['strip'], t("Toggle the compact swarm summary next to the conversation"));
142
+ if (command === 'relay') return named(['status', 'new', 'start', 'stop', 'unlink', 'hub'], t("Swarm project of this repo; every step is a card you confirm"));
143
+ if (command === 'timing') return named(['on', 'off'], t("Show or hide response timing after each answer"));
140
144
  if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
141
145
  if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
142
146
  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);