@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
@@ -0,0 +1,56 @@
1
+ import {digest,immutable} from './operations.mjs';
2
+ export const SCOPE_MECHANISM='comal-house-scope-v1';
3
+ export const scopeFailure=(message,code='HOUSE_SCOPE_UNAVAILABLE')=>Object.assign(new Error(message),{code});
4
+ const id=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
5
+ export function guardIdentity(name,binding,token){
6
+ if(!id(name))throw scopeFailure('The guard needs a bounded host identity.','HOUSE_SCOPE_INVALID');
7
+ if(binding===undefined)return immutable({id:name,instance_token:token});
8
+ if(!id(binding?.revision)||binding.configuration_digest!=null&&!/^[a-f0-9]{64}$/.test(binding.configuration_digest))throw scopeFailure('The host guard implementation/configuration identity is invalid.','HOUSE_SCOPE_INVALID');
9
+ return immutable({id:name,revision:binding.revision,configuration_digest:binding.configuration_digest??null});
10
+ }
11
+ export function mechanismIdentity(name,binding){
12
+ if(!id(name)||!id(binding?.revision)||typeof binding.check!=='function'||!Array.isArray(binding.operations)||binding.operations.some(name=>name!=='*'&&!id(name))||binding.configuration_digest!=null&&!/^[a-f0-9]{64}$/.test(binding.configuration_digest))throw scopeFailure('The host mechanism identity is invalid.','HOUSE_SCOPE_INVALID');
13
+ return immutable({id:name,revision:binding.revision,operations:[...binding.operations],configuration_digest:binding.configuration_digest??null});
14
+ }
15
+ export function scopeCeiling(body){return immutable({...body,digest:digest(body)});}
16
+ export function validateScopeCeiling(value,parent){
17
+ if(!value||typeof value!=='object')throw scopeFailure('The original scope ceiling is missing.','HOUSE_SCOPE_INVALID');
18
+ const{digest:stamp,...body}=value;
19
+ try{
20
+ if(body.schema!=='comal-scope-ceiling/1'||digest(body)!==stamp||!body.parent||typeof body.parent.house!=='string'||typeof body.parent.session_id!=='string'||digest(body.parent)!==digest(parent)||!Array.isArray(body.laws)||body.laws.length>64||!Array.isArray(body.guards)||body.guards.length>128||Buffer.byteLength(JSON.stringify(value))>1024*1024||Object.keys(body).some(key=>!['schema','parent','laws','guards'].includes(key)))throw Error();
21
+ for(const source of body.laws){
22
+ if(!source||typeof source.house!=='string'||typeof source.session_id!=='string'||typeof source.activation_id!=='string'||!/^[a-f0-9]{64}$/.test(source.mechanism_stamp||'')||!Array.isArray(source.mechanisms))throw Error();
23
+ const{plan_hash,...core}=source.plan||{};if(core.schema!=='comal-governance/v1'||digest(core)!==plan_hash||!Array.isArray(core.gates)||!Array.isArray(core.decisions))throw Error();
24
+ for(const entry of source.mechanisms){const identity=entry?.identity;if(!id(entry?.gate_id)||!id(identity?.id)||!id(identity?.revision)||!Array.isArray(identity.operations)||identity.operations.some(op=>op!=='*'&&!id(op)))throw Error();}
25
+ }
26
+ for(const guard of body.guards)if(!id(guard?.id)||!(id(guard.revision)&&guard.instance_token===undefined||guard.revision===undefined&&/^[a-f0-9-]{36}$/.test(guard.instance_token||'')))throw Error();
27
+ }catch{throw scopeFailure('The original scope ceiling or parent binding does not verify.','HOUSE_SCOPE_INVALID');}
28
+ return immutable(structuredClone(value));
29
+ }
30
+
31
+ // Local semantic audit of a recorded scope; never re-runs a guard or mechanism.
32
+ export function auditScopeAuthority(rows,{cwd,id:sessionId}={}){
33
+ const violations=[],starts=new Map(),fault=(row,reason)=>violations.push({invocation_id:row.invocation_id,operation:row.operation,reason}),births=rows.filter(row=>row.type==='house_scope_bound');let birth,revoked=false,activeLaw;
34
+ for(const row of rows){
35
+ if(row.type==='house_scope_bound'){
36
+ if(birth)fault(row,'scope_birth_duplicated');
37
+ try{validateScopeCeiling(row.ceiling,{house:row.parent_house,session_id:row.parent_session_id});if(row.source!=='scope_host'||row.mechanism!==SCOPE_MECHANISM||row.ceiling_digest!==row.ceiling.digest||row.house!==cwd||row.session_id!==sessionId||!row.actor?.id||!['agent','subagent','human'].includes(row.actor.kind)||!/^[a-f0-9-]{36}$/.test(row.scope_id||''))throw Error();birth=row;}catch{fault(row,'scope_birth_invalid');}
38
+ }
39
+ if(row.type==='house_scope_revoked'){
40
+ if(row.source!=='scope_host'||row.mechanism!==SCOPE_MECHANISM||row.house!==cwd||row.session_id!==sessionId||birth&&row.scope_id!==birth.scope_id)fault(row,'scope_revocation_invalid');else revoked=true;
41
+ }
42
+ if(!birth)continue;
43
+ if(['house_proposal','house_started','house_receipt'].includes(row.type)&&digest(row.actor??null)!==digest(birth.actor))fault(row,'scope_actor_binding_mismatch');
44
+ if(row.type==='house_started')starts.set(row.invocation_id,{revoked,missingLaw:birth.ceiling.laws.length&&(!activeLaw||row.governance_plan_hash!==activeLaw.plan.plan_hash||row.mechanism_stamp!==activeLaw.mechanism_stamp||row.governance_activation_id!==activeLaw.activation_id)});
45
+ if(row.type==='house_receipt'&&['returned','failed'].includes(row.status)){const start=starts.get(row.invocation_id);if(start?.revoked)fault(row,'scope_dispatch_after_revocation');if(start?.missingLaw)fault(row,'scope_dispatch_without_original_law');}
46
+ if(row.type==='governance_activated')activeLaw=row.inherited_from?row:null;
47
+ if(row.type==='governance_activated'&&row.inherited_from){
48
+ try{if(!Array.isArray(row.inherited_from))throw Error();for(const source of birth.ceiling.laws){
49
+ const ref={house:source.house,session_id:source.session_id,plan_hash:source.plan.plan_hash,mechanism_stamp:source.mechanism_stamp,activation_id:source.activation_id},index=row.inherited_from.findIndex(value=>digest(value)===digest(ref));
50
+ if(index<0){fault(row,'scope_original_law_omitted');continue;}const prefix='scope'+index+':',scope=name=>prefix+(prefix.length+name.length>128?digest(name):name);
51
+ for(const gate of source.plan.gates){const actual=row.plan?.gates?.find(value=>value.id===scope(gate.id));if(!actual||actual.tier!==gate.tier||digest(actual.operations)!==digest(gate.operations)||actual.decision_id!==scope(gate.decision_id))fault(row,'scope_original_gate_changed');}
52
+ }}catch{fault(row,'scope_original_law_invalid');}
53
+ }
54
+ }
55
+ return{scope:'recorded_scope_identity_and_revocation',births:births.length,revoked,consistent:!violations.length,violations,executable_attestation:false};
56
+ }
@@ -0,0 +1,39 @@
1
+ import {PermissionPolicy,BYPASS_PERMISSION_MODE,PERMISSION_MODES} from './permissions.mjs';
2
+ import {digest,immutable} from './operations.mjs';
3
+
4
+ const fail=message=>Object.assign(new Error(message),{code:'SCOPED_POLICY_INVALID'});
5
+ const modes=new Set([...PERMISSION_MODES,BYPASS_PERMISSION_MODE]);
6
+ export function capturePolicyCeiling(parent,intentions=parent.context?.intentions||[]){
7
+ return immutable({mode:parent.mode,allowed:[...parent.allowed],denied:[...parent.denied],intention_digest:digest(intentions)});
8
+ }
9
+ // Restoring a saved ceiling never restores startup bypass or grants from a child
10
+ // prompt. Actual source root and local recorder belong to the scoped runtime.
11
+ export function scopedPolicy({parent,ceiling=capturePolicyCeiling(parent),cwd,intentions=parent.context?.intentions||[],actions=[],readOnly=false,onDecision=()=>{}}){
12
+ if(!parent?.authorize||typeof cwd!=='string'||!modes.has(ceiling?.mode)||!['allowed','denied'].every(key=>Array.isArray(ceiling[key])&&ceiling[key].every(name=>typeof name==='string'))||ceiling.intention_digest&&ceiling.intention_digest!==digest(intentions))throw fail('The original policy ceiling or trusted human context does not verify.');
13
+ const original=immutable(structuredClone(ceiling)),trusted=immutable(structuredClone(intentions)),initialActions=immutable(structuredClone(actions));
14
+ const mode=()=>{
15
+ if(readOnly)return 'plan';const current=parent.mode,prior=original.mode;if(!modes.has(current))throw fail('The current parent mode is unavailable.');
16
+ if(prior===BYPASS_PERMISSION_MODE)return current===BYPASS_PERMISSION_MODE&&!parent.dangerouslySkipPermissions?'default':current;
17
+ if(current===BYPASS_PERMISSION_MODE)return prior;
18
+ return prior==='plan'||current==='plan'?'plan':prior==='default'||current==='default'?'default':prior==='auto'||current==='auto'?'auto':'acceptEdits';
19
+ };
20
+ const initial=mode(),policy=new PermissionPolicy({mode:initial,dangerouslySkipPermissions:initial===BYPASS_PERMISSION_MODE,allowed:original.allowed,denied:original.denied,cwd,onDecision});
21
+ Object.defineProperties(policy,{
22
+ mode:{get:mode},allowed:{get:()=>new Set(original.allowed.filter(name=>parent.allowed.has(name)))},denied:{get:()=>new Set([...original.denied,...parent.denied])},
23
+ classifier:{get:()=>parent.classifier},confirm:{get:()=>parent.confirm},rules:{get:()=>parent.rules},contractOf:{get:()=>parent.contractOf},ceiling:{value:original},
24
+ });
25
+ policy.setContext=context=>PermissionPolicy.prototype.setContext.call(policy,{intentions:structuredClone(trusted),actions:[...initialActions,...(context?.actions||[])].slice(-20)});
26
+ policy.setContext({actions:[]});return policy;
27
+ }
28
+
29
+ // A chain must retain each trusted human context as well as intersecting rule
30
+ // sets/modes. Merely nesting scopedPolicy objects selects the newest context
31
+ // for the classifier; a later broader request could then replace the old one.
32
+ export function scopedPolicyStack({parent,bindings,cwd,onDecision=()=>{}}){
33
+ if(!Array.isArray(bindings)||!bindings.length)throw fail('A policy stack requires original native bindings.');
34
+ const scope=(parent,b)=>scopedPolicy({parent,ceiling:b.authority,cwd,intentions:b.inherited.intentions,actions:b.inherited.actions,onDecision});
35
+ const combined=bindings.reduce(scope,parent),unique=[...new Map(bindings.map(b=>[digest({authority:b.authority,inherited:b.inherited}),b])).values()],checks=unique.map(b=>scope(combined,b));
36
+ const setContext=combined.setContext.bind(combined);combined.setContext=context=>{setContext(context);for(const policy of checks)policy.setContext(context);};
37
+ combined.authorize=async(name,input,options)=>{let result;for(const policy of checks){result=await policy.authorize(name,input,options);if(!result.allowed)return result;}return result;};
38
+ return combined;
39
+ }
@@ -0,0 +1,68 @@
1
+ import { t, registerMessages } from './i18n/index.mjs';
2
+ import { authProviderDefinition, acceptsSecretLogin } from './provider-auth.mjs';
3
+
4
+ registerMessages({
5
+ 'API key for {provider}': 'Llave de API de {provider}',
6
+ 'Subscription key for {provider}': 'Llave de suscripción de {provider}',
7
+ 'Hidden field: nothing you type is shown, saved in history or sent to the model. Comal stores it only in its private credential store ({directory}). Esc cancels.': 'Campo oculto: lo que escribas no se muestra, no entra al historial ni se envía al modelo. Comal la guarda sólo en su almacén privado de credenciales ({directory}). Esc cancela.',
8
+ 'Type or paste the key.': 'Escribe o pega la llave.',
9
+ 'Login cancelled. No key was stored.': 'Login cancelado. No se guardó ninguna llave.',
10
+ 'Use /login PROVIDER [--key | --api-key-env VARIABLE]. Never paste keys into the composer.': 'Usa /login PROVEEDOR [--key | --api-key-env VARIABLE]. Nunca pegues llaves en el compositor.',
11
+ '{provider} only supports browser sign-in.': '{provider} sólo admite inicio de sesión en el navegador.',
12
+ 'The hidden key field needs the interactive terminal; use --api-key-env VARIABLE here.': 'El campo oculto de la llave necesita la terminal interactiva; aquí usa --api-key-env VARIABLE.',
13
+ });
14
+
15
+ const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+
17
+ /** Argumentos de /login después del proveedor. Sólo nombres, nunca valores. */
18
+ export function parseLoginArguments(parts) {
19
+ if (!parts.length) return {};
20
+ if (parts.length === 1 && parts[0] === '--key') return { hidden: true };
21
+ if (parts.length === 2 && parts[0] === '--api-key-env' && VARIABLE.test(parts[1])) return { apiKeyEnv: parts[1] };
22
+ throw new Error(t('Use /login PROVIDER [--key | --api-key-env VARIABLE]. Never paste keys into the composer.'));
23
+ }
24
+
25
+ /**
26
+ * Quita las cabeceras de credencial que el adaptador ya puso con su copia de la
27
+ * llave. credentialFetch añade la vigente; si no, fetch une ambas en
28
+ * "Bearer X, Bearer X" y servidores estrictos (Ollama Cloud) responden 401.
29
+ */
30
+ export function withoutCredentialHeaders(headers) {
31
+ const entries = headers instanceof Headers ? [...headers.entries()] : Array.isArray(headers) ? headers : Object.entries(headers || {});
32
+ return Object.fromEntries(entries.filter(([name]) => !['authorization', 'x-api-key'].includes(String(name).toLowerCase())));
33
+ }
34
+
35
+ const cancelled = () => Object.assign(new Error(t('Login cancelled. No key was stored.')), { name: 'AbortError', code: 'ABORT_ERR' });
36
+
37
+ /** Pide la llave en el campo oculto de la TUI. Devuelve null si el humano cancela. */
38
+ export async function promptSecret({ ui, provider, directory, signal }) {
39
+ const p = authProviderDefinition(provider);
40
+ const value = await ui.setupInput({
41
+ title: t(p.subscription ? 'Subscription key for {provider}' : 'API key for {provider}', { provider: p.label }),
42
+ description: t('Hidden field: nothing you type is shown, saved in history or sent to the model. Comal stores it only in its private credential store ({directory}). Esc cancels.', { directory: directory || '~/.config/comal/auth' }),
43
+ secret: true, signal,
44
+ // El campo oculto nunca muestra el mensaje del validador; sólo un aviso genérico.
45
+ validate: text => text.trim() ? true : t('Type or paste the key.'),
46
+ });
47
+ return typeof value === 'string' ? value.trim() : null;
48
+ }
49
+
50
+ /**
51
+ * Login de proveedor con tres entradas posibles para la llave:
52
+ * --api-key-env VAR (variable de entorno), la variable por omisión del proveedor
53
+ * si existe, o el campo oculto de la TUI (siempre con --key). La llave sólo pasa
54
+ * de la TUI al almacén de credenciales: no vuelve en el resultado, no entra en
55
+ * entradas de operaciones del libro y nunca se copia a process.env.
56
+ */
57
+ export async function loginProvider({ authManager, ui, provider, apiKeyEnv, hidden = false, signal, onEvent, env = authManager.env || process.env }) {
58
+ const p = authProviderDefinition(provider);
59
+ if (hidden && !acceptsSecretLogin(p.id)) throw new Error(t('{provider} only supports browser sign-in.', { provider: p.label }));
60
+ const secretCapable = acceptsSecretLogin(p.id) && p.method !== 'device_code';
61
+ const useEnvironment = apiKeyEnv || !hidden && (!secretCapable || !ui || (p.apiKeyEnv && env[p.apiKeyEnv]));
62
+ if (useEnvironment) return authManager.login(p.id, { apiKeyEnv, signal, onEvent });
63
+ if (!ui) throw new Error(t('The hidden key field needs the interactive terminal; use --api-key-env VARIABLE here.'));
64
+ let secret = await promptSecret({ ui, provider: p.id, directory: authManager.directory, signal });
65
+ if (!secret) throw cancelled();
66
+ try { return await authManager.login(p.id, { secret, signal }); }
67
+ finally { secret = null; }
68
+ }
@@ -1,5 +1,6 @@
1
1
  import net from 'node:net';
