@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.
Files changed (137) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/backend/contracts/remote-workspace-v1.schema.json +287 -0
  4. package/governance/chrome-task-v10.json +323 -0
  5. package/governance/chrome-task-v11.json +355 -0
  6. package/governance/chrome-task-v12.json +387 -0
  7. package/governance/chrome-task-v13.json +419 -0
  8. package/governance/chrome-task-v3.json +99 -0
  9. package/governance/chrome-task-v4.json +131 -0
  10. package/governance/chrome-task-v5.json +163 -0
  11. package/governance/chrome-task-v6.json +195 -0
  12. package/governance/chrome-task-v7.json +227 -0
  13. package/governance/chrome-task-v8.json +259 -0
  14. package/governance/chrome-task-v9.json +291 -0
  15. package/governance/source-task-v1.json +1 -0
  16. package/governance/source-task-v2.json +1 -0
  17. package/package.json +15 -1
  18. package/src/agent-recovery-proof.mjs +12 -1
  19. package/src/agent.mjs +94 -30
  20. package/src/audit.mjs +21 -6
  21. package/src/auto-guard.mjs +48 -1
  22. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  23. package/src/chrome-recovery-proof.mjs +6 -2
  24. package/src/chrome-source-syntax.mjs +93 -0
  25. package/src/cli.mjs +161 -56
  26. package/src/codex-provider.mjs +3 -2
  27. package/src/connection-settings.mjs +204 -0
  28. package/src/context-pressure.mjs +2 -1
  29. package/src/durable-agent-runs.mjs +5 -11
  30. package/src/event-channels.mjs +8 -0
  31. package/src/file-recovery-proof.mjs +11 -3
  32. package/src/file-recovery.mjs +24 -0
  33. package/src/gemini-provider.mjs +6 -1
  34. package/src/git-agent-revision.mjs +12 -0
  35. package/src/git-agent-workspaces.mjs +143 -0
  36. package/src/git-integration-lineage.mjs +20 -0
  37. package/src/git-integration-operations.mjs +200 -0
  38. package/src/git-integration-recovery-proof.mjs +70 -0
  39. package/src/git-page-cli-bridge.mjs +61 -0
  40. package/src/git-page-evidence.mjs +30 -0
  41. package/src/git-page-identity.mjs +10 -0
  42. package/src/git-page-proof.mjs +96 -0
  43. package/src/git-page-settlement.mjs +21 -0
  44. package/src/git-page-verification.mjs +134 -0
  45. package/src/git-plumbing.mjs +212 -0
  46. package/src/git-process-worker.mjs +50 -0
  47. package/src/git-source.mjs +110 -0
  48. package/src/git-workspaces.mjs +519 -0
  49. package/src/goals.mjs +13 -7
  50. package/src/house.mjs +246 -20
  51. package/src/i18n/cli-es.mjs +4 -0
  52. package/src/i18n/es.mjs +35 -0
  53. package/src/judges.mjs +27 -11
  54. package/src/mcp-runtime.mjs +15 -3
  55. package/src/model-controls.mjs +11 -1
  56. package/src/model-deadline.mjs +4 -3
  57. package/src/native-refusal.mjs +38 -0
  58. package/src/onboarding.mjs +88 -24
  59. package/src/openai-provider.mjs +14 -7
  60. package/src/operations.mjs +9 -2
  61. package/src/output-recovery.mjs +48 -0
  62. package/src/peer-tools.mjs +18 -8
  63. package/src/permission-controls.mjs +232 -0
  64. package/src/permission-rules.mjs +312 -0
  65. package/src/permissions.mjs +113 -30
  66. package/src/plugin-catalog.mjs +84 -0
  67. package/src/plugin-config.mjs +48 -6
  68. package/src/plugin-controls.mjs +332 -0
  69. package/src/plugin-notices.mjs +88 -0
  70. package/src/plugin-trust.mjs +104 -0
  71. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  72. package/src/plugins/chrome-lab/client.mjs +103 -25
  73. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  74. package/src/plugins/chrome-lab/index.mjs +116 -26
  75. package/src/plugins/chrome-lab/project.mjs +30 -2
  76. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  77. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  78. package/src/plugins/open-relay/actions.mjs +205 -0
  79. package/src/plugins/open-relay/bound.mjs +47 -0
  80. package/src/plugins/open-relay/client.mjs +158 -0
  81. package/src/plugins/open-relay/dossier.mjs +119 -0
  82. package/src/plugins/open-relay/index.mjs +153 -0
  83. package/src/plugins/open-relay/lookup.mjs +45 -0
  84. package/src/plugins/open-relay/notices.mjs +79 -0
  85. package/src/plugins/open-relay/watch.mjs +127 -0
  86. package/src/plugins.mjs +72 -29
  87. package/src/private-fs.mjs +40 -0
  88. package/src/process-lock.mjs +24 -13
  89. package/src/provider-auth.mjs +14 -3
  90. package/src/provider.mjs +11 -2
  91. package/src/read-only-check.mjs +7 -3
  92. package/src/remote-control.mjs +29 -4
  93. package/src/remote-workspace.mjs +50 -0
  94. package/src/scope-ceiling.mjs +56 -0
  95. package/src/scoped-policy.mjs +39 -0
  96. package/src/secret-login.mjs +68 -0
  97. package/src/session-bus.mjs +41 -7
  98. package/src/session-projections-candidate.mjs +119 -0
  99. package/src/session-projections.mjs +119 -0
  100. package/src/session-read-cache.mjs +23 -3
  101. package/src/sessions.mjs +35 -11
  102. package/src/source-editor-role.mjs +13 -0
  103. package/src/source-ownership.mjs +73 -0
  104. package/src/source-proposal-proof.mjs +55 -0
  105. package/src/source-proposals.mjs +118 -0
  106. package/src/source-requests.mjs +148 -0
  107. package/src/source-task-proof.mjs +24 -0
  108. package/src/source-task-revision-proof.mjs +44 -0
  109. package/src/subagents.mjs +187 -87
  110. package/src/tool-packs.mjs +5 -5
  111. package/src/tools.mjs +82 -15
  112. package/src/trusted-context.mjs +5 -1
  113. package/src/tui/connection-transition.mjs +1 -1
  114. package/src/tui/controller.mjs +8 -4
  115. package/src/tui/discovery.mjs +24 -12
  116. package/src/tui/inbound-blocks.mjs +55 -0
  117. package/src/tui/present.mjs +504 -0
  118. package/src/tui/screen.mjs +49 -13
  119. package/src/windows-private.ps1 +61 -0
  120. package/src/work-assertions.mjs +6 -2
  121. package/src/work-plan.mjs +34 -8
  122. package/src/work-state.mjs +15 -9
  123. package/src/workflows/chrome-page-cases.mjs +156 -0
  124. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  125. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  126. package/src/workflows/chrome-page-law.mjs +48 -0
  127. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  128. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  129. package/src/workflows/chrome-page-task.mjs +429 -0
  130. package/src/workflows/chrome-page-verifier.mjs +123 -0
  131. package/src/workflows/chrome-source.mjs +73 -0
  132. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  133. package/src/workflows/chrome-task-adoption.mjs +122 -0
  134. package/src/workflows/chrome-task.mjs +379 -74
  135. package/src/workflows/source-task-v1.mjs +124 -0
  136. package/src/workflows/source-task-v2.mjs +227 -0
  137. package/src/workflows/source-task.mjs +13 -0
