@milpa/comal 0.1.6 → 0.2.0
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 +19 -1
- package/backend/contracts/remote-commands-v1.json +12 -0
- package/backend/contracts/remote-workspace-v1.schema.json +287 -0
- package/governance/chrome-task-v10.json +323 -0
- package/governance/chrome-task-v11.json +355 -0
- package/governance/chrome-task-v12.json +387 -0
- package/governance/chrome-task-v13.json +419 -0
- package/governance/chrome-task-v3.json +99 -0
- package/governance/chrome-task-v4.json +131 -0
- package/governance/chrome-task-v5.json +163 -0
- package/governance/chrome-task-v6.json +195 -0
- package/governance/chrome-task-v7.json +227 -0
- package/governance/chrome-task-v8.json +259 -0
- package/governance/chrome-task-v9.json +291 -0
- package/governance/source-task-v1.json +1 -0
- package/governance/source-task-v2.json +1 -0
- package/package.json +15 -1
- package/src/agent-recovery-proof.mjs +12 -1
- package/src/agent.mjs +94 -30
- package/src/audit.mjs +21 -6
- package/src/auto-guard.mjs +48 -1
- package/src/chrome-candidate-recovery-proof.mjs +47 -0
- package/src/chrome-recovery-proof.mjs +6 -2
- package/src/chrome-source-syntax.mjs +93 -0
- package/src/cli.mjs +161 -56
- package/src/codex-provider.mjs +3 -2
- package/src/connection-settings.mjs +204 -0
- package/src/context-pressure.mjs +2 -1
- package/src/durable-agent-runs.mjs +5 -11
- package/src/event-channels.mjs +8 -0
- package/src/file-recovery-proof.mjs +11 -3
- package/src/file-recovery.mjs +24 -0
- package/src/gemini-provider.mjs +6 -1
- package/src/git-agent-revision.mjs +12 -0
- package/src/git-agent-workspaces.mjs +143 -0
- package/src/git-integration-lineage.mjs +20 -0
- package/src/git-integration-operations.mjs +200 -0
- package/src/git-integration-recovery-proof.mjs +70 -0
- package/src/git-page-cli-bridge.mjs +61 -0
- package/src/git-page-evidence.mjs +30 -0
- package/src/git-page-identity.mjs +10 -0
- package/src/git-page-proof.mjs +96 -0
- package/src/git-page-settlement.mjs +21 -0
- package/src/git-page-verification.mjs +134 -0
- package/src/git-plumbing.mjs +212 -0
- package/src/git-process-worker.mjs +50 -0
- package/src/git-source.mjs +110 -0
- package/src/git-workspaces.mjs +519 -0
- package/src/goals.mjs +13 -7
- package/src/house.mjs +246 -20
- package/src/i18n/cli-es.mjs +4 -0
- package/src/i18n/es.mjs +35 -0
- package/src/judges.mjs +27 -11
- package/src/mcp-runtime.mjs +15 -3
- package/src/model-controls.mjs +11 -1
- package/src/model-deadline.mjs +4 -3
- package/src/native-refusal.mjs +38 -0
- package/src/onboarding.mjs +88 -24
- package/src/openai-provider.mjs +14 -7
- package/src/operations.mjs +9 -2
- package/src/output-recovery.mjs +48 -0
- package/src/peer-tools.mjs +18 -8
- package/src/permission-controls.mjs +232 -0
- package/src/permission-rules.mjs +312 -0
- package/src/permissions.mjs +113 -30
- package/src/plugin-catalog.mjs +84 -0
- package/src/plugin-config.mjs +48 -6
- package/src/plugin-controls.mjs +332 -0
- package/src/plugin-notices.mjs +88 -0
- package/src/plugin-trust.mjs +104 -0
- package/src/plugins/chrome-lab/bridge.mjs +38 -12
- package/src/plugins/chrome-lab/client.mjs +103 -25
- package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
- package/src/plugins/chrome-lab/index.mjs +116 -26
- package/src/plugins/chrome-lab/project.mjs +30 -2
- package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
- package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
- package/src/plugins/open-relay/actions.mjs +205 -0
- package/src/plugins/open-relay/bound.mjs +47 -0
- package/src/plugins/open-relay/client.mjs +158 -0
- package/src/plugins/open-relay/dossier.mjs +119 -0
- package/src/plugins/open-relay/index.mjs +153 -0
- package/src/plugins/open-relay/lookup.mjs +45 -0
- package/src/plugins/open-relay/notices.mjs +79 -0
- package/src/plugins/open-relay/watch.mjs +127 -0
- package/src/plugins.mjs +72 -29
- package/src/private-fs.mjs +40 -0
- package/src/process-lock.mjs +24 -13
- package/src/provider-auth.mjs +14 -3
- package/src/provider.mjs +11 -2
- package/src/read-only-check.mjs +7 -3
- package/src/remote-control.mjs +29 -4
- package/src/remote-workspace.mjs +50 -0
- package/src/scope-ceiling.mjs +56 -0
- package/src/scoped-policy.mjs +39 -0
- package/src/secret-login.mjs +68 -0
- package/src/session-bus.mjs +41 -7
- package/src/session-projections-candidate.mjs +119 -0
- package/src/session-projections.mjs +119 -0
- package/src/session-read-cache.mjs +23 -3
- package/src/sessions.mjs +35 -11
- package/src/source-editor-role.mjs +13 -0
- package/src/source-ownership.mjs +73 -0
- package/src/source-proposal-proof.mjs +55 -0
- package/src/source-proposals.mjs +118 -0
- package/src/source-requests.mjs +148 -0
- package/src/source-task-proof.mjs +24 -0
- package/src/source-task-revision-proof.mjs +44 -0
- package/src/subagents.mjs +187 -87
- package/src/tool-packs.mjs +5 -5
- package/src/tools.mjs +82 -15
- package/src/trusted-context.mjs +5 -1
- package/src/tui/connection-transition.mjs +1 -1
- package/src/tui/controller.mjs +8 -4
- package/src/tui/discovery.mjs +24 -12
- package/src/tui/inbound-blocks.mjs +55 -0
- package/src/tui/present.mjs +504 -0
- package/src/tui/screen.mjs +49 -13
- package/src/windows-private.ps1 +61 -0
- package/src/work-assertions.mjs +6 -2
- package/src/work-plan.mjs +34 -8
- package/src/work-state.mjs +15 -9
- package/src/workflows/chrome-page-cases.mjs +156 -0
- package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
- package/src/workflows/chrome-page-diagnostics.mjs +113 -0
- package/src/workflows/chrome-page-law.mjs +48 -0
- package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
- package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
- package/src/workflows/chrome-page-task.mjs +429 -0
- package/src/workflows/chrome-page-verifier.mjs +123 -0
- package/src/workflows/chrome-source.mjs +73 -0
- package/src/workflows/chrome-supplied-inputs.mjs +43 -0
- package/src/workflows/chrome-task-adoption.mjs +122 -0
- package/src/workflows/chrome-task.mjs +379 -74
- package/src/workflows/source-task-v1.mjs +124 -0
- package/src/workflows/source-task-v2.mjs +227 -0
- package/src/workflows/source-task.mjs +13 -0
package/src/tool-packs.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { searchToolCatalog } from './tool-catalog.mjs';
|
|
|
6
6
|
const BROWSER = ['new_page','list_pages','navigate_page','list_console_messages','list_network_requests','take_snapshot'];
|
|
7
7
|
const CAPTURE = ['take_screenshot'];
|
|
8
8
|
const ADMIN = ['WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog'];
|
|
9
|
-
const SCOPED = {'sessions.peers':['ListAgents','SendMessage'],'provider.features':['ProviderCapabilities','ProviderSearch','AnalyzeMedia'],'workspace.inspect':['ListDirectory','FileInfo']};
|
|
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
10
|
const TEMPORARY=new Set([...ADMIN,...Object.values(SCOPED).flat()]);
|
|
11
11
|
// Native artifact binding needs the tiny metadata/hash contract on the initial
|
|
12
12
|
// surface. Search/acquire may still describe it; a lease never grants permission.
|
|
@@ -26,7 +26,7 @@ export class ToolPacks {
|
|
|
26
26
|
return [{ id: 'browser.verify', available: browser.length === BROWSER.length, count: browser.length, purpose: 'DOM, console, network and navigation; execution still judged.' },
|
|
27
27
|
{ 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
28
|
{ 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==='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.':'Current provider native search/image/video tools where supported.'})),
|
|
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.'})),
|
|
30
30
|
...[...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
31
|
...[...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
32
|
}
|
|
@@ -39,7 +39,7 @@ export class ToolPacks {
|
|
|
39
39
|
if (row.type !== 'tool_pack_lease' || row.source !== 'capability_host' || row.actor?.id !== actor.id || row.actor?.kind !== actor.kind) continue;
|
|
40
40
|
if (row.action === 'release') leases.delete(row.pack); else leases.set(row.pack, row);
|
|
41
41
|
}
|
|
42
|
-
const pinned = new Set(this.pinned().filter(name=>byName.has(name))), visible = new Set(pinned), tokens = new Map([...pinned].map(name=>[name,'pinned'])), active = [], expired = [], expiredDetails = [];
|
|
42
|
+
const pinned = new Set(this.pinned(sessionId,actor).filter(name=>byName.has(name))), visible = new Set(pinned), tokens = new Map([...pinned].map(name=>[name,'pinned'])), active = [], expired = [], expiredDetails = [];
|
|
43
43
|
for (const lease of leases.values()) {
|
|
44
44
|
const originPlan = lease.task_id ? session.rows.findLast(row => ['work_plan_set','work_checks_repaired'].includes(row.type) && row.source === 'work_host' && row.version === lease.plan_version) : null;
|
|
45
45
|
const generation = lease.work_digest || workDigest(originPlan?.tasks.find(task => task.id === lease.task_id));
|
|
@@ -48,7 +48,7 @@ export class ToolPacks {
|
|
|
48
48
|
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
49
|
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
50
|
}
|
|
51
|
-
const view = { visible, tokens, turn, work, state: { offers: this.offers(actor), required: work?.current?.capabilities || [], active, pinned: this.pinned().filter(name => byName.has(name)), expired, expired_details:expiredDetails, acquisition_grants_permission: false } };
|
|
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 } };
|
|
52
52
|
this.views.set(this.key(sessionId,actor), view); return view.state;
|
|
53
53
|
}
|
|
54
54
|
recoveryCall(operation, actor) {
|
|
@@ -125,7 +125,7 @@ export class ToolPacks {
|
|
|
125
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 } : {}) };
|
|
126
126
|
}
|
|
127
127
|
definition() {
|
|
128
|
-
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). 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.',
|
|
128
|
+
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.',
|
|
129
129
|
effects: packEffects,
|
|
130
130
|
input_schema: { type: 'object', properties: { action: { type: 'string', enum: ['list','search','acquire','release'] }, query: { type: 'string', minLength: 1, maxLength: 500 }, max_results: { type: 'integer', minimum: 1, maximum: 5 }, pack: { type: 'string', maxLength: 100 }, tools: { type: 'array', minItems: 1, maxItems: 12, uniqueItems: true, items: { type: 'string', maxLength: 128 } } }, required: ['action'], additionalProperties: false },
|
|
131
131
|
run: async (input, options) => {
|
package/src/tools.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, rename, unlink, mkdir, realpath, stat, readdir, opendir, open } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, rename, link, unlink, mkdir, realpath, stat, lstat, readdir, opendir, open } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
@@ -8,20 +8,27 @@ import { readOnlyBashCheck } from './read-only-check.mjs';
|
|
|
8
8
|
import { auditExecution } from './audit.mjs';
|
|
9
9
|
import { digest } from './operations.mjs';
|
|
10
10
|
import { recoverFileEffects } from './file-recovery.mjs';
|
|
11
|
+
import { SourceOwnership } from './source-ownership.mjs';
|
|
12
|
+
import { bindChromeSourceSyntax, chromeSourceSyntaxAdvice } from './chrome-source-syntax.mjs';
|
|
13
|
+
import {SourceProposals,sourceDefinitions} from './source-proposals.mjs';
|
|
14
|
+
import { registerNativeSourceRequests, nativeSourceRequestCapability, consumeNativeSourceRequest, settleNativeSourceRequest } from './source-requests.mjs';
|
|
15
|
+
const nativeOperations=new WeakSet();
|
|
11
16
|
|
|
12
17
|
const LIMIT = 64 * 1024, FILE_LIMIT = 1024 * 1024;
|
|
13
18
|
const HASH_LIMIT = 256 * 1024 * 1024, HASH_CHUNK = 128 * 1024, HASH_TIMEOUT = 10000;
|
|
14
19
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
15
20
|
const schema = (name, description, properties, required) => ({ name, description, input_schema: { type: 'object', properties, required, additionalProperties: false } });
|
|
16
21
|
const str = description => ({ type: 'string', description });
|
|
22
|
+
const sourceVersion = {type:'string',pattern:'^(?:absent|[a-f0-9]{64})$',description:'Optional frozen precondition: SHA-256 of the inspected bytes, or absent for create-only. Checked under native source ownership; never refreshed automatically.'};
|
|
17
23
|
export const toolDefinitions = [
|
|
18
24
|
schema('Read', 'Read missing/outdated UTF-8 file ranges with line numbers. Verified file_working_set pages already provide inspection: do not reread covered code just to satisfy an edit prerequisite.', { file_path: str('Workspace-relative or absolute path'), offset: { type: 'integer', minimum: 1 }, limit: { type: 'integer', minimum: 1, maximum: 2000 } }, ['file_path']),
|
|
19
|
-
schema('Write', 'Create/replace a UTF-8 file. Inspect existing bytes via Read or verified file_working_set.
|
|
20
|
-
schema('Edit', 'Replace an exact string in inspected code (Read or verified file_working_set).
|
|
25
|
+
schema('Write', 'Create/replace a UTF-8 file. Inspect existing bytes via Read or verified file_working_set. Native ownership lasts through the durable outcome. Live read proof and optional expected_sha256 are checked before replacing.', { file_path: str('File path'), content: str('Complete new content'), expected_sha256:sourceVersion }, ['file_path', 'content']),
|
|
26
|
+
schema('Edit', 'Replace an exact string in inspected code (Read or verified file_working_set). Native ownership, own receipt, live hash and optional expected_sha256 are checked. Must be unique unless replace_all is true.', { file_path: str('File path'), old_string: str('Exact nonempty text'), new_string: str('Replacement'), replace_all: { type: 'boolean' }, expected_sha256:sourceVersion }, ['file_path', 'old_string', 'new_string']),
|
|
21
27
|
schema('Glob', 'List workspace files matching *, ** or ?; omits .git, .comal, .harness, .analysis and node_modules.', { pattern: str('Example: src/**/*.mjs') }, ['pattern']),
|
|
22
28
|
schema('Grep', 'Search workspace text with ripgrep regular expressions.', { pattern: str('Regular expression'), path: str('Workspace path; defaults to .'), glob: str('Optional file glob') }, ['pattern']),
|
|
23
29
|
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 } }, []),
|
|
24
|
-
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.' } }, ['file_path']),
|
|
30
|
+
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
|
+
...sourceDefinitions,
|
|
25
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']),
|
|
26
33
|
];
|
|
27
34
|
|
|
@@ -40,18 +47,31 @@ function validate(name, input) {
|
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export class WorkspaceTools {
|
|
43
|
-
constructor({ cwd, policy, store, onPermission = () => {}, house = new House({ cwd }), actor = { id: 'local-agent', kind: 'agent' }, sessionId, record, select, frontier, capture, unavailable }) {
|
|
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 }) {
|
|
44
51
|
this.cwd = cwd; this.policy = policy; this.onPermission = onPermission; this.reads = new Map(); this.house = house;
|
|
45
52
|
this.store = store;
|
|
46
|
-
|
|
53
|
+
this.sourceOwnership = new SourceOwnership({cwd,store,directory:sourceLockDirectory});
|
|
54
|
+
this.sourceProposals=new SourceProposals(this);this.nativeSettlers=new Set();
|
|
55
|
+
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) }));
|
|
56
|
+
registerNativeSourceRequests(this, { enabled: nativeSourceRequests, isNative: name => nativeOperations.has(house.registry.get(name)) });
|
|
47
57
|
this.executor = house.bind({ actor, sessionId, policy, onPermission, record, resources: { workspace: this }, select, frontier, capture, unavailable });
|
|
48
58
|
this.executor.recoverInterrupted = options => this.recoverInterrupted(options);
|
|
59
|
+
this.executor.bindSourceScope = (id,accept) => this.sourceProposals.bindScope(id,accept);
|
|
60
|
+
this.executor.bindChromeSourceSyntax = (id, facts) => bindChromeSourceSyntax(this, id, facts);
|
|
61
|
+
this.executor.isNativeOperation = name => this.isNativeOperation(name);
|
|
62
|
+
this.executor.nativeSourceRequestCapability = name => nativeSourceRequestCapability(this, name, this.executor.catalogue());
|
|
63
|
+
const snapshot = this.executor.snapshot;
|
|
64
|
+
this.executor.snapshot = () => { const frame = snapshot(), catalogue = frame.catalogue(); return { ...frame, nativeSourceRequestCapability: name => nativeSourceRequestCapability(this, name, catalogue) }; };
|
|
49
65
|
this.recoverySessionId = sessionId; this.recoveryActor = actor;
|
|
50
66
|
}
|
|
51
67
|
setContext(context) { this.executor.setContext(context); }
|
|
52
68
|
getMode() { return this.executor.getMode(); }
|
|
53
69
|
snapshot() { return this.executor.snapshot(); }
|
|
54
70
|
execute(name, input, options) { return this.executor.execute(name, input, options); }
|
|
71
|
+
isNativeOperation(name){return nativeOperations.has(this.house.registry.get(name));}
|
|
72
|
+
nativeSourceRequestCapability(name){return this.executor.nativeSourceRequestCapability(name);}
|
|
73
|
+
registerSettlement(handler){this.nativeSettlers.add(handler);return()=>this.nativeSettlers.delete(handler);}
|
|
74
|
+
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; }
|
|
55
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; }
|
|
56
76
|
inside(file) { const relative = path.relative(this.cwd, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
|
|
57
77
|
async resolve(file, { create = false } = {}) {
|
|
@@ -69,6 +89,7 @@ export class WorkspaceTools {
|
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
if (!this.inside(canonical)) throw new Error('The symbolic link leaves the workspace.');
|
|
92
|
+
if (this.sourceOwnership.contains(canonical)) throw new Error('Native source ownership state is protected.');
|
|
72
93
|
const first = path.relative(this.cwd, canonical).split(path.sep)[0];
|
|
73
94
|
if (['.comal', '.harness'].includes(first)) throw new Error('Comal internal state is protected.');
|
|
74
95
|
return canonical;
|
|
@@ -82,17 +103,36 @@ export class WorkspaceTools {
|
|
|
82
103
|
catch(error){throw Object.assign(new Error('The file is not UTF-8 text; Read does not decode images or video. This does not deny AnalyzeMedia permission.'),{code:'READ_BINARY',cause:error});}
|
|
83
104
|
}
|
|
84
105
|
async atomicWrite(file, content) {
|
|
106
|
+
const transaction=this.sourceOwnership.transaction(file);
|
|
107
|
+
await transaction?.revalidate?.();
|
|
85
108
|
await mkdir(path.dirname(file), { recursive: true });
|
|
86
109
|
// Recheck the canonical parent after creating directories.
|
|
87
110
|
const target = await this.resolve(file, { create: true });
|
|
88
111
|
if (target !== file) throw new Error('The path changed during the operation.');
|
|
89
112
|
let mode = 0o644; try { mode = (await stat(file)).mode & 0o777; } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
90
113
|
const temp = path.join(path.dirname(file), `.comal-write-${randomUUID()}`);
|
|
91
|
-
try {
|
|
114
|
+
try {
|
|
115
|
+
await writeFile(temp, content, { flag: 'wx', mode });
|
|
116
|
+
if(transaction)await this.assertSourceVersion(file,transaction.beforeHash);
|
|
117
|
+
await transaction?.revalidate?.();
|
|
118
|
+
transaction?.signal?.throwIfAborted();
|
|
119
|
+
if(transaction)transaction.replacementStarted=true;
|
|
120
|
+
if(transaction?.createOnly){
|
|
121
|
+
// Link a complete temp file without replacing any existing entry. A
|
|
122
|
+
// racing nonparticipant cannot turn explicit create-only into overwrite.
|
|
123
|
+
try{await link(temp,file);}catch(error){if(error.code==='EEXIST')throw Object.assign(new Error('Create-only source appeared before publication; it was preserved.'),{code:'SOURCE_CHANGED'});throw error;}
|
|
124
|
+
}else await rename(temp, file);
|
|
125
|
+
}
|
|
92
126
|
finally { await unlink(temp).catch(() => {}); }
|
|
93
127
|
this.reads.set(file, hash(content));
|
|
94
128
|
}
|
|
95
|
-
async
|
|
129
|
+
async assertSourceVersion(file,expected){
|
|
130
|
+
let current=null;
|
|
131
|
+
try{current=hash(await this.text(file));}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
132
|
+
if(current!==expected)throw Object.assign(new Error('The source changed after preparation. No replacement was started. Inspect the new bytes and propose a new explicit change.'),{code:'SOURCE_CHANGED'});
|
|
133
|
+
if(await this.resolve(file,{create:true})!==file)throw Object.assign(new Error('The canonical destination changed during preparation.'),{code:'SOURCE_CHANGED'});
|
|
134
|
+
}
|
|
135
|
+
async #run(name, input, { signal, actor, sessionId, invocation_id, revalidate, nativeSourceRequest, observe = () => {}, recordWork = async () => {} } = {}) {
|
|
96
136
|
validate(name, input); signal?.throwIfAborted();
|
|
97
137
|
if (name === 'ListDirectory') {
|
|
98
138
|
const dir = await this.resolve(input.path || '.'), entries = []; let truncated = false, bytes = 0;
|
|
@@ -110,9 +150,17 @@ export class WorkspaceTools {
|
|
|
110
150
|
return JSON.stringify({ path: relative, entries, truncated });
|
|
111
151
|
}
|
|
112
152
|
if (name === 'FileInfo') {
|
|
113
|
-
const file = await this.resolve(input.file_path
|
|
153
|
+
const file = await this.resolve(input.file_path,{create:Boolean(input.allow_missing)});let info;
|
|
154
|
+
try{info=await stat(file,input.hash?{bigint:true}:undefined);}catch(error){
|
|
155
|
+
if(error.code!=='ENOENT'||!input.allow_missing)throw error;
|
|
156
|
+
// ENOENT through a dangling link is not an absent entry.
|
|
157
|
+
try{await lstat(file);throw Object.assign(new Error('A dangling source link is not an absent destination.'),{code:'SOURCE_CHANGED'});}catch(named){if(named.code!=='ENOENT')throw named;}
|
|
158
|
+
for(let ancestor=path.dirname(file);ancestor!==this.cwd&&this.inside(ancestor);ancestor=path.dirname(ancestor)){try{const entry=await lstat(ancestor);if(entry.isSymbolicLink())throw Object.assign(new Error('An unresolved ancestor link is not an absent source path.'),{code:'SOURCE_CHANGED'});}catch(named){if(named.code!=='ENOENT')throw named;}}
|
|
159
|
+
if(await this.resolve(input.file_path,{create:true})!==file)throw Object.assign(new Error('The absent destination changed.'),{code:'SOURCE_CHANGED'});
|
|
160
|
+
const result={file_path:path.relative(this.cwd,file),type:'absent',exists:false,sha256:null};observe({kind:'file_absent',...result});return JSON.stringify(result);
|
|
161
|
+
}
|
|
114
162
|
if (input.hash) {
|
|
115
|
-
const hashed = await hashWorkspaceFile(file, { signal, expected: info, verifyPath: async () => {
|
|
163
|
+
const hashed = await hashWorkspaceFile(file, { signal, expected: info, maxBytes:input.max_bytes??HASH_LIMIT, verifyPath: async () => {
|
|
116
164
|
if (await this.resolve(input.file_path) !== file) throw fileHashError('The file path changed while hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
|
|
117
165
|
} });
|
|
118
166
|
const result = { file_path: path.relative(this.cwd, file), type: 'file', bytes: hashed.bytes,
|
|
@@ -143,9 +191,19 @@ export class WorkspaceTools {
|
|
|
143
191
|
return text.split('\n').slice(offset - 1, offset - 1 + count).map((line, i) => `${offset + i}\t${line}`).join('\n').slice(0, LIMIT);
|
|
144
192
|
}
|
|
145
193
|
if (name === 'Write' || name === 'Edit') {
|
|
194
|
+
const request = consumeNativeSourceRequest(this, nativeSourceRequest, { operation: name, input, invocation_id, actor, sessionId });
|
|
195
|
+
if (request) await request.validate();
|
|
196
|
+
let transaction;
|
|
197
|
+
try {
|
|
146
198
|
const file = await this.resolve(input.file_path, { create: name === 'Write' });
|
|
199
|
+
transaction=await this.sourceOwnership.acquire(file,{invocation_id,sessionId,actor,operation:name,signal,recordWork});
|
|
147
200
|
let before = null;
|
|
148
201
|
try { before = await this.text(file); } catch (e) { if (e.code !== 'ENOENT' || name === 'Edit') throw e; }
|
|
202
|
+
transaction.revalidate=request ? async () => { await request.revalidate(); await revalidate?.(); await request.revalidate(); } : revalidate;
|
|
203
|
+
transaction.beforeHash=before===null?null:hash(before);
|
|
204
|
+
transaction.createOnly=input.expected_sha256==='absent'||request?.expected_sha256==='absent';
|
|
205
|
+
if (request) { if (file !== request.file) throw Object.assign(new Error('The original source-request target changed.'),{code:'SOURCE_CHANGED'}); await request.validate(transaction.beforeHash); }
|
|
206
|
+
if(input.expected_sha256!==undefined&&input.expected_sha256!==(transaction.beforeHash??'absent'))throw Object.assign(new Error('Source precondition changed; the original expected_sha256 was not replaced by a newer inspection. No replacement was started.'),{code:'SOURCE_CHANGED'});
|
|
149
207
|
if (before !== null && this.reads.get(file) !== hash(before) && this.store && sessionId) {
|
|
150
208
|
const audit = auditExecution(await this.store.load(sessionId), this.house.stateContext()), relative = path.relative(this.cwd, file), currentHash = hash(before);
|
|
151
209
|
// Recover only this actor's own read/write proof, still matched against
|
|
@@ -162,17 +220,26 @@ export class WorkspaceTools {
|
|
|
162
220
|
after = input.replace_all ? before.split(input.old_string).join(input.new_string) : before.replace(input.old_string, () => input.new_string);
|
|
163
221
|
}
|
|
164
222
|
if (Buffer.byteLength(after) > FILE_LIMIT) throw new Error('The result exceeds 1 MiB.');
|
|
165
|
-
const
|
|
223
|
+
const syntaxAdvice = await chromeSourceSyntaxAdvice(this, { sessionId, file, source: after });
|
|
224
|
+
const output=JSON.stringify({ file_path: path.relative(this.cwd, file), status: before === null ? 'created' : 'updated', bytes: Buffer.byteLength(after), before_sha256: before === null ? null : hash(before), after_sha256: hash(after), ...(name === 'Edit' ? { removed: input.old_string.slice(0, 1000), added: input.new_string.slice(0, 1000) } : {}), ...(syntaxAdvice ? { syntax_advice: syntaxAdvice } : {}) });
|
|
166
225
|
// Durable preparation is a prerequisite of the replacement, not a receipt
|
|
167
226
|
// claiming that the handler returned. Recovery can only observe its target.
|
|
168
|
-
const preparation={mechanism:'workspace-file-v1',operation:name,arguments_digest:digest(input),file_path:path.relative(this.cwd,file),before_sha256:before===null?null:hash(before),after_sha256:hash(after),output};
|
|
227
|
+
const preparation={mechanism:'workspace-file-v1',operation:name,arguments_digest:digest(input),file_path:path.relative(this.cwd,file),before_sha256:before===null?null:hash(before),after_sha256:hash(after),output,...(request?{source_request_id:request.request_id,source_request_digest:request.request_digest}:{})};
|
|
169
228
|
await recordWork({type:'file_effect_prepared',source:'workspace_host',preparation,preparation_digest:digest(preparation)});
|
|
229
|
+
signal?.throwIfAborted();
|
|
230
|
+
await transaction.revalidate?.();
|
|
231
|
+
await this.assertSourceVersion(file,transaction.beforeHash);
|
|
232
|
+
signal?.throwIfAborted();
|
|
170
233
|
await this.atomicWrite(file, after);
|
|
171
234
|
// Read back independently; a handler return value is not a verified write.
|
|
172
235
|
const observed = await this.text(file);
|
|
173
236
|
observe({ kind: 'file_write', file_path: path.relative(this.cwd, file), before_sha256: before === null ? null : hash(before), after_sha256: hash(observed), matches_requested: observed === after });
|
|
174
237
|
await recordWork({type:'file_effect_observed',source:'workspace_host',preparation_digest:digest(preparation),sha256:hash(observed),matches_requested:observed===after});
|
|
175
238
|
return output;
|
|
239
|
+
} catch(error) {
|
|
240
|
+
if(!transaction?.replacementStarted)observe({kind:'native_file_no_replacement',mechanism:'workspace-file-v1',phase:'before_destination_dispatch',destination_replaced:false,auxiliary_effects:'possible_directories_or_temporary_files'});
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
176
243
|
}
|
|
177
244
|
if (name === 'Bash') {
|
|
178
245
|
const check = readOnlyBashCheck(input.command);
|
|
@@ -218,7 +285,7 @@ export class WorkspaceTools {
|
|
|
218
285
|
|
|
219
286
|
const fileHashError = (message, code) => Object.assign(new Error(message), { code });
|
|
220
287
|
const sameFileVersion = (left, right) => ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every(key => left[key] === right[key]);
|
|
221
|
-
async function hashWorkspaceFile(file, { signal, expected, verifyPath }) {
|
|
288
|
+
async function hashWorkspaceFile(file, { signal, expected, verifyPath, maxBytes=HASH_LIMIT }) {
|
|
222
289
|
const deadline = AbortSignal.timeout(HASH_TIMEOUT), combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
223
290
|
const check = () => {
|
|
224
291
|
signal?.throwIfAborted();
|
|
@@ -230,7 +297,7 @@ async function hashWorkspaceFile(file, { signal, expected, verifyPath }) {
|
|
|
230
297
|
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
231
298
|
const before = await handle.stat({ bigint: true });
|
|
232
299
|
if (!before.isFile()) throw fileHashError('Hashing requires a regular file.', 'FILE_HASH_TYPE');
|
|
233
|
-
if (before.size > BigInt(
|
|
300
|
+
if (before.size > BigInt(maxBytes)) throw fileHashError(`File hashing exceeds its ${maxBytes} byte limit.`, 'FILE_HASH_LIMIT');
|
|
234
301
|
if (before.dev !== BigInt(expected.dev) || before.ino !== BigInt(expected.ino)) throw fileHashError('The file changed before hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
|
|
235
302
|
await verifyPath();
|
|
236
303
|
if (!sameFileVersion(before, await stat(file, { bigint: true }))) throw fileHashError('The file changed before hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
|
|
@@ -238,7 +305,7 @@ async function hashWorkspaceFile(file, { signal, expected, verifyPath }) {
|
|
|
238
305
|
stream = handle.createReadStream({ highWaterMark: HASH_CHUNK, autoClose: false, signal: combined });
|
|
239
306
|
for await (const chunk of stream) {
|
|
240
307
|
check(); bytes += chunk.length;
|
|
241
|
-
if (bytes >
|
|
308
|
+
if (bytes > maxBytes) throw fileHashError(`File hashing exceeds its ${maxBytes} byte limit.`, 'FILE_HASH_LIMIT');
|
|
242
309
|
if (signature.length < 32) signature = Buffer.concat([signature, chunk.subarray(0, 32 - signature.length)]);
|
|
243
310
|
digest.update(chunk);
|
|
244
311
|
}
|
package/src/trusted-context.mjs
CHANGED
|
@@ -9,10 +9,14 @@ const historicalInput = (name, input) => {
|
|
|
9
9
|
return { ...Object.fromEntries(Object.entries(input || {}).filter(([key]) => !fields.includes(key))), ...metadata };
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
export const isHumanIntentRecord = row => row.type === 'intent' && !row.source && typeof row.text === 'string';
|
|
13
|
+
|
|
12
14
|
export function trustedContext(rows) {
|
|
13
15
|
const intentions = [], actions = [];
|
|
14
16
|
for (const row of rows) {
|
|
15
|
-
|
|
17
|
+
// Direct human/goal intents are unsourced. Workflow and classifier records
|
|
18
|
+
// cannot become human authority, including in previously affected journals.
|
|
19
|
+
if (isHumanIntentRecord(row)) intentions.push(row.text);
|
|
16
20
|
// Legacy sessions predating intent records used only direct user prompts.
|
|
17
21
|
if (row.type === 'message' && !row.source && row.message.role === 'user' && typeof row.message.content === 'string') intentions.push(row.message.content);
|
|
18
22
|
if (row.type === 'message' && row.message.role === 'assistant') for (const block of Array.isArray(row.message.content) ? row.message.content : []) {
|
|
@@ -29,7 +29,7 @@ function action(value) {
|
|
|
29
29
|
const argument = value.argument.trim();
|
|
30
30
|
if (!argument || argument.length > 512 || /[\x00-\x1f\x7f-\x9f]/.test(argument)) throw new TypeError('Invalid connection argument.');
|
|
31
31
|
if (['model', 'effort'].includes(value.command) && /\s/.test(argument)) throw new TypeError('Connection selection must be one identifier.');
|
|
32
|
-
if (['login', 'logout'].includes(value.command) && !/^[a-z][a-z0-9_-]*(?: --api-key-env [A-Za-z_][A-Za-z0-9_]
|
|
32
|
+
if (['login', 'logout'].includes(value.command) && !/^[a-z][a-z0-9_-]*(?: --api-key-env [A-Za-z_][A-Za-z0-9_]*| --key)?$/.test(argument)) throw new TypeError('Invalid credential control action.');
|
|
33
33
|
return {command: value.command, argument};
|
|
34
34
|
}
|
|
35
35
|
|
package/src/tui/controller.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
13
13
|
import { connectionOptions } from './connection-options.mjs';
|
|
14
14
|
import { ConnectionTransitions, connectionAction, connectionFailureReason } from './connection-transition.mjs';
|
|
15
15
|
|
|
16
|
-
const IMMEDIATE = new Set(['cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
|
|
16
|
+
const IMMEDIATE = new Set(['raw', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', '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']);
|
|
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; }
|
|
@@ -143,7 +143,7 @@ export class InteractiveController {
|
|
|
143
143
|
const mcpMutation = match?.[1] === 'mcp' && match[2] && !/^(?:status|list|config)(?:\s|$)/.test(match[2]);
|
|
144
144
|
const workResume = match?.[1] === 'work' && match[2]?.trim() === 'resume';
|
|
145
145
|
const pluginMutation = match?.[1] === 'plugins' && match[2] && !/^(?:status|list)(?:\s|$)/.test(match[2]);
|
|
146
|
-
const modelMutation = match && (['model', 'effort', 'provider'].includes(match[1]) && match[2]?.trim() || ['login', 'logout'].includes(match[1]) && match[2]?.trim());
|
|
146
|
+
const modelMutation = match && (['model', 'effort', 'provider', 'max-tokens', 'context-window'].includes(match[1]) && match[2]?.trim() || ['login', 'logout'].includes(match[1]) && match[2]?.trim() || match[1] === 'config' && /^(?:set|save)\b/.test(match[2]?.trim() || ''));
|
|
147
147
|
const modelBusy = modelMutation && (this.peerProcessing || this.ui.busy || this.queue.running || this.runner.processing);
|
|
148
148
|
if (match && IMMEDIATE.has(match[1]) && !mcpMutation && !pluginMutation && !workResume && !modelBusy) { await this.command(match[1], match[2] || ''); return; }
|
|
149
149
|
if (match?.[1] === 'goal' && (!match[2] || ['status', 'pause', 'clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes(match[2].trim().toLowerCase()))) {
|
|
@@ -171,6 +171,7 @@ export class InteractiveController {
|
|
|
171
171
|
}
|
|
172
172
|
if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
|
|
173
173
|
if (command === 'help') { this.ui.showHelp(); return; }
|
|
174
|
+
if (command === 'raw') { if (arg.trim()) throw new Error(t('Use /raw without arguments.')); this.ui.toggleRaw?.(); return; }
|
|
174
175
|
if (command==='models' || ['model','login','provider','effort'].includes(command) && !arg.trim()) return this.chooseConnection(command,arg);
|
|
175
176
|
if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
|
|
176
177
|
if (command === 'resume' || command === 'clear') {
|
|
@@ -188,19 +189,22 @@ export class InteractiveController {
|
|
|
188
189
|
else throw new Error(t("Use /queue, /queue pause|resume|clear or /queue edit N."));
|
|
189
190
|
this.syncQueue(); return;
|
|
190
191
|
}
|
|
191
|
-
if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.
|
|
192
|
+
if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.showOutput(command,arg,output);return;}
|
|
192
193
|
if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
|
|
193
194
|
if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
|
|
194
195
|
if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
|
|
196
|
+
if (['max-tokens', 'context-window'].includes(command) && arg.trim() || command === 'config' && /^(?:set|save)\b/.test(arg.trim())) await this.requireIdle();
|
|
195
197
|
if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
|
|
196
198
|
const changing=(['cloud','model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command))) || (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()));
|
|
197
199
|
if(changing)this.switching=true;
|
|
198
200
|
try {
|
|
199
|
-
const output=await this.backend.control(command,arg);if(output)this.
|
|
201
|
+
const output=await this.backend.control(command,arg);if(output)this.showOutput(command,arg,output);
|
|
200
202
|
this.ui.updateMetadata(this.backend.metadata()); this.refreshDiscovery();
|
|
201
203
|
} finally {if(changing){this.switching=false;this.runner.kick();}}
|
|
202
204
|
|
|
203
205
|
}
|
|
206
|
+
// Salida de comando legible si la pantalla sabe presentarla; si no, aviso como antes.
|
|
207
|
+
showOutput(command,arg,output){if(this.ui.commandResult)this.ui.commandResult(command,arg,output);else this.ui.notice(output);}
|
|
204
208
|
connectionIdentity() {
|
|
205
209
|
const meta=this.backend.metadata();return JSON.stringify(['provider','authProvider','profile','model','effort'].map(key=>meta[key] || null));
|
|
206
210
|
}
|
package/src/tui/discovery.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { t } from '../i18n/index.mjs';
|
|
1
|
+
import { t, languageInfo } from '../i18n/index.mjs';
|
|
2
|
+
import { BYPASS_PERMISSION_MODE, BYPASS_LOCKED_MESSAGE } from '../permissions.mjs';
|
|
2
3
|
// Presentation-only discovery: supplied snapshots in, suggestions out. Never
|
|
3
4
|
// reads a workspace, contacts a provider, executes tools, or changes authority.
|
|
4
5
|
import { graphemes, safeText } from './text.mjs';
|
|
@@ -11,6 +12,7 @@ const COMMANDS = [
|
|
|
11
12
|
['operations', '', "Contracts and effects of operations"], ['sessions', '', "Open the session selector"],
|
|
12
13
|
['resume', 'UUID', "Resume a saved session"], ['clear', '', "Create a new conversation"],
|
|
13
14
|
['copy', '', "Copy the last complete response"], ['select', '', "Select text with the terminal"],
|
|
15
|
+
['raw', '', "Toggle the exact JSON of results and permissions"],
|
|
14
16
|
['compact', '', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
|
|
15
17
|
['state', '', "Request and current job status"], ['audit', '', "Verify ledger integrity"],
|
|
16
18
|
['board', '', "Open the task board"], ['work', 'resume|recover|review', "Continue work, recover Chrome or review a native failure locally"],
|
|
@@ -18,15 +20,17 @@ const COMMANDS = [
|
|
|
18
20
|
['models', '', "Choose a model from the current provider"],
|
|
19
21
|
['effort', "LEVEL", "Check or change the effort announced by the model"],
|
|
20
22
|
['capabilities', '[refresh]', "Support and provenance of capabilities"],
|
|
21
|
-
['permissions', "MODE", "
|
|
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"],
|
|
22
24
|
['queue', '[pause|resume|clear|edit N]', "Manage queued messages"],
|
|
23
25
|
['rename', "NAME", "Change the visible name of this session"], ['table', "FILE", "Display a CSV, TSV, JSON, or Markdown table"],
|
|
24
|
-
['mcp', "[action]", "View or configure MCP servers"], ['plugins', "[action]", "View plugins and control their lifecycle"],
|
|
26
|
+
['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'],
|
|
25
27
|
['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"],
|
|
26
28
|
['search', "QUERY", "Search the web"], ['list-agents', '', "Discover other live sessions"],
|
|
27
29
|
['send', "ID_OR_NAME TEXT", "Send a message to another session"], ['inbox', "[action]", "Control the mailbox between sessions"],
|
|
28
30
|
['providers', '', "View profiles and login providers"], ['provider', "PROFILE", "Switch to the configured profile"],
|
|
29
|
-
['login', "PROVIDER", "Authenticate a provider
|
|
31
|
+
['login', "PROVIDER [--key]", "Authenticate a provider; keys go in a hidden field"], ['logout', "PROVIDER", "Remove Comal credentials"],
|
|
32
|
+
['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"],
|
|
30
34
|
['exit', '', "Save and exit"], ['quit', '', "Save and exit"],
|
|
31
35
|
].map(([name, args, description]) => Object.freeze({ name, args, description }));
|
|
32
36
|
export const COMMAND_CATALOG = Object.freeze(COMMANDS);
|
|
@@ -120,9 +124,10 @@ function argumentItems(command, args, context) {
|
|
|
120
124
|
if (position === 0) {
|
|
121
125
|
if (command === 'rc' || command === 'remote-control')return named(['start','status','stop'],'Remote session projection; local tools and permissions');
|
|
122
126
|
if (command === 'cloud') return named(['login','status','logout'],'Comal account; separate from the model provider');
|
|
123
|
-
if (command === 'permissions') return named(['default', 'plan', 'acceptEdits', 'auto'], t("Permissions mode; does not change the model"));
|
|
127
|
+
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
|
+
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"));
|
|
124
129
|
if (command === 'effort') return named(context.effortLevels || context.capabilities?.effort?.levels || ['auto'], t("Level announced by the current model"));
|
|
125
|
-
if (command === 'provider') return [...entries(context.profiles), ...entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok'], 'id')];
|
|
130
|
+
if (command === 'provider') return [...entries(context.profiles), ...entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok', 'ollama'], 'id')];
|
|
126
131
|
if (command === 'model' || command === 'models') return entries(context.models, 'id');
|
|
127
132
|
if (command === 'resume') return entries(context.sessions, 'id');
|
|
128
133
|
if (command === 'agents') return [...named(['status','wait','stop','resume','background','continue'],t("Child run controls")),...entries(context.agents,'id')];
|
|
@@ -132,20 +137,24 @@ function argumentItems(command, args, context) {
|
|
|
132
137
|
if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
|
|
133
138
|
if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
|
|
134
139
|
if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
|
|
135
|
-
if (command === 'plugins') return named(['status', 'list', 'enable', 'disable', 'reload', 'run'], t("Action on plugins"));
|
|
140
|
+
if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
|
|
141
|
+
if (command === 'chrome-workflow') return named(['auto', 'raw'], 'Saved per project; applies to the entry of this session');
|
|
136
142
|
if (command === 'agent') return named(['explore', 'general','background'], t("Subagent type; complete the task afterwards"));
|
|
137
143
|
if (command === 'inbox') return named(['status', 'accept', 'hold', 'refuse', 'release'], t("Mailbox control between sessions"));
|
|
138
|
-
if (command === 'login' || command === 'logout') return [...(command === 'login' ? named(['status'], t("Check login status")) : []), ...entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok'], 'id')];
|
|
144
|
+
if (command === 'login' || command === 'logout') return [...(command === 'login' ? named(['status'], t("Check login status")) : []), ...entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok', 'ollama'], 'id')];
|
|
139
145
|
}
|
|
140
146
|
if (position === 1) {
|
|
141
147
|
if(command==='agents' && ['status','wait','stop','resume','background'].includes(first))return entries(context.agents,'id');
|
|
142
148
|
if (command === 'mcp' && ['reconnect', 'remove'].includes(first)) return entries(context.mcpServers);
|
|
143
|
-
if (command === 'plugins' && ['enable', 'disable', 'reload', 'run'].includes(first)) return entries(context.plugins, 'id');
|
|
149
|
+
if (command === 'plugins' && ['enable', 'disable', 'reload', 'run', 'config', 'allow', 'untrust', 'remove'].includes(first)) return entries(context.plugins, 'id');
|
|
150
|
+
if (command === 'plugins' && first === 'add') return named(['chrome-lab', 'open-relay'], 'Plugin bundled with Comal');
|
|
144
151
|
if (command === 'inbox' && first === 'release') return named(['all'], t("Release held messages; does not change hold mode"));
|
|
145
152
|
if (command === 'queue' && first === 'edit') return named((context.queue?.items || []).map((_, index) => String(index + 1)), t("Edit this pending message"));
|
|
146
|
-
if (command === 'login' && first === 'status') return entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok'], 'id');
|
|
147
|
-
if (command === 'login' && first !== 'status') return named(['--api-key-env'], t("Environment variable name, never the key"));
|
|
153
|
+
if (command === 'login' && first === 'status') return entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok', 'ollama'], 'id');
|
|
154
|
+
if (command === 'login' && first !== 'status') return [...(first === 'codex' ? [] : named(['--key'], t("Hidden field for the key; never the composer"))), ...named(['--api-key-env'], t("Environment variable name, never the key"))];
|
|
148
155
|
}
|
|
156
|
+
if (position === 2 && command === 'permissions' && ['allow', 'ask', 'deny', 'remove'].includes(first)) return named(languageInfo().language === 'es' ? ['--proyecto', '--usuario', '--sesion'] : ['--project', '--user', '--session'], t("Rule scope; the default is --project"));
|
|
157
|
+
if (position === 2 && command === 'plugins' && first === 'allow') return named(['observe', 'all', 'none'], 'Saved permissions; explicit denials still win');
|
|
149
158
|
if (position === 2 && command === 'plugins' && first === 'run') {
|
|
150
159
|
const plugin = (context.plugins || []).find(plugin => plugin.id === args[1]);
|
|
151
160
|
const prefix = `plugin__${args[1]}__`;
|
|
@@ -158,10 +167,12 @@ function argumentItems(command, args, context) {
|
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
function syntaxHint(command, context) {
|
|
170
|
+
if (command === 'permissions' && context.mode === BYPASS_PERMISSION_MODE) return t(BYPASS_LOCKED_MESSAGE);
|
|
171
|
+
if (command === 'permissions') return t("/permissions allow|ask|deny RULE [--project|--user|--session] · RULE: Tool, Bash(git commit:*), Edit(src/**), WebFetch(domain:x.com), mcp__srv__*, plugin:ID:observe");
|
|
161
172
|
if (command === 'model' && !(context.models || []).length) return t("Use /models to check the catalog; no requests are made when typing.");
|
|
162
173
|
if (command === 'effort' && !context.effortLevels && !context.capabilities?.effort?.levels) return t("Only auto is confirmed. /capabilities shows the available levels.");
|
|
163
174
|
if (command === 'plugins') return t("/plugins run ID OPERATION {JSON} · mutations wait their turn.");
|
|
164
|
-
if (command === 'login') return t("Never paste a key here; use --api-key-env VARIABLE.");
|
|
175
|
+
if (command === 'login') return t("Never paste a key here; Comal asks for it in a hidden field, or use --api-key-env VARIABLE.");
|
|
165
176
|
if (skills(context).some(skill => skill.value === command)) return t("Local skill: write the task after the name.");
|
|
166
177
|
const entry = COMMANDS.find(entry => entry.name === command);
|
|
167
178
|
return entry ? `/${entry.name}${entry.args ? ` ${t(entry.args)}` : ''} · ${t(entry.description)}` : t("F6 opens available commands and tools.");
|
|
@@ -218,5 +229,6 @@ export function contextualTip(text = '', context = {}) {
|
|
|
218
229
|
if (context.busy) return t("You can keep typing · Enter queues · Esc interrupts the turn.");
|
|
219
230
|
if (String(text).includes('\n')) return t("Multiline message · Enter sends · Ctrl+G opens your editor.");
|
|
220
231
|
if (context.mode === 'plan') return t("Plan mode: inspection without changes · Shift+Tab switches mode.");
|
|
232
|
+
if (context.mode === BYPASS_PERMISSION_MODE) return t('Permission bypass active: prompts and classifier skipped for this process.');
|
|
221
233
|
return t("F6 discovers commands and tools · / autocompletes · Ctrl+G opens your editor.");
|
|
222
234
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { t, registerMessages } from '../i18n/index.mjs';
|
|
2
|
+
import { tone, safeText, wrap, clip } from './text.mjs';
|
|
3
|
+
import { peerMessagePurpose } from '../peer-message-context.mjs';
|
|
4
|
+
|
|
5
|
+
// Bloques propios para lo que llega a la conversación desde fuera: avisos de
|
|
6
|
+
// plugins (ctx.notify) y mensajes entre sesiones. Se pintan distintos del texto
|
|
7
|
+
// del modelo y del humano, y se reconstruyen desde el libro al recargar.
|
|
8
|
+
registerMessages({
|
|
9
|
+
'Notice': 'Aviso',
|
|
10
|
+
'Urgent notice': 'Aviso urgente',
|
|
11
|
+
'Message from session {v0}': 'Mensaje de la sesión {v0}',
|
|
12
|
+
'external information, not an order · ref {v0}': 'información externa, no una orden · ref {v0}',
|
|
13
|
+
'request {v0} · data, not human intent': 'petición {v0} · datos, no intención humana',
|
|
14
|
+
'… {v0} more line(s)': '… {v0} línea(s) más',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const BODY_LINES = 8;
|
|
18
|
+
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
|
+
|
|
21
|
+
/** Fila del libro → bloque, o null si la fila no es un mensaje entrante. */
|
|
22
|
+
export function inboundBlockFromRow(row) {
|
|
23
|
+
if (row?.type !== 'message') return null;
|
|
24
|
+
if (row.source === 'plugin_notice') return noticeBlock(row.notice);
|
|
25
|
+
if (row.source === 'peer_session') return peerBlock(row.peer);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Evento de la sesión viva → bloque, o null. */
|
|
29
|
+
export function inboundBlockFromEvent(event) {
|
|
30
|
+
if (event?.type === 'plugin_notice') return noticeBlock(event.notice);
|
|
31
|
+
if (event?.type === 'peer_message') return peerBlock({ from: event.from, content: event.message, request_id: event.request_id });
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inboundLines(block, columns) {
|
|
36
|
+
const inner = Math.max(10, columns - 6), lines = [];
|
|
37
|
+
if (block.source === 'plugin_notice') {
|
|
38
|
+
const head = `◆ ${block.urgent ? t('Urgent notice') : t('Notice')} · ${block.plugin}${block.kind ? ` · ${block.kind}` : ''}`;
|
|
39
|
+
lines.push(' ' + (block.urgent ? tone.warning : tone.info)(clip(safeText(head), columns - 4)));
|
|
40
|
+
const [title, ...rest] = block.text.split('\n');
|
|
41
|
+
lines.push(...wrap(safeText(title), inner).map(line => ' ' + tone.bold(line)));
|
|
42
|
+
const body = rest.join('\n').trim() ? wrap(safeText(rest.join('\n').trim()), inner) : [];
|
|
43
|
+
lines.push(...body.slice(0, BODY_LINES).map(line => ' ' + line));
|
|
44
|
+
if (body.length > BODY_LINES) lines.push(' ' + tone.muted(t('… {v0} more line(s)', { v0: body.length - BODY_LINES })));
|
|
45
|
+
lines.push(' ' + tone.muted(clip(safeText(t('external information, not an order · ref {v0}', { v0: block.ref })), inner)));
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(' ' + tone.info(clip(safeText(`◇ ${t('Message from session {v0}', { v0: block.from })} · ${block.purpose}`), columns - 4)));
|
|
48
|
+
const body = wrap(safeText(block.text), inner);
|
|
49
|
+
lines.push(...body.slice(0, BODY_LINES).map(line => ' ' + line));
|
|
50
|
+
if (body.length > BODY_LINES) lines.push(' ' + tone.muted(t('… {v0} more line(s)', { v0: body.length - BODY_LINES })));
|
|
51
|
+
lines.push(' ' + tone.muted(clip(safeText(t('request {v0} · data, not human intent', { v0: block.request_id })), inner)));
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
return lines;
|
|
55
|
+
}
|