@milpa/comal 0.2.1 → 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.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +232 -63
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +133 -2
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +99 -6
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Fase 4 de open-relay: el host lleva al panel /swarm de la TUI la foto que el plugin
|
|
2
|
+
// publica con ctx.provide('open-relay.swarm-view'). Sin turnos del modelo ni herramientas:
|
|
3
|
+
// el plugin avisa (su vigilancia de events.wait vio un cambio), el host copia la foto como
|
|
4
|
+
// datos planos y la pantalla la vuelve a limpiar al pintarla. Sólo se acepta el servicio si
|
|
5
|
+
// su dueño es el plugin open-relay. Si el plugin no está, está apagado o falló, la vista lo
|
|
6
|
+
// dice para que el panel explique por qué y cómo configurarlo.
|
|
7
|
+
import { SWARM_VIEW_SERVICE } from './plugins/open-relay/panel.mjs';
|
|
8
|
+
export { SWARM_VIEW_SERVICE };
|
|
9
|
+
export const SWARM_VIEW_PLUGIN = 'open-relay';
|
|
10
|
+
const MAX_BYTES = 512 * 1024;
|
|
11
|
+
|
|
12
|
+
function plain(view) {
|
|
13
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) return null;
|
|
14
|
+
let text; try { text = JSON.stringify(view); } catch { return null; }
|
|
15
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Por qué no hay foto, según el estado del plugin en el runtime. */
|
|
20
|
+
export function swarmViewUnavailable(status, plugin = SWARM_VIEW_PLUGIN) {
|
|
21
|
+
const item = status?.plugins?.find(entry => entry.id === plugin);
|
|
22
|
+
if (!item) return { available: false, reason: 'not_configured' };
|
|
23
|
+
const reason = { disabled: 'disabled', pending_trust: 'pending_trust', failed: 'failed', loading: 'loading', registered: 'disabled', active: 'no_service' }[item.state] || 'disabled';
|
|
24
|
+
return { available: false, reason, ...(item.error ? { error: String(item.error).slice(0, 600) } : {}) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Conecta el servicio con onView(view). Devuelve { refresh, close }. onView recibe
|
|
29
|
+
* { available:true, ...foto } o { available:false, reason, error? }.
|
|
30
|
+
*/
|
|
31
|
+
export function connectSwarmView({ plugins, onView, plugin = SWARM_VIEW_PLUGIN }) {
|
|
32
|
+
let bound, unsubscribe = null, closed = false, last = null;
|
|
33
|
+
const emit = view => { if (closed) return; const key = JSON.stringify(view); if (key === last) return; last = key; try { onView(view); } catch {} };
|
|
34
|
+
const bind = () => {
|
|
35
|
+
if (closed) return;
|
|
36
|
+
const service = plugins.service(SWARM_VIEW_SERVICE), value = service?.owner === plugin ? service.value : null;
|
|
37
|
+
if (value && value === bound) return;
|
|
38
|
+
try { unsubscribe?.(); } catch {}
|
|
39
|
+
unsubscribe = null; bound = value;
|
|
40
|
+
if (!value || typeof value.subscribe !== 'function' || typeof value.snapshot !== 'function') { emit(swarmViewUnavailable(plugins.status(), plugin)); return; }
|
|
41
|
+
const deliver = view => { if (closed || bound !== value) return; const copy = plain(view); emit(copy ? { ...copy, available: true } : { available: false, reason: 'invalid' }); };
|
|
42
|
+
try { unsubscribe = value.subscribe(deliver); deliver(value.snapshot()); }
|
|
43
|
+
catch (error) { emit({ available: false, reason: 'failed', error: String(error?.message || error).slice(0, 600) }); }
|
|
44
|
+
};
|
|
45
|
+
const stop = plugins.watchServices(bind);
|
|
46
|
+
bind();
|
|
47
|
+
return {
|
|
48
|
+
refresh: bind,
|
|
49
|
+
/** La foto actual, sin esperar al próximo aviso (p. ej. /swarm fuera de la TUI). */
|
|
50
|
+
current() { const service = plugins.service(SWARM_VIEW_SERVICE); if (service?.owner !== plugin) return swarmViewUnavailable(plugins.status(), plugin); try { const copy = plain(service.value.snapshot()); return copy ? { ...copy, available: true } : { available: false, reason: 'invalid' }; } catch (error) { return { available: false, reason: 'failed', error: String(error?.message || error).slice(0, 600) }; } },
|
|
51
|
+
close() { closed = true; stop(); try { unsubscribe?.(); } catch {} unsubscribe = null; },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|
package/src/tool-packs.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)}
|
|
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
|
-
|
|
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')
|
|
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 });
|
package/src/tool-schema.mjs
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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}={}) {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
268
|
-
|
|
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(
|
|
276
|
-
if (entry.isDirectory()) await walk(file, depth + 1);
|
|
277
|
-
else if (entry.isFile() && matcher.test(relative)) files.push(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
if (
|
|
362
|
-
if (
|
|
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}: ${
|
|
382
|
-
else resolve(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
|
}
|
|
@@ -14,6 +14,16 @@ const MESSAGES = Object.freeze({
|
|
|
14
14
|
uncertain: 'Connection change could not be confirmed. Check the active connection before trying again.',
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
// Next step shown with a failed change. Built from the classified reason only,
|
|
18
|
+
// never from the upstream error text.
|
|
19
|
+
export const CONNECTION_REASON_HINTS = Object.freeze({
|
|
20
|
+
authentication: 'Sign in or check the configured credential environment variable.',
|
|
21
|
+
timeout: 'The model server timed out.',
|
|
22
|
+
unavailable: 'The model server could not be reached.',
|
|
23
|
+
unsupported: 'The active model does not support this setting.',
|
|
24
|
+
unknown: 'The change could not be verified.',
|
|
25
|
+
});
|
|
26
|
+
|
|
17
27
|
function label(value) {
|
|
18
28
|
if (typeof value !== 'string') return '';
|
|
19
29
|
// Never render terminal escapes or URL credentials/query strings in a status.
|