@@ -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. The host checks own read/write evidence against live bytes before replacing.', { file_path: str('File path'), content: str('Complete new content') }, ['file_path', 'content']),
20
- schema('Edit', 'Replace an exact string in inspected code (Read or verified file_working_set). Own receipt and live hash are checked by the host. 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' } }, ['file_path', 'old_string', 'new_string']),
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
- for (const definition of toolDefinitions) if (!house.registry.get(definition.name)) house.registry.add({ ...definition, run: (input, options) => (options.workspace || this).#run(definition.name, input, options) });
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 { await writeFile(temp, content, { flag: 'wx', mode }); await rename(temp, file); }
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 #run(name, input, { signal, actor, sessionId, observe = () => {}, recordWork = 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), info = await stat(file, input.hash ? { bigint: true } : undefined);
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 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) } : {}) });
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(HASH_LIMIT)) throw fileHashError('File hashing supports at most 256 MiB.', 'FILE_HASH_LIMIT');
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 > HASH_LIMIT) throw fileHashError('File hashing supports at most 256 MiB.', 'FILE_HASH_LIMIT');
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
  }
@@ -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
- if (row.type === 'intent') intentions.push(row.text);
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_]*)?$/.test(argument)) throw new TypeError('Invalid credential control action.');
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
 
@@ -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.ui.notice(output);return;}
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.ui.notice(output);
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
  }
@@ -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", "Change the permissions mode"], ['goal', "[condition|status|pause|resume|clear]", "Define or control a persistent goal"],
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 without pasting keys"], ['logout', "PROVIDER", "Remove Comal credentials"],
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
+ }