@milpa/comal 0.1.7 → 0.2.1

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 (134) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/governance/chrome-task-v10.json +323 -0
  4. package/governance/chrome-task-v11.json +355 -0
  5. package/governance/chrome-task-v12.json +387 -0
  6. package/governance/chrome-task-v13.json +419 -0
  7. package/governance/chrome-task-v3.json +99 -0
  8. package/governance/chrome-task-v4.json +131 -0
  9. package/governance/chrome-task-v5.json +163 -0
  10. package/governance/chrome-task-v6.json +195 -0
  11. package/governance/chrome-task-v7.json +227 -0
  12. package/governance/chrome-task-v8.json +259 -0
  13. package/governance/chrome-task-v9.json +291 -0
  14. package/governance/source-task-v1.json +1 -0
  15. package/governance/source-task-v2.json +1 -0
  16. package/package.json +14 -1
  17. package/src/agent-recovery-proof.mjs +12 -1
  18. package/src/agent.mjs +94 -30
  19. package/src/audit.mjs +21 -6
  20. package/src/auto-guard.mjs +48 -1
  21. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  22. package/src/chrome-recovery-proof.mjs +6 -2
  23. package/src/chrome-source-syntax.mjs +93 -0
  24. package/src/cli.mjs +158 -54
  25. package/src/codex-provider.mjs +3 -2
  26. package/src/connection-settings.mjs +204 -0
  27. package/src/context-pressure.mjs +2 -1
  28. package/src/durable-agent-runs.mjs +5 -11
  29. package/src/event-channels.mjs +8 -0
  30. package/src/file-recovery-proof.mjs +11 -3
  31. package/src/file-recovery.mjs +24 -0
  32. package/src/gemini-provider.mjs +6 -1
  33. package/src/git-agent-revision.mjs +12 -0
  34. package/src/git-agent-workspaces.mjs +143 -0
  35. package/src/git-integration-lineage.mjs +20 -0
  36. package/src/git-integration-operations.mjs +200 -0
  37. package/src/git-integration-recovery-proof.mjs +70 -0
  38. package/src/git-page-cli-bridge.mjs +61 -0
  39. package/src/git-page-evidence.mjs +30 -0
  40. package/src/git-page-identity.mjs +10 -0
  41. package/src/git-page-proof.mjs +96 -0
  42. package/src/git-page-settlement.mjs +21 -0
  43. package/src/git-page-verification.mjs +134 -0
  44. package/src/git-plumbing.mjs +212 -0
  45. package/src/git-process-worker.mjs +50 -0
  46. package/src/git-source.mjs +110 -0
  47. package/src/git-workspaces.mjs +519 -0
  48. package/src/goals.mjs +13 -7
  49. package/src/house.mjs +246 -20
  50. package/src/i18n/cli-es.mjs +4 -0
  51. package/src/i18n/es.mjs +35 -0
  52. package/src/judges.mjs +27 -11
  53. package/src/mcp-runtime.mjs +15 -3
  54. package/src/model-controls.mjs +11 -1
  55. package/src/model-deadline.mjs +4 -3
  56. package/src/native-refusal.mjs +38 -0
  57. package/src/onboarding.mjs +88 -24
  58. package/src/openai-provider.mjs +14 -7
  59. package/src/operations.mjs +9 -2
  60. package/src/output-recovery.mjs +48 -0
  61. package/src/peer-tools.mjs +18 -8
  62. package/src/permission-controls.mjs +232 -0
  63. package/src/permission-rules.mjs +312 -0
  64. package/src/permissions.mjs +113 -30
  65. package/src/plugin-catalog.mjs +84 -0
  66. package/src/plugin-config.mjs +48 -6
  67. package/src/plugin-controls.mjs +332 -0
  68. package/src/plugin-notices.mjs +88 -0
  69. package/src/plugin-trust.mjs +104 -0
  70. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  71. package/src/plugins/chrome-lab/client.mjs +103 -25
  72. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  73. package/src/plugins/chrome-lab/index.mjs +116 -26
  74. package/src/plugins/chrome-lab/project.mjs +30 -2
  75. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  76. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  77. package/src/plugins/open-relay/actions.mjs +205 -0
  78. package/src/plugins/open-relay/bound.mjs +47 -0
  79. package/src/plugins/open-relay/client.mjs +158 -0
  80. package/src/plugins/open-relay/dossier.mjs +119 -0
  81. package/src/plugins/open-relay/index.mjs +153 -0
  82. package/src/plugins/open-relay/lookup.mjs +45 -0
  83. package/src/plugins/open-relay/notices.mjs +79 -0
  84. package/src/plugins/open-relay/watch.mjs +127 -0
  85. package/src/plugins.mjs +72 -29
  86. package/src/private-fs.mjs +46 -0
  87. package/src/process-lock.mjs +24 -13
  88. package/src/provider-auth.mjs +14 -3
  89. package/src/provider.mjs +11 -2
  90. package/src/read-only-check.mjs +7 -3
  91. package/src/scope-ceiling.mjs +56 -0
  92. package/src/scoped-policy.mjs +39 -0
  93. package/src/secret-login.mjs +68 -0
  94. package/src/session-bus.mjs +41 -7
  95. package/src/session-projections-candidate.mjs +119 -0
  96. package/src/session-projections.mjs +119 -0
  97. package/src/session-read-cache.mjs +23 -3
  98. package/src/sessions.mjs +35 -11
  99. package/src/source-editor-role.mjs +13 -0
  100. package/src/source-ownership.mjs +76 -0
  101. package/src/source-proposal-proof.mjs +55 -0
  102. package/src/source-proposals.mjs +118 -0
  103. package/src/source-requests.mjs +148 -0
  104. package/src/source-task-proof.mjs +24 -0
  105. package/src/source-task-revision-proof.mjs +44 -0
  106. package/src/subagents.mjs +187 -87
  107. package/src/tool-packs.mjs +5 -5
  108. package/src/tools.mjs +82 -15
  109. package/src/trusted-context.mjs +5 -1
  110. package/src/tui/connection-transition.mjs +1 -1
  111. package/src/tui/controller.mjs +8 -4
  112. package/src/tui/discovery.mjs +24 -12
  113. package/src/tui/inbound-blocks.mjs +55 -0
  114. package/src/tui/present.mjs +504 -0
  115. package/src/tui/screen.mjs +49 -13
  116. package/src/windows-private.ps1 +63 -0
  117. package/src/work-assertions.mjs +6 -2
  118. package/src/work-plan.mjs +34 -8
  119. package/src/work-state.mjs +15 -9
  120. package/src/workflows/chrome-page-cases.mjs +156 -0
  121. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  122. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  123. package/src/workflows/chrome-page-law.mjs +48 -0
  124. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  125. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  126. package/src/workflows/chrome-page-task.mjs +429 -0
  127. package/src/workflows/chrome-page-verifier.mjs +123 -0
  128. package/src/workflows/chrome-source.mjs +73 -0
  129. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  130. package/src/workflows/chrome-task-adoption.mjs +122 -0
  131. package/src/workflows/chrome-task.mjs +379 -74
  132. package/src/workflows/source-task-v1.mjs +124 -0
  133. package/src/workflows/source-task-v2.mjs +227 -0
  134. package/src/workflows/source-task.mjs +13 -0