2
2
  import { peerMessagePurpose, peerMessageHeader } from './peer-message-context.mjs';
3
+ import { admitNotice, noticeCounts, noticeMailbox, noticeMessage, NOTICE_LIMITS } from './plugin-notices.mjs';
3
4
  import path from 'node:path';
4
5
  import os from 'node:os';
5
6
  import { constants } from 'node:fs';
@@ -23,7 +24,7 @@ function authority(value = {}, cwd) {
23
24
  const normalize = input => {
24
25
  if (!input || typeof input !== 'object' || Array.isArray(input)) return invalid();
25
26
  const mode = input.mode === undefined ? 'plan' : input.mode;
26
- if (!['default', 'acceptEdits', 'plan', 'auto'].includes(mode)) return invalid();
27
+ if (!['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'].includes(mode)) return invalid();
27
28
  const list = key => {
28
29
  const items = input[key] === undefined ? [] : input[key];
29
30
  if (!Array.isArray(items) || items.length > 128 || items.some(item => typeof item !== 'string' || !item.length || item.length > 200)) return invalid();
@@ -311,18 +312,49 @@ export class SessionBus {
311
312
  return result;
312
313
  }
313
314
  async pending() {
314
- if (!this.availability.available) return { count: 0, held: 0, total: 0, ...this.availability };
315
+ // Los avisos de plugins no dependen del transporte entre sesiones: viven en el libro.
316
+ let notices = null;
317
+ const sessionId = this.getSessionId();
318
+ if (UUID.test(sessionId || '')) { const counts = noticeCounts((await this.store.load(sessionId)).rows, this.inbound); if (counts.total) notices = counts; }
319
+ const extra = notices ? { notices } : {}, urgent = notices?.urgent || 0;
320
+ if (!this.availability.available) return { count: urgent, held: notices?.held || 0, total: notices?.total || 0, ...extra, ...this.availability };
315
321
  const id = this.getSessionId(); this._paths(id); const state = mailbox((await this.store.load(id)).rows);
316
322
  const held = state.live.filter(item => item.held && !state.released.has(item.message_id)).length;
317
- return { count: this.inbound==='accept' ? state.live.length - held : 0, held, total: state.live.length };
323
+ return { count: (this.inbound==='accept' ? state.live.length - held : 0) + urgent, held: held + (notices?.held || 0), total: state.live.length + (notices?.total || 0), ...extra };
324
+ }
325
+ /** Encola un aviso de plugin ya normalizado (plugin-notices.mjs). Nunca es intención humana. */
326
+ async notify(normalized) {
327
+ const id = this.getSessionId();
328
+ if (!UUID.test(id || '')) return { status: 'no_session' };
329
+ const result = await this.store.workTransaction(id, async () => {
330
+ const admitted = admitNotice((await this.store.load(id)).rows, normalized, { inbound: this.inbound });
331
+ if (admitted.notice) await this.store.append(id, { type: 'plugin_notice_received', source: 'plugin_host', notice: admitted.notice, grants_permission: false });
332
+ return admitted;
333
+ });
334
+ if (result.status === 'accepted' && result.notice.urgent) this._signal();
335
+ this._event({ type: 'plugin_notice_admitted', plugin: normalized.plugin, ref: normalized.ref, status: result.status });
336
+ return { status: result.status, ...(result.notice_id ? { notice_id: result.notice_id } : {}) };
337
+ }
338
+ async _drainNotices(id) {
339
+ if (this.inbound !== 'accept') return [];
340
+ const state = noticeMailbox((await this.store.load(id)).rows), result = [];
341
+ // Urgentes primero; el resto espera al siguiente turno si excede el tope por entrega.
342
+ for (const notice of [...state.ready].sort((a, b) => Number(b.urgent) - Number(a.urgent)).slice(0, NOTICE_LIMITS.perDrain)) {
343
+ const message = noticeMessage(notice);
344
+ const row = await this.store.append(id, { type: 'message', source: 'plugin_notice', notice_id: notice.notice_id, notice, message });
345
+ result.push({ kind: 'plugin_notice', id: notice.notice_id, notice, message, row });
346
+ }
347
+ return result;
318
348
  }
319
349
  async drain() {
320
- if (!this.availability.available) return [];
350
+ const sessionId = this.getSessionId();
351
+ const notices = UUID.test(sessionId || '') && this.inbound === 'accept' ? await this.store.workTransaction(sessionId, () => this._drainNotices(sessionId)) : [];
352
+ if (!this.availability.available) return notices;
321
353
  const id = this.getSessionId(); this._paths(id);
322
- if (this.inbound !== 'accept') return [];
354
+ if (this.inbound !== 'accept') return notices;
323
355
  return this.store.workTransaction(id, async () => {
324
- if(this.inbound!=='accept')return [];
325
- const session = await this.store.load(id), state = mailbox(session.rows), result = [];
356
+ if(this.inbound!=='accept')return notices;
357
+ const session = await this.store.load(id), state = mailbox(session.rows), result = [...notices];
326
358
  for (const peer of state.live) {
327
359
  if (peer.held && !state.released.has(peer.message_id)) continue;
328
360
  const purpose = peerMessagePurpose(peer), header = peerMessageHeader(peer);
@@ -339,6 +371,8 @@ export class SessionBus {
339
371
  const count = await this.store.workTransaction(sessionId, async () => {
340
372
  const state = mailbox((await this.store.load(sessionId)).rows); let released = 0;
341
373
  for (const peer of state.live) if (peer.held && !state.released.has(peer.message_id) && (id === 'all' || id === peer.message_id)) { await this.store.append(sessionId, { type: 'peer_message_released', message_id: peer.message_id }); released++; }
374
+ const notices = noticeMailbox((await this.store.load(sessionId)).rows);
375
+ for (const notice of notices.live) if (notice.held && !notices.released.has(notice.notice_id) && (id === 'all' || id === notice.notice_id)) { await this.store.append(sessionId, { type: 'plugin_notice_released', notice_id: notice.notice_id }); released++; }
342
376
  return released;
343
377
  });
344
378
  if (count && this.inbound === 'accept') this._signal(); return { released: count };
@@ -0,0 +1,119 @@
1
+ // Prospective host-only reader. Not imported by SessionStore, CLI or a workflow.
2
+ // A projection is data, not execution/audit/permission authority.
3
+ import {readFile} from 'node:fs/promises';
4
+ import {performance} from 'node:perf_hooks';
5
+
6
+ const jsonBytes=value=>Buffer.byteLength(JSON.stringify(value));
7
+ const validHash=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
8
+ const defaultState=()=>({stage:'new',status:'ready'});
9
+ // Preserve the original SessionStore fold operations and their JS exceptions.
10
+ // Only its private accumulated fold is reused; callers never receive it. A
11
+ // string messages.slice() can succeed, then messages.push() can fail, or reset
12
+ // can restore an array. No new schema is imposed on these records.
13
+ function foldCompatibility(rows,header,prior){
14
+ let messages=prior?(Array.isArray(prior.messages)?prior.messages.slice():prior.messages):[];
15
+ const historyMessages=prior?prior.historyMessages.slice():[];
16
+ let metadata=prior?prior.metadata:header,goal=prior?.goal,queue=prior?.queue,draft=prior?prior.draft:'',draftAttachments=prior?prior.draftAttachments:[],compaction=prior?.compaction,projection=prior?.projection;
17
+ for (const row of rows) {
18
+ if (row.type === 'reset') { messages = []; historyMessages.length = 0; compaction = undefined; projection = undefined; }
19
+ if (row.type === 'compaction') { messages = row.messages.slice(); compaction = row; }
20
+ if (row.type === 'context_projection') { messages = row.messages.slice(); projection = row; compaction = row; }
21
+ if (row.type === 'context_rebase') { messages = row.messages.slice(); projection = undefined; compaction = undefined; }
22
+ if (row.type === 'message') { messages.push(row.message); historyMessages.push(row.message); }
23
+ if (row.type === 'model') metadata = { ...metadata, model: row.model };
24
+ if (row.type === 'provider') metadata = { ...metadata, provider: row.provider, model: row.model, ...(row.profile !== undefined ? {provider_profile:row.profile} : {}),...(row.connection ? {provider_connection:row.connection} : {}) };
25
+ if (row.type === 'goal') goal = row.goal;
26
+ if (row.type === 'queue') queue = row.snapshot;
27
+ if (row.type === 'draft') { draft = row.text; draftAttachments = row.attachments || []; }
28
+ if (row.type === 'name') metadata = { ...metadata, name: row.name };
29
+ if (row.type === 'parent') metadata = { ...metadata, parent_session_id: row.parent_session_id, subagent_type: row.subagent_type, name: row.description };
30
+ }
31
+ return{messages,historyMessages,metadata,goal,queue,draft,draftAttachments,compaction,projection};
32
+ }
33
+ export function createSessionProjectionReader({store,JournalCache,auditJournal,assertClassifierChannels,maxCachedBytes=16*1024*1024,maxIndexBytes=1024*1024,maxTasks=8,maxReceipts=64}={}) {
34
+ if(!store||typeof store.load!=='function'||typeof store.file!=='function'||!(store.writers instanceof Map)||typeof store.cwd!=='string'||typeof JournalCache!=='function'||typeof auditJournal!=='function'||typeof assertClassifierChannels!=='function')throw new TypeError('Explicit original host store and journal implementations required.');
35
+ for(const [name,value,limit] of [['maxCachedBytes',maxCachedBytes,64*1024*1024],['maxIndexBytes',maxIndexBytes,8*1024*1024],['maxTasks',maxTasks,64],['maxReceipts',maxReceipts,256]])if(!Number.isSafeInteger(value)||value<0||value>limit)throw new TypeError('Invalid bounded projection option: '+name);
36
+ let entry=null,journal=new JournalCache({maxBytes:maxCachedBytes}),pending=Promise.resolve();
37
+ const stats={reads:0,identical:0,extensions:0,full:0,fallbacks:0,failures:0,retained_buffer_bytes:0,retained_index_json_bytes:0,max_cached_buffer_bytes:2*maxCachedBytes,max_index_json_bytes:maxIndexBytes,max_tasks:maxTasks,max_receipts:maxReceipts,last:null};
38
+ // Native trim() admits blank lines at EOF, but appending after them makes
39
+ // them internal parser errors. Such bytes can cache equality, never a prefix.
40
+ const reusablePrefix=(bytes,records)=>{if(bytes.at(-1)!==10)return false;let n=0;for(let p=bytes.indexOf(10);p>=0;p=bytes.indexOf(10,p+1))n++;return n===records;};
41
+ const clear=()=>{entry=null;journal=new JournalCache({maxBytes:maxCachedBytes});stats.retained_buffer_bytes=0;stats.retained_index_json_bytes=0;};
42
+ const index=()=>({header:null,accepted:null,tasks:new Map(),receipts:new Map()});
43
+ const put=(map,key,value,cap)=>{if(map.has(key))map.delete(key);map.set(key,value);while(map.size>cap)map.delete(map.keys().next().value);};
44
+ function fold(view,rows,offset=0){
45
+ for(let i=0;i<rows.length;i++){
46
+ const row=rows[i],n=offset+i;if(!row)continue;
47
+ if(n===0)view.header=row;
48
+ if(row.type==='chrome_task_accepted'&&row.source==='workflow_host')view.accepted=row;
49
+ if(row.type==='chrome_task_checkpoint')put(view.tasks,row.task_digest,{row,n},maxTasks);
50
+ if(['house_proposal','house_started','house_receipt'].includes(row.type)&&typeof row.invocation_id==='string'){
51
+ const tuple=view.receipts.get(row.invocation_id)||{};
52
+ // A bounded recent index. Missing/evicted ancestors never become proof;
53
+ // explicit receipt queries fall back to the original complete reader.
54
+ tuple[row.type]={row,n};put(view.receipts,row.invocation_id,tuple,maxReceipts);
55
+ }
56
+ }
57
+ return view;
58
+ }
59
+ const size=(view,compatibility)=>jsonBytes({header:view.header,accepted:view.accepted,tasks:[...view.tasks],receipts:[...view.receipts],compatibility});
60
+ function identity(header,id){if(header?.type!=='session'||header.id!==id||header.cwd!==store.cwd)throw new Error('La sesión pertenece a otro directorio o es inválida.');}
61
+ async function fallback(id,query){
62
+ stats.fallbacks++;clear();const book=await store.load(id);
63
+ const view={header:book.rows[0],accepted:book.rows.findLast(row=>row.type==='chrome_task_accepted'&&row.source==='workflow_host'),tasks:new Map(),receipts:new Map()};
64
+ if(query.kind==='page'){const n=book.rows.findLastIndex(row=>row.type==='chrome_task_checkpoint'&&row.task_digest===query.taskDigest);if(n>=0)view.tasks.set(query.taskDigest,{row:book.rows[n],n});}
65
+ else for(let i=0;i<book.rows.length;i++){const row=book.rows[i];if(row.invocation_id===query.invocationId&&['house_proposal','house_started','house_receipt'].includes(row.type)){const tuple=view.receipts.get(query.invocationId)||{};tuple[row.type]={row,n:i};view.receipts.set(query.invocationId,tuple);}}
66
+ return result(view,book.journalAudit,query);
67
+ }
68
+ function result(view,audit,query){
69
+ const base={session:{id:view.header.id,cwd:view.header.cwd},journalAudit:audit};
70
+ if(query.kind==='page')return structuredClone({...base,accepted:view.accepted??null,checkpoint:view.tasks.get(query.taskDigest)?.row??null,state:view.tasks.get(query.taskDigest)?.row.state||defaultState()});
71
+ const tuple=view.receipts.get(query.invocationId)||{};
72
+ return structuredClone({...base,proposal:tuple.house_proposal?.row??null,started:tuple.house_started?.row??null,receipt:tuple.house_receipt?.row??null,indices:{proposal:tuple.house_proposal?.n??null,started:tuple.house_started?.n??null,receipt:tuple.house_receipt?.n??null}});
73
+ }
74
+ async function read(id,query){
75
+ const began=performance.now();stats.reads++;
76
+ try{
77
+ while(store.writers.has(id))await store.writers.get(id).catch(()=>{});
78
+ const bytes=await readFile(store.file(id));
79
+ if(!maxCachedBytes||!maxIndexBytes||bytes.length>maxCachedBytes)return await fallback(id,query);
80
+ let audit=journal.get(id,bytes),view,records,mode,compatibility;
81
+ if(entry?.id===id&&bytes.equals(entry.bytes)&&audit){view=entry.view;records=entry.records;compatibility=entry.compatibility;mode='identical';stats.identical++;}
82
+ else if(entry?.id===id&&entry.audit.verified&&entry.prefixSafe&&bytes.length>entry.bytes.length&&bytes.subarray(0,entry.bytes.length).equals(entry.bytes)&&bytes.subarray(entry.bytes.length).toString('utf8').trimEnd()){
83
+ // Do not trimStart: a blank line inserted after the verified prefix is
84
+ // invalid in the original full parser and must remain invalid here.
85
+ const suffix=bytes.subarray(entry.bytes.length).toString('utf8').trimEnd().split('\n').map(row=>JSON.parse(row));
86
+ const absolute=new Array(entry.records).concat(suffix);identity(entry.view.header,id);assertClassifierChannels(absolute);
87
+ compatibility=foldCompatibility(suffix,entry.view.header,entry.compatibility);
88
+ audit=journal.extend(id,bytes,absolute);
89
+ if(!audit)return await fallback(id,query);
90
+ view=fold({header:entry.view.header,accepted:entry.view.accepted,tasks:new Map(entry.view.tasks),receipts:new Map([...entry.view.receipts].map(([k,v])=>[k,{...v}]))},suffix,entry.records);
91
+ records=absolute.length;mode='verified-prefix';stats.extensions++;
92
+ } else {
93
+ const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row));identity(rows[0],id);assertClassifierChannels(rows);
94
+ compatibility=foldCompatibility(rows.slice(1),rows[0]);
95
+ audit=audit||auditJournal(rows);view=fold(index(),rows);records=rows.length;mode='full';stats.full++;
96
+ }
97
+ identity(view.header,id);
98
+ const indexBytes=size(view,compatibility);
99
+ if(indexBytes>maxIndexBytes)return await fallback(id,query);
100
+ if(mode!=='identical')journal.set(id,bytes,audit);
101
+ if(audit.consistent){if(mode!=='identical')entry={id,bytes:Buffer.from(bytes),view,records,compatibility,prefixSafe:reusablePrefix(bytes,records),audit:structuredClone(audit)};stats.retained_buffer_bytes=2*bytes.length;stats.retained_index_json_bytes=indexBytes;}
102
+ else clear();
103
+ // A missing recent receipt or task may have been evicted; never credit
104
+ // absence from a bounded index as absence from the original book.
105
+ if(query.kind==='receipt'&&!view.receipts.get(query.invocationId)?.house_proposal||query.kind==='page'&&!view.tasks.has(query.taskDigest)&&view.tasks.size>=maxTasks)return await fallback(id,query);
106
+ stats.last={mode,bytes:bytes.length,records,index_json_bytes:indexBytes,elapsed_ms:performance.now()-began};
107
+ return result(view,audit,query);
108
+ }catch(error){stats.failures++;clear();throw error;}
109
+ finally{stats.last={...stats.last,elapsed_ms:performance.now()-began};}
110
+ }
111
+ // Serialize private cache updates; no journal writer, actor or permission is
112
+ // substituted, and original writer-drain semantics remain per read.
113
+ const serial=(id,query)=>{const next=pending.then(()=>read(id,query));pending=next.catch(()=>{});return next;};
114
+ return Object.freeze({
115
+ readPageState(id,taskDigest){if(!validHash(taskDigest))return Promise.reject(new TypeError('Exact accepted task digest required.'));return serial(id,{kind:'page',taskDigest});},
116
+ readReceipt(id,invocationId){if(typeof invocationId!=='string'||!/^[a-f0-9-]{36}$/.test(invocationId))return Promise.reject(new TypeError('Exact original invocation id required.'));return serial(id,{kind:'receipt',invocationId});},
117
+ status(){return structuredClone(stats);},clear(){return pending.then(()=>clear());},
118
+ });
119
+ }
@@ -0,0 +1,119 @@
1
+ // Narrow host-only reader. A normal page workflow uses it only after explicit host opt-in.
2
+ // A projection is data, not execution/audit/permission authority.
3
+ import {readFile} from 'node:fs/promises';
4
+ import {performance} from 'node:perf_hooks';
5
+
6
+ const jsonBytes=value=>Buffer.byteLength(JSON.stringify(value));
7
+ const validHash=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
8
+ const defaultState=()=>({stage:'new',status:'ready'});
9
+ // Preserve the original SessionStore fold operations and their JS exceptions.
10
+ // Only its private accumulated fold is reused; callers never receive it. A
11
+ // string messages.slice() can succeed, then messages.push() can fail, or reset
12
+ // can restore an array. No new schema is imposed on these records.
13
+ function foldCompatibility(rows,header,prior){
14
+ let messages=prior?(Array.isArray(prior.messages)?prior.messages.slice():prior.messages):[];
15
+ const historyMessages=prior?prior.historyMessages.slice():[];
16
+ let metadata=prior?prior.metadata:header,goal=prior?.goal,queue=prior?.queue,draft=prior?prior.draft:'',draftAttachments=prior?prior.draftAttachments:[],compaction=prior?.compaction,projection=prior?.projection;
17
+ for (const row of rows) {
18
+ if (row.type === 'reset') { messages = []; historyMessages.length = 0; compaction = undefined; projection = undefined; }
19
+ if (row.type === 'compaction') { messages = row.messages.slice(); compaction = row; }
20
+ if (row.type === 'context_projection') { messages = row.messages.slice(); projection = row; compaction = row; }
21
+ if (row.type === 'context_rebase') { messages = row.messages.slice(); projection = undefined; compaction = undefined; }
22
+ if (row.type === 'message') { messages.push(row.message); historyMessages.push(row.message); }
23
+ if (row.type === 'model') metadata = { ...metadata, model: row.model };
24
+ if (row.type === 'provider') metadata = { ...metadata, provider: row.provider, model: row.model, ...(row.profile !== undefined ? {provider_profile:row.profile} : {}),...(row.connection ? {provider_connection:row.connection} : {}) };
25
+ if (row.type === 'goal') goal = row.goal;
26
+ if (row.type === 'queue') queue = row.snapshot;
27
+ if (row.type === 'draft') { draft = row.text; draftAttachments = row.attachments || []; }
28
+ if (row.type === 'name') metadata = { ...metadata, name: row.name };
29
+ if (row.type === 'parent') metadata = { ...metadata, parent_session_id: row.parent_session_id, subagent_type: row.subagent_type, name: row.description };
30
+ }
31
+ return{messages,historyMessages,metadata,goal,queue,draft,draftAttachments,compaction,projection};
32
+ }
33
+ export function createSessionProjectionReader({store,JournalCache,auditJournal,assertClassifierChannels,maxCachedBytes=16*1024*1024,maxIndexBytes=1024*1024,maxTasks=8,maxReceipts=64}={}) {
34
+ if(!store||typeof store.load!=='function'||typeof store.file!=='function'||!(store.writers instanceof Map)||typeof store.cwd!=='string'||typeof JournalCache!=='function'||typeof auditJournal!=='function'||typeof assertClassifierChannels!=='function')throw new TypeError('Explicit original host store and journal implementations required.');
35
+ for(const [name,value,limit] of [['maxCachedBytes',maxCachedBytes,64*1024*1024],['maxIndexBytes',maxIndexBytes,8*1024*1024],['maxTasks',maxTasks,64],['maxReceipts',maxReceipts,256]])if(!Number.isSafeInteger(value)||value<0||value>limit)throw new TypeError('Invalid bounded projection option: '+name);
36
+ let entry=null,journal=new JournalCache({maxBytes:maxCachedBytes}),pending=Promise.resolve();
37
+ const stats={reads:0,identical:0,extensions:0,full:0,fallbacks:0,failures:0,retained_buffer_bytes:0,retained_index_json_bytes:0,max_cached_buffer_bytes:2*maxCachedBytes,max_index_json_bytes:maxIndexBytes,max_tasks:maxTasks,max_receipts:maxReceipts,last:null};
38
+ // Native trim() admits blank lines at EOF, but appending after them makes
39
+ // them internal parser errors. Such bytes can cache equality, never a prefix.
40
+ const reusablePrefix=(bytes,records)=>{if(bytes.at(-1)!==10)return false;let n=0;for(let p=bytes.indexOf(10);p>=0;p=bytes.indexOf(10,p+1))n++;return n===records;};
41
+ const clear=()=>{entry=null;journal=new JournalCache({maxBytes:maxCachedBytes});stats.retained_buffer_bytes=0;stats.retained_index_json_bytes=0;};
42
+ const index=()=>({header:null,accepted:null,tasks:new Map(),receipts:new Map()});
43
+ const put=(map,key,value,cap)=>{if(map.has(key))map.delete(key);map.set(key,value);while(map.size>cap)map.delete(map.keys().next().value);};
44
+ function fold(view,rows,offset=0){
45
+ for(let i=0;i<rows.length;i++){
46
+ const row=rows[i],n=offset+i;if(!row)continue;
47
+ if(n===0)view.header=row;
48
+ if(row.type==='chrome_task_accepted'&&row.source==='workflow_host')view.accepted=row;
49
+ if(row.type==='chrome_task_checkpoint')put(view.tasks,row.task_digest,{row,n},maxTasks);
50
+ if(['house_proposal','house_started','house_receipt'].includes(row.type)&&typeof row.invocation_id==='string'){
51
+ const tuple=view.receipts.get(row.invocation_id)||{};
52
+ // A bounded recent index. Missing/evicted ancestors never become proof;
53
+ // explicit receipt queries fall back to the original complete reader.
54
+ tuple[row.type]={row,n};put(view.receipts,row.invocation_id,tuple,maxReceipts);
55
+ }
56
+ }
57
+ return view;
58
+ }
59
+ const size=(view,compatibility)=>jsonBytes({header:view.header,accepted:view.accepted,tasks:[...view.tasks],receipts:[...view.receipts],compatibility});
60
+ function identity(header,id){if(header?.type!=='session'||header.id!==id||header.cwd!==store.cwd)throw new Error('La sesión pertenece a otro directorio o es inválida.');}
61
+ async function fallback(id,query){
62
+ stats.fallbacks++;clear();const book=await store.load(id);
63
+ const view={header:book.rows[0],accepted:book.rows.findLast(row=>row.type==='chrome_task_accepted'&&row.source==='workflow_host'),tasks:new Map(),receipts:new Map()};
64
+ if(query.kind==='page'){const n=book.rows.findLastIndex(row=>row.type==='chrome_task_checkpoint'&&row.task_digest===query.taskDigest);if(n>=0)view.tasks.set(query.taskDigest,{row:book.rows[n],n});}
65
+ else for(let i=0;i<book.rows.length;i++){const row=book.rows[i];if(row.invocation_id===query.invocationId&&['house_proposal','house_started','house_receipt'].includes(row.type)){const tuple=view.receipts.get(query.invocationId)||{};tuple[row.type]={row,n:i};view.receipts.set(query.invocationId,tuple);}}
66
+ return result(view,book.journalAudit,query);
67
+ }
68
+ function result(view,audit,query){
69
+ const base={session:{id:view.header.id,cwd:view.header.cwd},journalAudit:audit};
70
+ if(query.kind==='page')return structuredClone({...base,accepted:view.accepted??null,checkpoint:view.tasks.get(query.taskDigest)?.row??null,state:view.tasks.get(query.taskDigest)?.row.state||defaultState()});
71
+ const tuple=view.receipts.get(query.invocationId)||{};
72
+ return structuredClone({...base,proposal:tuple.house_proposal?.row??null,started:tuple.house_started?.row??null,receipt:tuple.house_receipt?.row??null,indices:{proposal:tuple.house_proposal?.n??null,started:tuple.house_started?.n??null,receipt:tuple.house_receipt?.n??null}});
73
+ }
74
+ async function read(id,query){
75
+ const began=performance.now();stats.reads++;
76
+ try{
77
+ while(store.writers.has(id))await store.writers.get(id).catch(()=>{});
78
+ const bytes=await readFile(store.file(id));
79
+ if(!maxCachedBytes||!maxIndexBytes||bytes.length>maxCachedBytes)return await fallback(id,query);
80
+ let audit=journal.get(id,bytes),view,records,mode,compatibility;
81
+ if(entry?.id===id&&bytes.equals(entry.bytes)&&audit){view=entry.view;records=entry.records;compatibility=entry.compatibility;mode='identical';stats.identical++;}
82
+ else if(entry?.id===id&&entry.audit.verified&&entry.prefixSafe&&bytes.length>entry.bytes.length&&bytes.subarray(0,entry.bytes.length).equals(entry.bytes)&&bytes.subarray(entry.bytes.length).toString('utf8').trimEnd()){
83
+ // Do not trimStart: a blank line inserted after the verified prefix is
84
+ // invalid in the original full parser and must remain invalid here.
85
+ const suffix=bytes.subarray(entry.bytes.length).toString('utf8').trimEnd().split('\n').map(row=>JSON.parse(row));
86
+ const absolute=new Array(entry.records).concat(suffix);identity(entry.view.header,id);assertClassifierChannels(absolute);
87
+ compatibility=foldCompatibility(suffix,entry.view.header,entry.compatibility);
88
+ audit=journal.extend(id,bytes,absolute);
89
+ if(!audit)return await fallback(id,query);
90
+ view=fold({header:entry.view.header,accepted:entry.view.accepted,tasks:new Map(entry.view.tasks),receipts:new Map([...entry.view.receipts].map(([k,v])=>[k,{...v}]))},suffix,entry.records);
91
+ records=absolute.length;mode='verified-prefix';stats.extensions++;
92
+ } else {
93
+ const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row));identity(rows[0],id);assertClassifierChannels(rows);
94
+ compatibility=foldCompatibility(rows.slice(1),rows[0]);
95
+ audit=audit||auditJournal(rows);view=fold(index(),rows);records=rows.length;mode='full';stats.full++;
96
+ }
97
+ identity(view.header,id);
98
+ const indexBytes=size(view,compatibility);
99
+ if(indexBytes>maxIndexBytes)return await fallback(id,query);
100
+ if(mode!=='identical')journal.set(id,bytes,audit);
101
+ if(audit.consistent){if(mode!=='identical')entry={id,bytes:Buffer.from(bytes),view,records,compatibility,prefixSafe:reusablePrefix(bytes,records),audit:structuredClone(audit)};stats.retained_buffer_bytes=2*bytes.length;stats.retained_index_json_bytes=indexBytes;}
102
+ else clear();
103
+ // A missing recent receipt or task may have been evicted; never credit
104
+ // absence from a bounded index as absence from the original book.
105
+ if(query.kind==='receipt'&&!view.receipts.get(query.invocationId)?.house_proposal||query.kind==='page'&&!view.tasks.has(query.taskDigest)&&view.tasks.size>=maxTasks)return await fallback(id,query);
106
+ stats.last={mode,bytes:bytes.length,records,index_json_bytes:indexBytes,elapsed_ms:performance.now()-began};
107
+ return result(view,audit,query);
108
+ }catch(error){stats.failures++;clear();throw error;}
109
+ finally{stats.last={...stats.last,elapsed_ms:performance.now()-began};}
110
+ }
111
+ // Serialize private cache updates; no journal writer, actor or permission is
112
+ // substituted, and original writer-drain semantics remain per read.
113
+ const serial=(id,query)=>{const next=pending.then(()=>read(id,query));pending=next.catch(()=>{});return next;};
114
+ return Object.freeze({
115
+ readPageState(id,taskDigest){if(!validHash(taskDigest))return Promise.reject(new TypeError('Exact accepted task digest required.'));return serial(id,{kind:'page',taskDigest});},
116
+ readReceipt(id,invocationId){if(typeof invocationId!=='string'||!/^[a-f0-9-]{36}$/.test(invocationId))return Promise.reject(new TypeError('Exact original invocation id required.'));return serial(id,{kind:'receipt',invocationId});},
117
+ status(){return structuredClone(stats);},clear(){return pending.then(()=>clear());},
118
+ });
119
+ }
@@ -1,20 +1,40 @@
1
1
  // Cache computation, never authority or file state. Every lookup is preceded by
2
2
  // a new complete file read and requires exact bytes, not stat/mtime metadata.
3
+ import {sealRecord} from './audit.mjs';
4
+
3
5
  export class SessionJournalCache {
4
6
  #entry;
5
- constructor({maxBytes=8*1024*1024}={}) {
7
+ constructor({maxBytes=16*1024*1024}={}) {
6
8
  if(!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>64*1024*1024)throw new Error('journalCacheBytes debe ser un entero entre 0 y 64 MiB.');
7
- this.maxBytes=maxBytes;this.stats={hits:0,misses:0,oversized:0,invalid:0};
9
+ this.maxBytes=maxBytes;this.stats={hits:0,misses:0,prefix_hits:0,prefix_records:0,suffix_records:0,oversized:0,invalid:0};
8
10
  }
9
11
  get(id,bytes) {
10
12
  if(this.#entry?.id===id && this.#entry.bytes.equals(bytes)){this.stats.hits++;return structuredClone(this.#entry.audit);}
11
13
  this.stats.misses++;return null;
12
14
  }
15
+ // Called only after the complete fresh file has been parsed and admitted.
16
+ // Match ALL old bytes through a complete line boundary. Recompute every new
17
+ // seal at its absolute original sequence; never trust a suffix's claimed tip.
18
+ extend(id,bytes,rows) {
19
+ const prior=this.#entry;
20
+ if(!prior||prior.id!==id||!prior.audit.verified||prior.bytes.at(-1)!==10||bytes.length<=prior.bytes.length||rows.length<=prior.audit.records||!bytes.subarray(0,prior.bytes.length).equals(prior.bytes))return null;
21
+ let previous=prior.audit.tip_sha256,sealed=prior.audit.sealed_records,legacy=prior.audit.legacy_records;const violations=[];
22
+ for(let i=prior.audit.records;i<rows.length;i++){
23
+ const {audit,...body}=rows[i],expected=sealRecord(body,i,previous).audit;
24
+ if(audit){
25
+ sealed++;
26
+ if(audit.version!==1||audit.sequence!==i||audit.previous_sha256!==previous||audit.sha256!==expected.sha256)violations.push({row:i,reason:'journal_hash_chain_mismatch'});
27
+ }else{legacy++;if(sealed)violations.push({row:i,reason:'unsealed_record_after_sealed_suffix'});}
28
+ previous=expected.sha256;
29
+ }
30
+ this.stats.prefix_hits++;this.stats.prefix_records+=prior.audit.records;this.stats.suffix_records+=rows.length-prior.audit.records;
31
+ return {scope:'local_journal_consistency',verified:rows.length>0&&!legacy&&!violations.length,consistent:!violations.length,records:rows.length,sealed_records:sealed,legacy_records:legacy,tip_sha256:previous,integrity:'local_hash_chain_not_signed',violations};
32
+ }
13
33
  set(id,bytes,audit) {
14
34
  if(!this.maxBytes || bytes.length>this.maxBytes){this.#entry=undefined;this.stats.oversized++;return;}
15
35
  if(!audit.consistent){this.#entry=undefined;this.stats.invalid++;return;}
16
36
  // No mutable buffer/audit handed to a caller can poison a future hit.
17
37
  this.#entry={id,bytes:Buffer.from(bytes),audit:structuredClone(audit)};
18
38
  }
19
- status(){return {...this.stats,max_bytes:this.maxBytes,retained_bytes:this.#entry?.bytes.length ||0,entries:this.#entry?1:0,proof:'complete_file_bytes_equal'};}
39
+ status(){return {...this.stats,max_bytes:this.maxBytes,retained_bytes:this.#entry?.bytes.length ||0,entries:this.#entry?1:0,proof:'complete_file_bytes_equal',prefix_proof:'complete_prior_bytes_equal_and_absolute_suffix_seals_replayed'};}
20
40
  }