@@ -202,13 +202,14 @@ export class CodexProvider {
202
202
  if (framed.format === 'json') {
203
203
  const result = normalized(await responseJSON(framed.body)); for (const block of result.content) if (block.type === 'text') onText(block.text); return account(result);
204
204
  }
205
- let result, textChars = 0, reasoningChars = 0, argumentChars = 0;
205
+ let result, textChars = 0, reasoningChars = 0, argumentChars = 0, toolName;
206
206
  for await (const event of sseEvents(framed.body)) {
207
207
  if (event.type === 'response.output_text.delta') { onText(event.delta || ''); textChars += (event.delta || '').length; deadline.touch(); }
208
208
  else if (event.type === 'response.reasoning_summary_text.delta') { reasoningChars += (event.delta || '').length; deadline.touch(); }
209
209
  else if (event.type === 'response.function_call_arguments.delta') { argumentChars += (event.delta || '').length; deadline.touch(); }
210
+ else if (event.type === 'response.output_item.added') { toolName = event.item?.type === 'function_call' && typeof event.item.name === 'string' ? event.item.name : undefined; }
210
211
  else if (event.type === 'response.completed') result = normalized(event.response);
211
- else if (['response.failed', 'response.incomplete', 'error'].includes(event.type)) throw responseFailure(event);
212
+ else if (['response.failed', 'response.incomplete', 'error'].includes(event.type)) { const failure = responseFailure(event); if (failure.code === 'OUTPUT_LENGTH') failure.details = { ...failure.details, text_chars: textChars, reasoning_chars: reasoningChars, tool_argument_chars: argumentChars, ...(toolName ? { tool_name: toolName } : {}) }; throw failure; }
212
213
  onProgress({ text_chars: textChars, reasoning_chars: reasoningChars, tool_argument_chars: argumentChars, elapsed_ms: deadline.elapsedMs(), purpose });
213
214
  }
214
215
  deadline.signal.throwIfAborted();
@@ -0,0 +1,204 @@
1
+ import { t, registerMessages } from './i18n/index.mjs';
2
+ import { contextSettings, resolveContextBudget } from './compaction.mjs';
3
+ import { comalEnvironment } from './compat.mjs';
4
+ import { AUTH_PROVIDERS } from './provider-auth.mjs';
5
+ import { connectionPreferenceFile, saveConnection, saveLimits } from './onboarding.mjs';
6
+
7
+ // Configuración de la conexión desde dentro de la sesión: /max-tokens,
8
+ // /context-window y /config. Sólo lee estados y nombres de variables; nunca
9
+ // toca una llave. cli.mjs sólo cablea getters y efectos.
10
+ registerMessages({
11
+ 'Use /max-tokens [N] with N between 64 and 1000000.': 'Usa /max-tokens [N] con N entre 64 y 1000000.',
12
+ 'Use /context-window [auto|N] with N between 4096 and 2000000.': 'Usa /context-window [auto|N] con N entre 4096 y 2000000.',
13
+ 'The effective window ({window}) must be at least twice max-tokens ({maxTokens}, the output reserve). Lower /max-tokens or raise /context-window; nothing was changed.': 'La ventana efectiva ({window}) debe ser al menos el doble de max-tokens ({maxTokens}, la reserva de salida). Baja /max-tokens o sube /context-window; no se cambió nada.',
14
+ 'Max tokens: {value} · source {source}': 'Max-tokens: {value} · origen {source}',
15
+ 'Context window: {value} · {kind} · source {source} · announced {announced} · explicit limit {limit}': 'Ventana de contexto: {value} · {kind} · origen {source} · anunciada {announced} · límite explícito {limit}',
16
+ 'Applied to this session.': 'Aplicado a esta sesión.',
17
+ 'Saved in {file} for the next sessions.': 'Guardado en {file} para las siguientes sesiones.',
18
+ 'Applied, but not saved: {reason}': 'Aplicado, pero no se guardó: {reason}',
19
+ 'The startup flag {flag} still wins over the saved value when it is passed.': 'La bandera de arranque {flag} sigue mandando sobre lo guardado cuando se pasa.',
20
+ 'The environment variable {name} still wins over the saved value.': 'La variable de entorno {name} sigue mandando sobre lo guardado.',
21
+ 'Saved limits were ignored: {reason}': 'Se ignoraron los límites guardados: {reason}',
22
+ 'Use /config, /config set KEY VALUE or /config save. Keys: {keys}.': 'Usa /config, /config set CLAVE VALOR o /config save. Claves: {keys}.',
23
+ 'The demo has no connection to save.': 'La demo no tiene conexión que guardar.',
24
+ 'Profiles are selected with defaultProfile in {file}; nothing was saved.': 'Los perfiles se eligen con defaultProfile en {file}; no se guardó nada.',
25
+ 'Default connection saved in {file}: {summary}. Startup flags still win.': 'Conexión por omisión guardada en {file}: {summary}. Las banderas de arranque siguen mandando.',
26
+ 'Comal configuration': 'Configuración de Comal',
27
+ 'Provider': 'Proveedor', 'Login': 'Login', 'Credential': 'Credencial', 'Model': 'Modelo', 'Effort': 'Effort', 'Context window': 'Ventana',
28
+ 'Max tokens': 'Max-tokens', 'Permissions': 'Permisos', 'Plugins': 'Plugins', 'Files': 'Archivos',
29
+ 'connection': 'conexión', 'credentials': 'credenciales', 'profiles': 'perfiles', 'plugins': 'plugins',
30
+ 'none': 'ninguno', 'unknown': 'desconocida', 'demo without provider': 'demo sin proveedor',
31
+ 'no login · key from {name} ({state})': 'sin login · llave de {name} ({state})', 'defined': 'definida', 'not defined': 'no definida',
32
+ 'no credential': 'sin credencial', 'method {method}': 'método {method}', 'expires {date}': 'expira {date}', 'stored key, does not expire': 'llave guardada, no expira',
33
+ 'connected': 'conectado', 'configured': 'configurado', 'expired': 'expirado', 'logged out': 'sin sesión',
34
+ 'active: {list}': 'activos: {list}', 'inactive: {list}': 'inactivos: {list}', 'unavailable': 'no disponible',
35
+ 'Change: /config set model|effort|provider|permissions|max-tokens|context-window VALUE · /config save remembers this connection · /login PROVIDER': 'Cambiar: /config set model|effort|provider|permissions|max-tokens|context-window VALOR · /config save recuerda esta conexión · /login PROVEEDOR',
36
+ 'session': 'sesión', 'flag': 'bandera', 'environment': 'entorno', 'saved': 'guardado', 'default': 'omisión',
37
+ 'detected': 'detectada', 'announced {value}': 'anunciada {value}', 'configured limit': 'límite configurado', 'fallback': 'por omisión',
38
+ });
39
+
40
+ const MIN_OUTPUT = 64, MAX_OUTPUT = 1000000, MIN_WINDOW = 4096, MAX_WINDOW = 2000000;
41
+ const FALLBACK_WINDOW = contextSettings({}, {}).contextWindow;
42
+ const SOURCE_LABELS = { session: 'session', cli: 'flag', environment: 'environment', saved: 'saved', fallback: 'default', default: 'default' };
43
+ const sourceLabel = value => t(SOURCE_LABELS[value] || value || 'unknown');
44
+ const integer = text => /^\d{1,9}$/.test(text) ? Number(text) : NaN;
45
+
46
+ export function parseLimit(key, text) {
47
+ const value = String(text ?? '').trim().replace(/[_,]/g, '');
48
+ if (key === 'max-tokens') {
49
+ const n = integer(value);
50
+ if (!Number.isSafeInteger(n) || n < MIN_OUTPUT || n > MAX_OUTPUT) throw new Error(t('Use /max-tokens [N] with N between 64 and 1000000.'));
51
+ return n;
52
+ }
53
+ if (key === 'context-window') {
54
+ if (value.toLowerCase() === 'auto') return 'auto';
55
+ const n = integer(value);
56
+ if (!Number.isSafeInteger(n) || n < MIN_WINDOW || n > MAX_WINDOW) throw new Error(t('Use /context-window [auto|N] with N between 4096 and 2000000.'));
57
+ return n;
58
+ }
59
+ throw new TypeError('Unknown limit.');
60
+ }
61
+
62
+ /**
63
+ * Precedencia al arrancar: bandera > variable de entorno > connection.json >
64
+ * omisión. Si lo guardado no encaja con una bandera, se ignora con aviso en vez
65
+ * de impedir el arranque.
66
+ */
67
+ export function resolveStartupLimits({ flags = {}, saved = {}, env = process.env } = {}) {
68
+ const environment = comalEnvironment(env), warnings = [];
69
+ const fromSaved = { maxTokens: flags.maxTokens == null && environment.COMAL_MAX_TOKENS == null && Number.isSafeInteger(saved['max-tokens']),
70
+ contextWindow: flags.contextWindow == null && environment.COMAL_CONTEXT_WINDOW == null && Number.isSafeInteger(saved['context-window']) };
71
+ const build = useSaved => {
72
+ const config = contextSettings({ contextWindow: flags.contextWindow ?? (useSaved && fromSaved.contextWindow ? saved['context-window'] : undefined),
73
+ maxTokens: flags.maxTokens ?? (useSaved && fromSaved.maxTokens ? saved['max-tokens'] : undefined) }, env);
74
+ if (useSaved && fromSaved.contextWindow) config.contextWindowSource = 'saved';
75
+ config.maxTokensSource = flags.maxTokens != null ? 'cli' : environment.COMAL_MAX_TOKENS != null ? 'environment' : useSaved && fromSaved.maxTokens ? 'saved' : 'default';
76
+ return config;
77
+ };
78
+ if (!fromSaved.maxTokens && !fromSaved.contextWindow) return { config: build(false), warnings };
79
+ try { return { config: build(true), warnings }; }
80
+ catch (error) { warnings.push(t('Saved limits were ignored: {reason}', { reason: error.message })); return { config: build(false), warnings }; }
81
+ }
82
+
83
+ /** Calcula los límites candidatos sin aplicarlos. */
84
+ export function nextLimits(current, key, value) {
85
+ if (key === 'max-tokens') return { ...current, maxTokens: value, maxTokensSource: 'session' };
86
+ return value === 'auto' ? { ...current, contextWindow: FALLBACK_WINDOW, contextWindowSource: 'fallback' }
87
+ : { ...current, contextWindow: value, contextWindowSource: 'session' };
88
+ }
89
+
90
+ /** Regla de resolveContextBudget con un error que dice qué hacer. */
91
+ export function checkLimits(candidate, report) {
92
+ try { return resolveContextBudget(candidate, report); }
93
+ catch (error) {
94
+ let window;
95
+ try { window = resolveContextBudget({ ...candidate, maxTokens: 0 }, report).contextWindow; } catch { throw error; }
96
+ if (window >= candidate.maxTokens * 2) throw error;
97
+ throw Object.assign(new Error(t('The effective window ({window}) must be at least twice max-tokens ({maxTokens}, the output reserve). Lower /max-tokens or raise /context-window; nothing was changed.', { window, maxTokens: candidate.maxTokens })), { code: 'CONTEXT_LIMITS_INVALID' });
98
+ }
99
+ }
100
+
101
+ const SET_KEYS = ['model', 'effort', 'provider', 'permissions', 'max-tokens', 'context-window'];
102
+ export function parseConfigCommand(arg) {
103
+ const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
104
+ if (!parts.length || parts.length === 1 && ['show', 'status'].includes(parts[0])) return { action: 'show' };
105
+ if (parts.length === 1 && parts[0] === 'save') return { action: 'save' };
106
+ const key = parts[1] === 'mode' ? 'permissions' : parts[1];
107
+ if (parts[0] === 'set' && parts.length === 3 && SET_KEYS.includes(key)) return { action: 'set', key, value: parts[2] };
108
+ throw new Error(t('Use /config, /config set KEY VALUE or /config save. Keys: {keys}.', { keys: SET_KEYS.join(', ') }));
109
+ }
110
+
111
+ function loginLine(state, status) {
112
+ if (!status) return t('no credential');
113
+ const words = { authenticated: 'connected', configured: 'configured', expired: 'expired', logged_out: 'logged out' };
114
+ return [t(words[status.status] || 'unknown'), t('method {method}', { method: status.method }),
115
+ status.expiresAt ? t('expires {date}', { date: new Date(status.expiresAt).toISOString() }) : status.status === 'configured' ? t('stored key, does not expire') : ''].filter(Boolean).join(' · ');
116
+ }
117
+ function pluginLine(status) {
118
+ if (!status || !Array.isArray(status.plugins)) return t('unavailable');
119
+ const active = status.plugins.filter(item => item.state === 'active'), other = status.plugins.filter(item => item.state !== 'active');
120
+ return [t('active: {list}', { list: active.map(item => item.id).join(', ') || t('none') }),
121
+ other.length ? t('inactive: {list}', { list: other.map(item => `${item.id} (${item.state})`).join(', ') }) : ''].filter(Boolean).join(' · ');
122
+ }
123
+
124
+ /** Texto de /config. Recibe sólo datos de estado: nunca la llave ni el proveedor vivo. */
125
+ export function formatConfig(view) {
126
+ const row = (label, value) => `${(t(label) + ':').padEnd(16)} ${value}`.trimEnd();
127
+ const budget = view.budget || {};
128
+ const kinds = { detected: 'detected', configured: 'configured limit', fallback: 'fallback' };
129
+ const lines = [t('Comal configuration'), '',
130
+ row('Provider', view.demo ? t('demo without provider') : [view.provider, view.authProvider ? `login ${view.authProvider}` : view.profile ? `profile ${view.profile}` : '', view.endpoint].filter(Boolean).join(' · ')),
131
+ view.authProvider ? row('Login', loginLine(view, view.login)) : row('Credential', view.demo ? t('none') : t('no login · key from {name} ({state})', { name: view.apiKeyEnv || 'OPENAI_API_KEY', state: t(view.apiKeyDefined ? 'defined' : 'not defined') })),
132
+ row('Model', view.model || t('unknown')),
133
+ row('Effort', `${view.effort || 'auto'}${view.thinking ? ` · thinking ${view.thinking}` : ''}`),
134
+ row('Context window', [budget.contextWindow ?? view.limits.contextWindow, t(kinds[budget.kind] || 'unknown'), `${sourceLabel(budget.source)}`, budget.announcedWindow && budget.announcedWindow !== budget.contextWindow ? t('announced {value}', { value: budget.announcedWindow }) : ''].filter(Boolean).join(' · ')),
135
+ row('Max tokens', `${view.limits.maxTokens} · ${sourceLabel(view.limits.maxTokensSource)}`),
136
+ row('Permissions', view.mode),
137
+ row('Plugins', pluginLine(view.plugins)),
138
+ row('Files', ''),
139
+ ...view.paths.map(([label, file]) => ` ${t(label).padEnd(14)} ${file}`),
140
+ '', t('Change: /config set model|effort|provider|permissions|max-tokens|context-window VALUE · /config save remembers this connection · /login PROVIDER')];
141
+ return lines.join('\n');
142
+ }
143
+
144
+ /**
145
+ * Comandos de sesión. `limits` es el objeto vivo de límites de cli.mjs y se
146
+ * muta sólo después de validar. `state()` devuelve datos ya proyectados.
147
+ */
148
+ export class SessionSettings {
149
+ constructor({ limits, flags = {}, env = process.env, authManager, state, applyLimits = async () => {}, delegate, persistLimits = saveLimits, persistConnection = saveConnection }) {
150
+ Object.assign(this, { limits, flags, env, authManager, state, applyLimits, delegate, persistLimits, persistConnection });
151
+ }
152
+ handles(command) { return ['config', 'max-tokens', 'context-window'].includes(command); }
153
+ file() { return connectionPreferenceFile(this.env); }
154
+ async control(command, arg = '') {
155
+ if (command === 'max-tokens' || command === 'context-window') return arg.trim() ? this.setLimit(command, arg) : this.describeLimit(command);
156
+ const action = parseConfigCommand(arg);
157
+ if (action.action === 'show') return formatConfig(await this.view());
158
+ if (action.action === 'save') return this.saveDefault();
159
+ if (['max-tokens', 'context-window'].includes(action.key)) return this.setLimit(action.key, action.value);
160
+ return this.delegate(action.key, action.value);
161
+ }
162
+ describeLimit(key) {
163
+ const budget = this.budget();
164
+ if (key === 'max-tokens') return t('Max tokens: {value} · source {source}', { value: this.limits.maxTokens, source: sourceLabel(this.limits.maxTokensSource) });
165
+ return t('Context window: {value} · {kind} · source {source} · announced {announced} · explicit limit {limit}', { value: budget.contextWindow, kind: budget.kind, source: sourceLabel(budget.source), announced: budget.announcedWindow ?? t('unknown'), limit: budget.configuredLimit ?? t('none') });
166
+ }
167
+ budget() { try { return resolveContextBudget(this.limits, this.state().modelCapabilities); } catch { return { contextWindow: this.limits.contextWindow, kind: 'unknown', source: this.limits.contextWindowSource }; } }
168
+ async setLimit(key, text) {
169
+ const value = parseLimit(key, text), candidate = nextLimits(this.limits, key, value);
170
+ checkLimits(candidate, this.state().modelCapabilities);
171
+ Object.assign(this.limits, candidate);
172
+ await this.applyLimits();
173
+ const lines = [this.describeLimit(key), t('Applied to this session.')];
174
+ try { await this.persistLimits({ [key]: value }, { env: this.env }); lines.push(t('Saved in {file} for the next sessions.', { file: this.file() })); }
175
+ catch (error) { lines.push(t('Applied, but not saved: {reason}', { reason: error.message })); }
176
+ const flag = key === 'max-tokens' ? this.flags.maxTokens : this.flags.contextWindow;
177
+ const variable = key === 'max-tokens' ? 'COMAL_MAX_TOKENS' : 'COMAL_CONTEXT_WINDOW';
178
+ if (flag != null) lines.push(t('The startup flag {flag} still wins over the saved value when it is passed.', { flag: '--' + key }));
179
+ else if (comalEnvironment(this.env)[variable] != null) lines.push(t('The environment variable {name} still wins over the saved value.', { name: variable }));
180
+ return lines.join('\n');
181
+ }
182
+ async saveDefault() {
183
+ const s = this.state();
184
+ if (s.demo) throw new Error(t('The demo has no connection to save.'));
185
+ let selection;
186
+ if (s.authProvider) selection = { 'auth-provider': s.authProvider, ...(s.model ? { model: s.model } : {}) };
187
+ else if (s.profile) throw new Error(t('Profiles are selected with defaultProfile in {file}; nothing was saved.', { file: s.paths?.profiles || '.comal/providers.json' }));
188
+ else if (s.provider === 'openai' && s.endpoint) selection = { provider: 'openai', 'base-url': s.endpoint, ...(s.model ? { model: s.model } : {}) };
189
+ else throw new Error(t('Profiles are selected with defaultProfile in {file}; nothing was saved.', { file: s.paths?.profiles || '.comal/providers.json' }));
190
+ await this.persistConnection(selection, { env: this.env });
191
+ return t('Default connection saved in {file}: {summary}. Startup flags still win.', { file: this.file(), summary: Object.entries(selection).map(([k, v]) => `${k}=${v}`).join(' ') });
192
+ }
193
+ async view() {
194
+ const s = this.state();
195
+ let login = null;
196
+ if (s.authProvider && AUTH_PROVIDERS.some(item => item.id === s.authProvider)) {
197
+ try { const status = await this.authManager.status(s.authProvider); login = { status: status.status, method: status.method, ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}) }; }
198
+ catch { login = { status: 'unknown', method: 'unknown' }; }
199
+ }
200
+ return { demo: s.demo, provider: s.provider, authProvider: s.authProvider, profile: s.profile, endpoint: s.endpoint, model: s.model, effort: s.effort, thinking: s.thinking,
201
+ apiKeyEnv: s.apiKeyEnv, apiKeyDefined: Boolean(s.apiKeyEnv && this.env[s.apiKeyEnv]), login, budget: this.budget(), limits: { ...this.limits }, mode: s.mode, plugins: s.plugins,
202
+ paths: [['connection', this.file()], ['credentials', this.authManager?.directory || ''], ...(s.paths?.profiles ? [['profiles', s.paths.profiles]] : []), ...(s.paths?.plugins ? [['plugins', s.paths.plugins]] : [])] };
203
+ }
204
+ }
@@ -1,5 +1,6 @@
1
1
  import { digest } from './operations.mjs';
2
2
  import { auditExecution } from './audit.mjs';
3
+ import { isHumanIntentRecord } from './trusted-context.mjs';
3
4
 
4
5
  // A view change only. Full receipts and the original visible messages remain in
5
6
  // the append-only book. No model-generated claims authorize this substitution.
@@ -42,7 +43,7 @@ export function rapidPressureCount(rows, {maxModelRequests=3}={}) {
42
43
  let requests=0,count=0;
43
44
  for(let index=rows.length-1;index>=0;index--){
44
45
  const row=rows[index];
45
- if(row.type==='reset' || row.type==='intent')break;
46
+ if(row.type==='reset' && !row.source || isHumanIntentRecord(row))break;
46
47
  if(row.type==='model_request')requests++;
47
48
  if(requests>maxModelRequests)break;
48
49
  if(row.type==='context_projection' && row.reason!=='manual')count++;
@@ -1,5 +1,3 @@
1
- import { mkdir, open } from 'node:fs/promises';
2
- import { sealRecord } from './audit.mjs';
3
1
  import { digest } from './operations.mjs';
4
2
  import { turnOutcome as executionOutcome } from './execution-metrics.mjs';
5
3
 
@@ -10,23 +8,19 @@ export function agentRuns(rows) {
10
8
  return [...runs.values()];
11
9
  }
12
10
  export function publicAgentRun(run) {
13
- const keys = ['run_id', 'launch_id', 'session_id', 'parent_session_id', 'parent_invocation_id', 'turn_id', 'description', 'subagent_type', 'max_turns', 'runner_state', 'turn_state', 'work_state', 'status', 'delivery_state', 'background', 'async_launched', 'asked', 'work_stop_reason', 'yielded', 'result', 'result_truncated', 'turns', 'usage', 'evidence', 'house', 'result_ref', 'error', 'manual_stop', 'outcome', 'phase'];
11
+ const keys = ['run_id', 'launch_id', 'session_id', 'parent_session_id', 'parent_invocation_id', 'turn_id', 'description', 'subagent_type', 'max_turns', 'runner_state', 'turn_state', 'work_state', 'status', 'delivery_state', 'background', 'async_launched', 'asked', 'work_stop_reason', 'work_stop_cause', 'turn_termination', 'yielded', 'result', 'result_truncated', 'turns', 'usage', 'evidence', 'house', 'result_ref', 'result_read_call','error','code', 'manual_stop', 'outcome', 'phase','source_workspace','source_offer'];
14
12
  return Object.fromEntries(keys.filter(key => run[key] !== undefined).map(key => [key, run[key]]));
15
13
  }
16
14
  export function turnOutcome(result = {}) {
17
15
  const outcome = executionOutcome(result), cause = outcome.turn;
18
16
  return { runner_state: outcome.runner, turn_state: cause, work_state: outcome.work, asked: 'unjudged',
19
- status: ['failed', 'cancelled'].includes(cause) ? cause : ['budget', 'no_progress', 'permission_denied', 'blocked', 'plan_mode', 'yielded'].includes(cause) ? 'paused' : 'completed',
20
- ...(result.work_stop_reason ? { work_stop_reason: result.work_stop_reason } : {}), ...(result.yielded ? { yielded: true } : {}) };
17
+ status: ['failed', 'cancelled'].includes(cause) ? cause : ['budget', 'no_progress', 'permission_denied', 'native_refused', 'blocked', 'plan_mode', 'yielded'].includes(cause) ? 'paused' : 'completed',
18
+ ...(result.work_stop_reason ? { work_stop_reason: result.work_stop_reason, ...(result.work_stop_cause?{work_stop_cause:structuredClone(result.work_stop_cause)}:{}) } : {}), ...(result.turn_termination ? { turn_termination: structuredClone(result.turn_termination) } : {}), ...(result.yielded ? { yielded: true } : {}) };
21
19
  }
22
20
  // Child identity is reserved in the parent BEFORE file creation. Reconciliation
23
21
  // creates the same UUID, so a kill between the two journals cannot spawn twice.
24
22
  export async function ensureAgentSession(store, run) {
25
- await mkdir(store.dir, { recursive: true, mode: 0o700 });
26
- let file;
27
- try { file = await open(store.file(run.session_id), 'wx', 0o600); }
28
- catch (error) { if (error.code !== 'EEXIST') throw error; }
29
- if (file) try { await file.writeFile(JSON.stringify(sealRecord({ type: 'session', id: run.session_id, cwd: store.cwd, model: run.configuration.model, provider: run.configuration.provider, created_at: run.created_at }, 0)) + '\n'); await file.sync(); } finally { await file.close(); }
23
+ await store.reserve({id:run.session_id,model:run.configuration.model,provider:run.configuration.provider,created_at:run.created_at});
30
24
  return store.workTransaction(run.session_id, async () => {
31
25
  const session = await store.load(run.session_id);
32
26
  if (!session.journalAudit.consistent) throw Object.assign(new Error('Libro hijo alterado.'), { code: 'AUDIT_INVALID' });
@@ -37,4 +31,4 @@ export async function ensureAgentSession(store, run) {
37
31
  return store.load(run.session_id);
38
32
  });
39
33
  }
40
- export function outcomeDigest(item) { return digest({ run_id: item.run_id, turn_id: item.turn_id, result_ref: item.result_ref, turn_state: item.turn_state, work_state: item.work_state, result: item.result, evidence: item.evidence }); }
34
+ export function outcomeDigest(item) { return digest({ run_id: item.run_id, turn_id: item.turn_id, result_ref: item.result_ref, turn_state: item.turn_state, work_state: item.work_state, result: item.result, evidence: item.evidence,...(item.source_offer?{source_offer:item.source_offer}:{}) }); }
@@ -0,0 +1,8 @@
1
+ // Classifier output belongs only to permission decision/result records. This check
2
+ // deliberately does not claim to authenticate arbitrary journal authorship.
3
+ export const inadmissibleClassifierRecord = row => ['classifier', 'classifier-error'].includes(row.source) && !['auto_decision', 'permission'].includes(row.type);
4
+ export const classifierChannelViolations = rows => rows.flatMap((row, index) => inadmissibleClassifierRecord(row) ? [{ row: index, reason: 'classifier_record_channel_mismatch' }] : []);
5
+ export function assertClassifierChannels(rows) {
6
+ const violations = classifierChannelViolations(rows);
7
+ if (violations.length) throw Object.assign(new Error('Classifier records outside their decision channel; session cannot resume. The original journal is preserved for inspection.'), { code: 'JOURNAL_SOURCE_MISMATCH', violations });
8
+ }
@@ -1,9 +1,13 @@
1
+ import {sourceTaskRecoveryValid} from './source-task-proof.mjs';
2
+ import {sourcePublicationLeafValid,sourceRecoveryValid} from './source-proposal-proof.mjs';
3
+ import {gitIntegrationRecoveryValid} from './git-integration-recovery-proof.mjs';
1
4
  import { digest } from './operations.mjs';
2
5
  import { workLeafValid, workRecoveryValid } from './work-recovery-proof.mjs';
3
6
  import { chromeRecoveryValid,chromeObservationRetryValid } from './chrome-recovery-proof.mjs';
4
7
  import { agentRecoveryValid } from './agent-recovery-proof.mjs';
5
- export const recoveryAttribution=kind=>({file_postcondition:'not_established',not_started:'handler_not_entered',read_interrupted:'read_result_unavailable',work_not_dispatched:'no_completed_mutation_dispatch',work_child_receipts:'recorded_child_receipts',work_draft_saved:'recorded_draft_chunk',chrome_publication:'observed_publication_only',chrome_observation_retry:'read_result_unavailable',agent_launch_recorded:'registered_children_only',agent_not_dispatched:'no_child_dispatch',agent_read_retry:'read_result_unavailable',agent_control_recorded:'control_handler_return_unavailable'})[kind] || 'unknown';
6
- export function nativeRecoveryRead(rows,p){const c=rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===p.contract_digest)?.contract;return ['Read','Glob','Grep','ListDirectory','FileInfo'].includes(p.operation)&&c?.origin===null&&c.name===p.operation&&c.effects.workspace==='read'&&c.effects.external==='none'&&c.permission===p.operation;}
8
+ import { chromeCandidateRecoveryValid } from './chrome-candidate-recovery-proof.mjs';
9
+ export const recoveryAttribution=kind=>({file_postcondition:'not_established',not_started:'handler_not_entered',source_recorded:'recorded_source_children_not_delivery',source_task_recorded:'recorded_source_task_children_not_delivery',source_task_read:'read_result_unavailable',read_interrupted:'read_result_unavailable',work_not_dispatched:'no_completed_mutation_dispatch',work_child_receipts:'recorded_child_receipts',work_draft_saved:'recorded_draft_chunk',chrome_candidate:'recorded_candidate_children_not_current_delivery',chrome_publication:'observed_publication_only',chrome_observation_retry:'read_result_unavailable',agent_launch_recorded:'registered_children_only',agent_not_dispatched:'no_child_dispatch',agent_read_retry:'read_result_unavailable',agent_control_recorded:'control_handler_return_unavailable',git_integration_revision_call:'recorded_revision_composition_not_current_delivery',git_integration_intent:'registered_git_intent_not_current_delivery',git_integration_not_dispatched:'no_git_dispatch_from_this_handler'})[kind] || 'unknown';
10
+ export function nativeRecoveryRead(rows,p){const c=rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===p.contract_digest)?.contract;return ['Read','Glob','Grep','ListDirectory','FileInfo','GitInspect','GitIntegrationStatus','GitIntegrationEvidence','SourceRead'].includes(p.operation)&&c?.origin===null&&c.name===p.operation&&c.effects.workspace==='read'&&c.effects.external==='none'&&c.permission===p.operation&&(p.operation!=='GitInspect'||!c.steps&&c.effects.process==='read'&&c.effects.authority==='none');}
7
11
 
8
12
  export const recoveredFileOutput = preparation => JSON.stringify({...JSON.parse(preparation.output),status:'postcondition_observed',prepared_status:JSON.parse(preparation.output).status,recovered:true,handler_return:'unavailable',attribution:'not_established'});
9
13
  export function filePreparationValid(...args) {try{return preparationValid(...args);}catch{return false;}}
@@ -28,11 +32,15 @@ function receiptValid(rows,index,row) {
28
32
  const begins=rows.slice(r.proposal_ref+1,index).filter(e=>e.type==='house_started' && e.invocation_id===row.invocation_id);
29
33
  if(begins.some(begin=>['parent_invocation_id','operation','arguments_digest','contract_digest','actor','surface','work','house','session_id','execution_process'].some(key=>digest(begin[key]??null)!==digest(proposal[key]??null))))return false;
30
34
  if(r.kind==='read_interrupted')return begins.length===1 && nativeRecoveryRead(rows.slice(0,index),proposal) && row.status==='refused' && row.code==='HOUSE_RECOVERY_READ_RETRY' && row.effects_observed==='read_only' && !rows.slice(r.proposal_ref,index).some(e=>e.type==='house_proposal' && e.parent_invocation_id===proposal.invocation_id);
35
+ if(r.kind==='source_task_recorded'||r.kind==='source_task_read')return begins.length===1&&sourceTaskRecoveryValid(rows,index,row);
36
+ if(r.kind==='source_recorded')return begins.length===1&&sourceRecoveryValid(rows,index,row);
37
+ if(r.kind.startsWith('git_integration_'))return begins.length===1&&gitIntegrationRecoveryValid(rows,index,row);
31
38
  if(r.kind.startsWith('agent_'))return begins.length===1 && agentRecoveryValid(rows,index,row);
32
39
  if(r.kind.startsWith('work_'))return begins.length===1 && workRecoveryValid(rows,index,row);
33
40
  if(r.kind==='chrome_publication')return chromeRecoveryValid(rows,index,row);
41
+ if(r.kind==='chrome_candidate')return begins.length===1&&chromeCandidateRecoveryValid(rows,index,row);
34
42
  if(r.kind==='chrome_observation_retry')return chromeObservationRetryValid(rows,index,row);
35
- if(!workLeafValid(rows,proposal))return false;
43
+ if(!workLeafValid(rows,proposal)&&!sourcePublicationLeafValid(rows,proposal))return false;
36
44
  if(r.kind==='not_started')return !begins.length && row.status==='refused' && row.effects_observed==='none' && row.code==='HOUSE_RECOVERY_NOT_STARTED';
37
45
  if(r.kind!=='file_postcondition' || row.status!=='returned' || row.judgment_scope!=='observed_file_postcondition_not_handler_return' || begins.length!==1 || !Number.isSafeInteger(r.preparation_ref) || !Number.isSafeInteger(r.observation_ref) || r.preparation_ref<=r.proposal_ref || r.observation_ref<=r.preparation_ref || r.observation_ref>=index)return false;
38
46
  const prepared=rows[r.preparation_ref], read=rows[r.observation_ref], contract=rows.slice(0,r.proposal_ref).findLast(e=>e.type==='house_contract' && e.contract_digest===proposal.contract_digest)?.contract;
@@ -1,3 +1,6 @@
1
+ import {nativeSourceTask,sourceTaskRecoveryPlan} from './source-task-proof.mjs';
2
+ import {nativeSourceParent,sourceRecoveryPlan} from './source-proposal-proof.mjs';
3
+ import {nativeGitIntegration,gitIntegrationRecoveryPlan} from './git-integration-recovery-proof.mjs';
1
4
  import { randomUUID } from 'node:crypto';
2
5
  import { auditExecution } from './audit.mjs';
3
6
  import { digest } from './operations.mjs';
@@ -6,6 +9,7 @@ import { filePreparationValid, recoveredFileOutput, recoveryReceiptValid,nativeR
6
9
  import { nativeWorkParent, workRecoveryPlan } from './work-recovery-proof.mjs';
7
10
  import { chromeRecoveryMessage,chromeObservationMessage } from './chrome-recovery-proof.mjs';
8
11
  import { nativeDelegation, agentRecoveryPlan } from './agent-recovery-proof.mjs';
12
+ import { nativeChromeCandidate, chromeCandidateRecoveryPlan } from './chrome-candidate-recovery-proof.mjs';
9
13
 
10
14
  function pendingFromDeadProcess(session,actor,active=new Set()){
11
15
  const closed=new Set(session.rows.filter(r=>r.type==='house_receipt').map(r=>r.invocation_id)), starts=new Set(session.rows.filter(r=>r.type==='house_started').map(r=>r.invocation_id)), owners=new Map();
@@ -63,11 +67,31 @@ async function reconcile({store,house,executor,sessionId,actor,signal}) {
63
67
  else if(nativeRecoveryRead(session.rows,p)){
64
68
  recovery.kind='read_interrupted';receipt={...base,type:'house_receipt',source:'recovery_host',status:'refused',code:'HOUSE_RECOVERY_READ_RETRY',error:'Lectura nativa interrumpida sin resultado durable. Puede repetirse bajo permisos actuales; no se reconstruyeron bytes ni se repitió una mutación.',effects_observed:'read_only',observations:[],asked:'unjudged',recovery};
65
69
  }
70
+ else if(nativeSourceTask(session.rows,p)){
71
+ const plan=sourceTaskRecoveryPlan(session.rows,p);if(!plan)return blocked('The original accepted source task/children do not verify; no stage was replayed.');
72
+ recovery.kind=plan.kind;recovery.plan_digest=digest(plan);receipt={...base,type:'house_receipt',source:'recovery_host',status:'refused',code:plan.code,error:plan.error,effects_observed:plan.effects_observed,observations:[],asked:'unjudged',recovery};
73
+ }
74
+ else if(nativeSourceParent(session.rows,p)){
75
+ const plan=sourceRecoveryPlan(session.rows,p);if(!plan)return blocked('The retained-source children have unsettled or incompatible outcomes; nothing was replayed.');
76
+ recovery.kind=plan.kind;recovery.plan_digest=digest(plan);
77
+ receipt={...base,type:'house_receipt',source:'recovery_host',status:'refused',code:plan.code,error:plan.error,effects_observed:plan.effects_observed,observations:[],asked:'unjudged',recovery};
78
+ }
66
79
  else if(nativeDelegation(session.rows,p)){
67
80
  const plan=agentRecoveryPlan(session.rows,p);if(!plan)return blocked('No valida la preparación original o el parentesco del lanzamiento delegado');
68
81
  recovery.kind=plan.kind;recovery.plan_digest=digest(plan);
69
82
  receipt={...base,type:'house_receipt',source:'recovery_host',status:'refused',code:plan.code,error:plan.error,effects_observed:plan.effects_observed,judgment_scope:'registered_child_execution_not_delivery',observations:[],asked:'unjudged',recovery};
70
83
  }
84
+ else if(nativeGitIntegration(session.rows,p)){
85
+ const plan=gitIntegrationRecoveryPlan(session.rows,p);if(!plan)return blocked('The original native Git intent does not verify.');
86
+ recovery.kind=plan.kind;recovery.plan_digest=digest(plan);
87
+ receipt={...base,type:'house_receipt',source:'recovery_host',status:'refused',code:plan.code,error:plan.error,effects_observed:plan.effects_observed,observations:[],asked:'unjudged',recovery};
88
+ }
89
+ else if(nativeChromeCandidate(session.rows,p)){
90
+ const plan=chromeCandidateRecoveryPlan(session.rows,p);if(!plan)return blocked('Candidate children contain an uncertain or incompatible effect; no child is replayed.');
91
+ recovery.kind='chrome_candidate';recovery.plan_digest=digest(plan);
92
+ const contract=session.rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===p.contract_digest)?.contract;
93
+ receipt={...base,type:'house_receipt',source:'recovery_host',status:plan.status,observations:[],asked:'unjudged',recovery,...(plan.status==='returned'?{effects:contract.effects,disposition:'serve',judgment_scope:'recorded_child_execution_not_current_delivery',authority:'reconstructed_child_receipts',output:plan.output,output_digest:digest(plan.output),output_truncated:false}:{code:plan.code,error:plan.error,effects_observed:plan.effects_observed})};
94
+ }
71
95
  else if(nativeWorkParent(session.rows,p)){
72
96
  const plan=workRecoveryPlan(session.rows,p);if(!plan)return blocked('El coordinador no tiene hechos/hijos completos y compatibles para reconstruir su respuesta');
73
97
  recovery.kind=plan.kind;recovery.plan_digest=digest(plan);
@@ -67,7 +67,12 @@ export function toGeminiContents(messages, capabilities) {
67
67
  }
68
68
 
69
69
  function normalize(parts, finishReason, usage, groundingMetadata, usage_accounting) {
70
- if (finishReason === 'MAX_TOKENS') throw Object.assign(new Error('Respuesta Gemini truncada; no se ejecutaron herramientas.'), { code: 'OUTPUT_LENGTH', usage, usage_accounting });
70
+ if (finishReason === 'MAX_TOKENS') {
71
+ const last = parts.at(-1), size = (pick) => parts.reduce((sum, part) => sum + pick(part), 0);
72
+ const details = { text_chars: size(part => typeof part.text === 'string' && !part.thought ? part.text.length : 0), reasoning_chars: size(part => typeof part.text === 'string' && part.thought ? part.text.length : 0),
73
+ tool_argument_chars: size(part => part.functionCall ? JSON.stringify(part.functionCall.args ?? {}).length : 0), ...(typeof last?.functionCall?.name === 'string' && last.functionCall.name ? { tool_name: last.functionCall.name } : {}) };
74
+ throw Object.assign(new Error('Respuesta Gemini truncada; no se ejecutaron herramientas.'), { code: 'OUTPUT_LENGTH', usage, usage_accounting, details });
75
+ }
71
76
  if (finishReason !== 'STOP') throw new Error(`Respuesta Gemini incompleta (${finishReason || 'stream interrumpido'}); no se ejecutaron herramientas.`);
72
77
  const content = [], ids = new Set();
73
78
  for (const part of parts) {
@@ -0,0 +1,12 @@
1
+ import{digest}from'./operations.mjs';
2
+ import{scopeCeiling,validateScopeCeiling}from'./scope-ceiling.mjs';
3
+ import{integrationLineage}from'./git-integration-lineage.mjs';
4
+ export function joinRevisionCeilings(a,b,cwd,parent){a=validateScopeCeiling(a,{house:cwd,session_id:parent});b=validateScopeCeiling(b,{house:cwd,session_id:parent});const unique=items=>[...new Map(items.map(v=>[digest(v),v])).values()];return scopeCeiling({schema:a.schema,parent:a.parent,laws:unique([...a.laws,...b.laws]),guards:unique([...a.guards,...b.guards])});}
5
+ // Historical binding only; current native resource/goal/authority are checked by
6
+ // the admitted host frame and the child runtime, never inferred from this row.
7
+ export function revisionOrigin(rows,task,workspace,{parentId,actor,cwd}={}){
8
+ if(!task.git_revision)return workspace?.revision_source||workspace?.revision_origin?undefined:null;
9
+ try{const link=workspace.revision_origin,r=rows[link.row],s=workspace.revision_source,b=r.binding,k=b.resolved.kernel_input;if(r.type!=='git_integration_intent'||r.source!=='git_integration_host'||r.mechanism!=='comal-git-integration-v1'||r.operation!=='GitIntegrationPrepare'||r.session_id!==parentId||r.cwd!==cwd||r.audit.sha256!==link.digest||r.binding_digest!==digest(b)||r.binding_digest!==link.binding_digest||r.owner_digest!==digest(b.owner)||digest(b.owner)!==digest({actor,session_id:parentId})||r.input_digest!==digest(r.input)||b.resolved.integration_id!==task.git_revision||workspace.source!=='revision'||workspace.expected_source_identity!==digest(s)||s.integration_id!==task.git_revision||s.owner_digest!==r.owner_digest||s.verification_digest!==digest(k.verification)||s.target!==k.target||s.expected_target!==k.expected_target||s.base_commit!==k.base_commit||task.isolation!=='git'||task.work_tracking!=='parent'||task.git_source||task.run_in_background)return undefined;
10
+ const joined=validateScopeCeiling(workspace.ceiling,{house:cwd,session_id:parentId});for(const ancestor of integrationLineage(rows,r)){const original=validateScopeCeiling(ancestor.binding.ceiling,{house:cwd,session_id:parentId});for(const key of ['laws','guards'])if(original[key].some(v=>!joined[key].some(j=>digest(j)===digest(v))))return undefined;}return r;
11
+ }catch{return undefined;}
12
+ }
@@ -0,0 +1,143 @@
1
+ import {GitIntegrationOperations} from './git-integration-operations.mjs';
2
+ import {revisionOrigin,joinRevisionCeilings} from './git-agent-revision.mjs';
3
+ import {preparedEditorRole,reservedEditorRole} from './source-editor-role.mjs';
4
+ import path from 'node:path';
5
+ import {realpath} from 'node:fs/promises';
6
+ import {SessionStore} from './sessions.mjs';
7
+ import {GitWorkspaces,gitWorkspaceResourceId} from './git-workspaces.mjs';
8
+ import {inspectGitSource} from './git-source.mjs';
9
+ import {readGit} from './git-plumbing.mjs';
10
+ import {digest} from './operations.mjs';
11
+ import {auditExecution} from './audit.mjs';
12
+ import {toolDefinitions} from './tools.mjs';
13
+ import {WORK_NAMES,registerWorkOperations} from './work-plan.mjs';
14
+ import {evidenceOperations} from './work-state.mjs';
15
+ import {ensureAgentSession} from './durable-agent-runs.mjs';
16
+
17
+ export const GIT_AGENT_MECHANISM='comal-git-agent-v1';
18
+ const fail=(message,code='AGENT_GIT_BINDING_INVALID')=>Object.assign(new Error(message),{code});
19
+ const sourceTools=new Set([...toolDefinitions.map(tool=>tool.name),...WORK_NAMES,'EvidenceRead']);
20
+ export const agentKind=task=>task.subagent_type||(task.isolation==='git'?'general':'explore');
21
+ export const gitAgentActor=run=>({id:run.session_id,kind:'subagent',parent_session_id:run.parent_session_id});
22
+ const owner=run=>({actor:gitAgentActor(run),session_id:run.session_id});
23
+ const ref=(book,index)=>({session_id:book.id,row:index,digest:book.rows[index].audit.sha256});
24
+
25
+ // Native source/runtime port. It does not copy a plugin registry or acquire a
26
+ // provider slot; SubagentManager keeps original run, turn and budget leases.
27
+ export class GitAgentWorkspaces {
28
+ constructor({cwd,house,store,policy,kernel=new GitWorkspaces({cwd}),fault=async()=>{}}){Object.assign(this,{cwd,house,store,policy,kernel,fault});this.stores=new Map();this.revisionFrames=new Map();}
29
+ async prepare(tasks,parentSessionId,signal,options={}){
30
+ if(!tasks.some(task=>task.isolation==='git'))return tasks.map(()=>null);
31
+ for(const task of tasks.filter(task=>task.isolation==='git')){
32
+ if(agentKind(task)!=='general')throw fail('Git isolation currently accepts native general source tasks.','AGENT_GIT_SCOPE');
33
+ if((task.tools||[]).some(name=>!sourceTools.has(name)))throw fail('An isolated source agent needs an explicit workspace-aware port for plugin/web/MCP operations. No root-bound closure was copied.','AGENT_GIT_SCOPE');
34
+ }
35
+ const identity=await inspectGitSource({cwd:this.cwd,signal}),ceiling=this.house.captureScopeCeiling(parentSessionId);
36
+ return Promise.all(tasks.map(async task=>{
37
+ if(task.isolation!=='git')return null;
38
+ if(!task.git_revision)return{kind:'git',source:task.git_source||'working',expected_source_identity:identity.source_identity,repository_root:identity.repository.root,ceiling};
39
+ if(!options.actor||typeof options.revalidate!=='function'||!this.policy)throw fail('Native revision admission requires an active owned Agent frame.','AGENT_REVISION_FRAME_REQUIRED');
40
+ await this.revisionAdmission(options,parentSessionId);
41
+ const service=new GitIntegrationOperations({cwd:this.cwd,house:this.house,store:this.store,policy:this.policy}),context={...options,sessionId:parentSessionId},original=await service.preparation(context,task.git_revision),chain=await service.lineage(context,original);if(chain.some(i=>i.binding.authority.denied.includes('Agent'))||this.policy.denied.has('Agent')||this.policy.mode==='plan')throw fail('The original/current native delegation ceiling refuses this revision.','HOUSE_REFUSED');await service.frame(context,chain);
42
+ const source=await this.kernel.revisionSource(task.git_revision,{owner:original.binding.owner,signal}),book=await this.store.load(parentSessionId);
43
+ return{kind:'git',source:'revision',expected_source_identity:digest(source),repository_root:identity.repository.root,ceiling:chain.reduce((a,i)=>joinRevisionCeilings(a,i.binding.ceiling,this.cwd,parentSessionId),ceiling),revision_source:source,revision_origin:{row:book.rows.findIndex(r=>r.audit.sha256===original.audit.sha256),digest:original.audit.sha256,binding_digest:original.binding_digest}};
44
+ }));
45
+ }
46
+ reservation(run,input){return input?{...input,workspace_id:gitWorkspaceResourceId(input.repository_root,owner(run),run.run_id),request_id:run.run_id}:undefined;}
47
+ async validate(run,book){
48
+ if(!run.workspace){if(run.task?.isolation==='git')throw fail('A Git task cannot fall back to the shared workspace.');return;}
49
+ const initial=book.rows.find(row=>row.type==='subagent'&&row.source==='delegation_host'&&row.runner_state==='reserved'&&row.run_id===run.run_id),prepared=book.rows.find(row=>row.type==='agent_launch_prepared'&&row.source==='delegation_host'&&row.invocation_id===run.parent_invocation_id),index=prepared&&(prepared.operation==='AgentBatch'?prepared.input.tasks:[prepared.input]).findIndex((_,i)=>run.launch_id===`${prepared.invocation_id}:${i}`);
50
+ if(!book.journalAudit.verified||!initial||index<0||!prepared?.workspace_inputs?.[index]||digest(initial.workspace)!==digest(run.workspace)||digest(this.reservation(run,prepared.workspace_inputs[index]))!==digest(run.workspace)||preparedEditorRole(prepared,index)===undefined||initial.system!==(preparedEditorRole(prepared,index)?.system??prepared.system)||digest(initial.editor_role??null)!==digest(reservedEditorRole(prepared,index))||digest(run.editor_role??null)!==digest(initial.editor_role??null)||initial.session_id!==run.session_id||initial.parent_session_id!==run.parent_session_id||initial.input_digest!==digest(run.task)||run.workspace.repository_root!==await realpath(this.cwd))throw fail('The saved Git run differs from its original parent reservation.');
51
+ if(revisionOrigin(book.rows,run.task,run.workspace,{parentId:run.parent_session_id,actor:prepared.actor,cwd:this.cwd})===undefined)throw fail('The revision origin or inherited source constraints changed.','AGENT_REVISION_BINDING');
52
+ }
53
+ async revisionAdmission(options,parentId){
54
+ if(typeof options?.revalidate!=='function'||options.sessionId!==parentId)throw fail('A revision needs an admitted native delegation invocation.','AGENT_REVISION_FRAME_REQUIRED');
55
+ const observed=await options.revalidate(),book=await this.store.load(parentId),proposal=book.rows.find(r=>r.type==='house_proposal'&&r.invocation_id===options.invocation_id),contract=book.rows.find(r=>r.type==='house_contract'&&r.contract_digest===proposal?.contract_digest)?.contract;
56
+ if(observed?.invocation_id!==options.invocation_id||!proposal||!['Agent','AgentBatch','AgentContinue','AgentResume'].includes(proposal.operation)||proposal.operation!==options.command_name||proposal.session_id!==parentId||proposal.house!==this.cwd||digest(proposal.actor)!==digest(options.actor)||contract?.name!==proposal.operation||contract.origin?.plugin!=='delegation'||contract.origin.builtin!==true||!contract.description.includes('comal-delegation-v1'))throw fail('A read/plugin frame cannot create, resume or publish a revision editor.','AGENT_REVISION_FRAME_REQUIRED');
57
+ }
58
+ releaseRevisionFrames(invocation){if(invocation)for(const[id,value]of this.revisionFrames)if(value.invocation_id===invocation)this.revisionFrames.delete(id);}
59
+ async originalRevision(run){
60
+ const book=await this.store.load(run.parent_session_id);if(!book.journalAudit.verified||auditExecution(book,this.house.stateContext()).violations.length)throw fail('The original revision parent book does not verify.','AUDIT_INVALID');await this.validate(run,book);const prepared=book.rows.find(r=>r.type==='agent_launch_prepared'&&r.invocation_id===run.parent_invocation_id),original=revisionOrigin(book.rows,run.task,run.workspace,{parentId:run.parent_session_id,actor:prepared.actor,cwd:this.cwd});
61
+ if(!original)throw fail('The original revision integration does not verify.','AGENT_REVISION_BINDING');
62
+ const service=new GitIntegrationOperations({cwd:this.cwd,house:this.house,store:this.store,policy:this.policy}),chain=await service.lineage({actor:prepared.actor,sessionId:run.parent_session_id},original);if(chain.some(i=>i.binding.goal_owner!==(book.goal?.status==='active'?book.goal.id:null)))throw fail('The original revision execution goal changed.','GIT_GOAL_CHANGED');
63
+ await this.kernel.validateRevisionSource(run.workspace.revision_source,{owner:original.binding.owner});return original;
64
+ }
65
+ async revisionFrame(run,options=this.revisionFrames.get(run.run_id)){
66
+ if(typeof options?.revalidate!=='function'||options.sessionId!==run.parent_session_id)throw fail('Continue this revision through an admitted native Agent/Continue/Resume frame. Reads do not allocate or publish a correction.','AGENT_REVISION_FRAME_REQUIRED');
67
+ await this.revisionAdmission(options,run.parent_session_id);
68
+ const original=await this.originalRevision(run),service=new GitIntegrationOperations({cwd:this.cwd,house:this.house,store:this.store,policy:this.policy}),chain=await service.lineage(options,original);if(chain.some(i=>i.binding.authority.denied.includes('Agent'))||this.policy.denied.has('Agent')||this.policy.mode==='plan')throw fail('The original/current native delegation ceiling refuses this revision.','HOUSE_REFUSED');await service.frame(options,chain);return original;
69
+ }
70
+ async admitRevision(run,options){this.revisionFrames.set(run.run_id,options);return this.revisionFrame(run,options);}
71
+ async allocation(run,signal,options){
72
+ if(!run.workspace)return;
73
+ if(run.task.git_revision&&options?.revalidate)this.revisionFrames.set(run.run_id,options);
74
+ // Settled allocation is a fact, even after logical retirement. Historical
75
+ // evidence lookup must not re-enter a mutation merely to find its journal.
76
+ try{const status=await this.kernel.status(run.workspace.workspace_id,{owner:owner(run)}),opened=status.completed.find(row=>row.action==='open')?.output;if(opened)return opened;}catch(error){if(!['ENOENT','GIT_RESOURCE_UNINITIALIZED'].includes(error.code))throw error;}
77
+ const input={request_id:run.workspace.request_id,source:run.workspace.source,expected_source_identity:run.workspace.expected_source_identity};let work;
78
+ if(run.task.git_revision){const original=await this.revisionFrame(run,options),kernel=new GitWorkspaces({cwd:this.cwd,fault:this.kernel.fault,beforeEffect:()=>this.revisionFrame(run)});work=await kernel.openRevision({request_id:run.workspace.request_id,source:run.workspace.revision_source},{owner:owner(run),source_owner:original.binding.owner,signal});}
79
+ else work=await this.kernel.open(input,{owner:owner(run),signal});
80
+ if(work.workspace_id!==run.workspace.workspace_id)throw fail('Kernel allocation differs from its reserved resource.');await this.fault('workspace_allocated',{run,work});return work;
81
+ }
82
+ async location(run,{live=false}={}){
83
+ const status=await this.kernel.status(run.workspace.workspace_id,{owner:owner(run)}),opened=status.completed.find(row=>row.action==='open')?.output;
84
+ if(!opened||status.spec.request_id!==run.workspace.request_id||status.spec.source!==run.workspace.source||status.spec.expected_source_identity!==run.workspace.expected_source_identity||opened.workspace_id!==run.workspace.workspace_id||status.owner.session_id!==run.session_id||digest(status.owner.actor)!==digest(gitAgentActor(run))||opened.path!==path.join(this.kernel.worktrees,'work-'+run.workspace.workspace_id)||await realpath(opened.path)!==opened.path)throw fail('The owned source path, original intent or actor differs from its native resource.');
85
+ if(run.task.git_revision&&digest(status.spec.revision)!==digest(run.workspace.revision_source))throw fail('Revision workspace selected another native source.','AGENT_REVISION_BINDING');
86
+ if(live&&(status.lifecycle!=='retained'||status.pending.length))throw fail('The original source resource is retired or has an unsettled Git action.','AGENT_GIT_UNSETTLED');
87
+ let store=this.stores.get(opened.path);if(!store){store=new SessionStore({cwd:opened.path,stateDir:path.join(opened.path,'.comal')});this.stores.set(opened.path,store);if(this.stores.size>32)this.stores.delete(this.stores.keys().next().value);}
88
+ return{status,opened,store};
89
+ }
90
+ async ensure(run,signal,options){await this.allocation(run,signal,options);const{store}=await this.location(run);await ensureAgentSession(store,run);return store;}
91
+ async bindRuntime(run){
92
+ const{status,opened,store}=await this.location(run,{live:true}),book=await store.load(run.session_id),binding={workspace_id:run.workspace.workspace_id,run_id:run.run_id,session_id:run.session_id,parent_session_id:run.parent_session_id,owner_digest:digest(owner(run)),source_root:opened.path,initial_head:opened.head,request_digest:digest(run.workspace)};
93
+ if(!book.journalAudit.verified)throw fail('The reserved native source session must have a verified journal.');
94
+ await store.workTransaction(run.session_id,async()=>{
95
+ const saved=await store.load(run.session_id),existing=saved.rows.filter(row=>row.type==='git_agent_workspace_bound');
96
+ if(existing.length>1||existing.length&&(existing[0].source!=='git_delegation_host'||existing[0].mechanism!==GIT_AGENT_MECHANISM||digest(existing[0].binding)!==digest(binding)))throw fail('Native child belongs to another workspace/run.');
97
+ if(!existing.length){if(saved.rows.some(row=>row.type.startsWith('house_')))throw fail('Native effects already exist without the reserved source binding.');await store.append(run.session_id,{type:'git_agent_workspace_bound',source:'git_delegation_host',mechanism:GIT_AGENT_MECHANISM,binding,binding_digest:digest(binding)});}
98
+ });
99
+ const saved=await store.load(run.session_id),births=saved.rows.filter(row=>row.type==='house_scope_bound');let house;
100
+ if(saved.rows.some(row=>row.type==='house_scope_revoked'))throw fail('The child runtime was revoked.','HOUSE_SCOPE_REVOKED');
101
+ if(births.length)house=await this.house.restoreFork({cwd:opened.path,store,parentSessionId:run.parent_session_id,sessionId:run.session_id});
102
+ else{if(saved.rows.some(row=>['house_proposal','house_started','house_receipt'].includes(row.type)))throw fail('An unbound scope cannot be created around existing native effects.');house=this.house.fork({cwd:opened.path,record:row=>store.append(row.session_id,row),parentSessionId:run.parent_session_id,sessionId:run.session_id,ceiling:run.workspace.ceiling});}
103
+ const revision=run.task.git_revision?await this.originalRevision(run):null;
104
+ const check=async()=>{
105
+ if(run.task.git_revision)await this.originalRevision(run);
106
+ const current=await this.location(run,{live:true});
107
+ const head=(await readGit(opened.path,['rev-parse','--verify','HEAD'])).stdout.toString().trim(),branch=(await readGit(opened.path,['rev-parse','--symbolic-full-name','HEAD'])).stdout.toString().trim(),common=await realpath((await readGit(opened.path,['rev-parse','--path-format=absolute','--git-common-dir'])).stdout.toString().trim());
108
+ if(head!==opened.head||branch!==opened.branch||common!==current.status.spec.repository.common_directory)throw fail('The source resource changed repository, branch or base; its bytes were preserved.','AGENT_GIT_SOURCE_DIVERGED');return true;
109
+ };
110
+ await check();house.guard('git-agent-source',check,{revision:GIT_AGENT_MECHANISM,configuration_digest:digest(binding)});registerWorkOperations({store,house});for(const operation of evidenceOperations({store,house}).filter(op=>op.name==='EvidenceRead'))house.registry.add(operation);
111
+ const revisionBindings=revision?(await new GitIntegrationOperations({cwd:this.cwd,house:this.house,store:this.store,policy:this.policy}).lineage({actor:revision.binding.owner.actor,sessionId:run.parent_session_id},revision)).map(i=>i.binding):null;
112
+ return{cwd:opened.path,store,house,actor:gitAgentActor(run),check,...(revision?{revision_binding:revisionBindings}:{}),source_workspace:{workspace_id:opened.workspace_id,path:opened.path,base_commit:opened.head,scope:'owned_native_source_not_browser_or_human_acceptance',...(revision?{revision:{integration_id:run.task.git_revision,kind:run.workspace.revision_source.kind,remaining_source_offers:run.workspace.revision_source.remaining_offers.length,scope:'original_frozen_source_not_permission_or_acceptance'}}:{})}};
113
+ }
114
+ async delivery(run,saved,index,signal){
115
+ const resultRef=ref(saved,index);if(saved.rows[index].type!=='result')return saved;
116
+ const {store}=await this.location(run);let preparation;
117
+ await store.workTransaction(run.session_id,async()=>{
118
+ const book=await store.load(run.session_id);preparation=book.rows.find(row=>row.type==='git_agent_delivery_prepared'&&row.result_ref?.digest===resultRef.digest);
119
+ if(!preparation){const source=await inspectGitSource({cwd:store.cwd,signal});preparation=await store.append(run.session_id,{type:'git_agent_delivery_prepared',source:'git_delegation_host',mechanism:GIT_AGENT_MECHANISM,result_ref:resultRef,run_id:run.run_id,workspace_id:run.workspace.workspace_id,source_identity:source.source_identity});}
120
+ });
121
+ const book=await store.load(run.session_id);if(!book.rows.some(row=>row.type==='git_agent_source_offered'&&row.result_ref?.digest===resultRef.digest)){
122
+ const kernel=run.task.git_revision?new GitWorkspaces({cwd:this.cwd,fault:this.kernel.fault,beforeEffect:()=>this.revisionFrame(run)}):this.kernel;
123
+ if(run.task.git_revision)await this.revisionFrame(run);
124
+ const checkpoint=await kernel.checkpoint(run.workspace.workspace_id,{attempt_id:'result-'+resultRef.digest,expected_source_identity:preparation.source_identity,message:'Comal delegated source '+run.run_id},{owner:owner(run),signal});await this.fault('checkpoint_saved',{run,checkpoint});
125
+ const offer=await kernel.offer(run.workspace.workspace_id,{attempt_id:'offer-'+resultRef.digest,expected_commit:checkpoint.commit},{owner:owner(run),signal});
126
+ await store.workTransaction(run.session_id,async()=>{const current=await store.load(run.session_id);if(!current.rows.some(row=>row.type==='git_agent_source_offered'&&row.result_ref?.digest===resultRef.digest))await store.append(run.session_id,{type:'git_agent_source_offered',source:'git_delegation_host',mechanism:GIT_AGENT_MECHANISM,run_id:run.run_id,result_ref:resultRef,offer,scope:'source_offer_not_verification_or_acceptance'});});
127
+ }
128
+ const current=await store.load(run.session_id);await this.validateDelivery(run,current);return current;
129
+ }
130
+ sourceOffer(run,book,resultRef){
131
+ const rows=book.rows.filter(row=>row.type==='git_agent_source_offered'&&row.result_ref?.digest===resultRef.digest);if(!rows.length)return;
132
+ const row=rows[0];if(rows.length!==1||row.source!=='git_delegation_host'||row.mechanism!==GIT_AGENT_MECHANISM||row.run_id!==run.run_id||row.offer?.workspace_id!==run.workspace.workspace_id||digest(row.result_ref)!==digest(resultRef))throw fail('The native child source offer has another result/owner.');return row.offer;
133
+ }
134
+ async validateDelivery(run,book){
135
+ if(!book.journalAudit.verified||auditExecution(book).violations.length)throw fail('The native child source book does not verify.');
136
+ const{opened}=await this.location(run),bindings=book.rows.filter(row=>row.type==='git_agent_workspace_bound'),binding=bindings[0];
137
+ if(book.id!==run.session_id||book.cwd!==opened.path||book.parent_session_id!==run.parent_session_id||bindings.length>1||binding&&(binding.source!=='git_delegation_host'||binding.mechanism!==GIT_AGENT_MECHANISM||binding.binding_digest!==digest(binding.binding)||binding.binding.workspace_id!==run.workspace.workspace_id||binding.binding.run_id!==run.run_id||binding.binding.session_id!==run.session_id||binding.binding.parent_session_id!==run.parent_session_id||binding.binding.owner_digest!==digest(owner(run))||binding.binding.source_root!==opened.path||binding.binding.initial_head!==opened.head||binding.binding.request_digest!==digest(run.workspace))||!binding&&book.rows.some(row=>row.type.startsWith('house_')))throw fail('The direct child evidence has another native source/run binding.');
138
+ for(const row of book.rows.filter(row=>row.type==='git_agent_source_offered')){
139
+ const fact=book.rows[row.result_ref?.row];if(!fact||fact.audit.sha256!==row.result_ref.digest||row.result_ref.session_id!==book.id)throw fail('A source offer belongs to another result row.');
140
+ const offer=this.sourceOffer(run,book,row.result_ref),native=await this.kernel.offered({workspace_id:offer.workspace_id,offer_id:offer.offer_id});if(digest(native)!==digest(offer)||offer.metadata.owner_digest!==digest(owner(run)))throw fail('The retained source offer differs from the native kernel.');
141
+ }
142
+ }
143
+ }