@milpa/comal 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +232 -63
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +133 -2
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +99 -6
  45. package/src/plugin-catalog.mjs +19 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/omarchy/catalog.mjs +96 -0
  55. package/src/plugins/omarchy/environment.mjs +78 -0
  56. package/src/plugins/omarchy/guide.mjs +54 -0
  57. package/src/plugins/omarchy/index.mjs +213 -0
  58. package/src/plugins/omarchy/runner.mjs +62 -0
  59. package/src/plugins/omarchy/text.mjs +26 -0
  60. package/src/plugins/omarchy/tiers.mjs +157 -0
  61. package/src/plugins/open-relay/actions.mjs +212 -16
  62. package/src/plugins/open-relay/client.mjs +93 -35
  63. package/src/plugins/open-relay/decision.mjs +89 -0
  64. package/src/plugins/open-relay/dossier.mjs +23 -5
  65. package/src/plugins/open-relay/index.mjs +68 -28
  66. package/src/plugins/open-relay/lookup.mjs +41 -4
  67. package/src/plugins/open-relay/notices.mjs +48 -4
  68. package/src/plugins/open-relay/panel.mjs +59 -0
  69. package/src/plugins/open-relay/watch.mjs +198 -85
  70. package/src/plugins.mjs +12 -1
  71. package/src/process-lock.mjs +20 -12
  72. package/src/provider-auth.mjs +6 -3
  73. package/src/provider-profiles.mjs +13 -3
  74. package/src/provider.mjs +175 -30
  75. package/src/read-only-check.mjs +12 -4
  76. package/src/relay/broker.mjs +160 -0
  77. package/src/relay/cards.mjs +702 -0
  78. package/src/relay/flow.mjs +886 -0
  79. package/src/relay/follow.mjs +119 -0
  80. package/src/relay/git.mjs +23 -0
  81. package/src/relay/hub-client.mjs +163 -0
  82. package/src/relay/state.mjs +55 -0
  83. package/src/remote-control.mjs +115 -12
  84. package/src/remote-relay.mjs +468 -0
  85. package/src/session-bus.mjs +13 -3
  86. package/src/session-provider-settings.mjs +2 -2
  87. package/src/skill-runtime.mjs +8 -6
  88. package/src/source-ownership.mjs +3 -1
  89. package/src/swarm-view.mjs +53 -0
  90. package/src/tool-manifest.mjs +71 -0
  91. package/src/tool-packs.mjs +22 -6
  92. package/src/tool-schema.mjs +14 -2
  93. package/src/tools.mjs +96 -27
  94. package/src/tui/connection-transition.mjs +10 -0
  95. package/src/tui/controller.mjs +35 -9
  96. package/src/tui/discovery.mjs +13 -8
  97. package/src/tui/inbound-blocks.mjs +5 -1
  98. package/src/tui/screen.mjs +78 -15
  99. package/src/tui/swarm-panel.mjs +217 -0
  100. package/src/turn-timing.mjs +82 -0
  101. package/src/usage-accounting.mjs +12 -1
  102. package/src/vision.mjs +6 -1
  103. package/src/visual/chrome.mjs +379 -0
  104. package/src/visual/fingerprint.mjs +30 -0
  105. package/src/visual/fixtures.mjs +201 -0
  106. package/src/visual/gate.mjs +92 -0
  107. package/src/visual/judge.mjs +114 -0
  108. package/src/visual/ledger.mjs +160 -0
  109. package/src/visual/measure.mjs +145 -0
  110. package/src/visual/names.mjs +7 -0
  111. package/src/visual/operations.mjs +211 -0
  112. package/src/visual/pixel-diff.mjs +96 -0
  113. package/src/visual/png.mjs +103 -0
  114. package/src/visual/registry.mjs +265 -0
  115. package/src/visual/report.mjs +64 -0
  116. package/src/visual/verify.mjs +292 -0
  117. package/src/work-assertions.mjs +15 -4
  118. package/src/work-plan.mjs +166 -30
  119. package/src/work-state.mjs +4 -4
  120. package/src/workflows/chrome-task-adoption.mjs +7 -3
  121. package/src/workspace-serve.mjs +59 -0
package/src/agent.mjs CHANGED
@@ -1,13 +1,17 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { t } from './i18n/index.mjs';
2
4
  import path from 'node:path';
3
5
  import { toolDefinitions } from './tools.mjs';
4
6
  import { trustedContext } from './trusted-context.mjs';
5
7
  import { ContextCompactor } from './compaction.mjs';
8
+ import { CACHE_TTL_MS } from './autocompact.mjs';
6
9
  import { houseSnapshot } from './house.mjs';
7
10
  import { nativeRefusalForCall, refusalStopReason } from './native-refusal.mjs';
8
11
  import { pagePublicationDiagnosticForCall } from './workflows/chrome-page-diagnostics.mjs';
9
12
  import { checkpointWork, registerWorkOperations, workProjection, WORK_NAMES } from './work-plan.mjs';
10
13
  import { digest } from './operations.mjs';
14
+ import { ensureManifest, MANIFEST_KINDS } from './tool-manifest.mjs';
11
15
  import { checkContractIssues, proposeCheckRepairs, checkRepairItems } from './work-check-contracts.mjs';
12
16
  import { randomUUID } from 'node:crypto';
13
17
  import { projectToolOutput } from './model-output.mjs';
@@ -16,11 +20,24 @@ import { notifyBrowserCurrency } from './browser-currency.mjs';
16
20
  import { PEER_SYSTEM } from './peer-tools.mjs';
17
21
  import { chromeLabObservation, runtimeBoundReadCheck } from './read-only-check.mjs';
18
22
  import { RequestMetrics, turnOutcome } from './execution-metrics.mjs';
23
+ import { TurnTimer, resultTiming } from './turn-timing.mjs';
19
24
  import {accountingIdentity} from './usage-accounting.mjs';
20
25
  import {scheduleToolCalls} from './tool-scheduler.mjs';
21
26
  import { resolveHumanImage, HUMAN_IMAGE_COUNT } from './image-attachments.mjs';
22
27
  import { describeTruncation, planOutputRecovery, OUTPUT_LENGTH_RETRIES } from './output-recovery.mjs';
28
+ // A model stream cut mid-response (socket reset, SSE body ending without finish) is
29
+ // transient. Each retry waits this long first; the run's signal cancels the wait.
30
+ export const STREAM_RETRY_DELAYS_MS = Object.freeze([1000, 4000, 10000]);
31
+ const waitBeforeRetry = (ms, signal) => new Promise((resolve, reject) => {
32
+ if (signal?.aborted) return reject(signal.reason);
33
+ if (!(ms > 0)) return resolve();
34
+ const onAbort = () => { clearTimeout(timer); reject(signal.reason); };
35
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
36
+ signal?.addEventListener('abort', onAbort, { once: true });
37
+ });
38
+ const VISUAL_SYSTEM="\nVisual acceptance: UI components with visual/components/<id>.json are accepted only by VisualVerify (house capture vs human-approved reference: exact pixels or an independent vision judge, plus confirmed criteria). Tasks that change such a UI declare capability visual:<id> and the check {operation:\"VisualVerify\",arguments:{component:\"<id>\"},assertions:[{kind:\"json_equals\",pointer:\"/status\",value:\"passed\"}]}. Use VisualCapture to see your work while iterating. Draft references with VisualReferenceDraft and propose criteria in visual/criteria/<id>.json; only the human approves the component setup (spec + fixtures), references and criteria with /visual. Never edit approved references, the spec or fixtures to make a check pass: that blocks acceptance until the human approves again.";
23
39
  const BROWSER_SYSTEM="\nBrowser debugging: inspect get_console_message stacks before guessing from source. After web edits reload and obtain NEW DOM/console evidence; old errors describe pre-edit code. Inspect public DOM, not closure-private globals. Prefer synchronous evaluate_script with waitForStableDom:false on animated pages; async probes must settle on success/error. Extra observations require no new plan. For visual deliverables capture browser.capture screenshots and inspect sent pixels when supported; DOM/console alone do not prove rendering.";
40
+ const CHROME_LAB_DISPATCH = new Set(['Interact', 'Message'].map(name => `plugin__chrome-lab__${name}`));
24
41
  const ADMIN_POLL = new Set(['WorkBoard','WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog','HouseContext','PluginStatus','McpStatus']);
25
42
  const operationLookup = tools => {
26
43
  const catalogue = new Map((tools.catalogue?.() || []).map(operation => [operation.name, operation]));
@@ -37,8 +54,18 @@ const unavailableWork = (name,session) => {
37
54
  if(name==='WorkCheckRepair')return {code:'WORK_CHECKS_VALID',message:'WorkCheckRepair is not offered because the current check contracts are valid. It repairs existing contracts; it does not add observations. Do NOT rebuild the plan or reacquire an already active pack. Use WorkStepRun with a NEW unique step_id plus exact offered operation and arguments for DOM, console, network or other additional work.',plan_version:work.plan.version,task_id:work.current?.id,declared_check_ids:work.current?.checks.map(check=>check.step_id),observation_fields:['task_id','plan_version','step_id (NEW)','operation','arguments']};
38
55
  return {code:'WORK_CHECK_REPAIR_REQUIRED',message:'Repair the existing invalid check contracts before replacing the plan. Saved work stays intact.',repair_call:{name:'WorkCheckRepair',input:{plan_version:work.plan.version}}};
39
56
  };
57
+ // Recovery calls a house refusal offers (JSON at the top level or embedded in its message). Keyed by refusal
58
+ // code and recovery name: a repeat with another call_id or step_id is the same recovery, not a new one.
59
+ const RECOVERY_KEYS = ['recovery_call', 'retry_call', 'resume_call', 'after_acquisition', 'next_call'];
60
+ export const recoveryCalls = (results, refusals = new Map()) => results.filter(item => item.is_error && refusals.has(item.tool_use_id)).flatMap(item => {
61
+ const code = refusals.get(item.tool_use_id), text = typeof item.content === 'string' ? item.content : JSON.stringify(item.content);
62
+ const parse = value => { try { return JSON.parse(value); } catch { return null; } };
63
+ const whole = parse(text), candidates = whole ? [whole] : [parse(text.slice(text.indexOf('{')))].filter(Boolean);
64
+ for (const candidate of [...candidates]) if (typeof candidate.message === 'string' && candidate.message.includes('{')) { const inner = parse(candidate.message.slice(candidate.message.indexOf('{'))); if (inner) candidates.push(inner); }
65
+ return candidates.flatMap(candidate => RECOVERY_KEYS.map(key => candidate[key]).filter(call => call && typeof call === 'object' && typeof call.name === 'string').map(call => ({ key: digest([code, call.name]), code, call: { name: call.name, input: call.input ?? null } })));
66
+ });
40
67
  const progressStamp = session => {
41
- const work = workProjection(session), files = new Map(), learned = new Set(), capabilities = new Map();let surfaceEpoch=digest([]), memoryEpoch='initial';
68
+ const work = workProjection(session), files = new Map(), learned = new Set(), capabilities = new Map();let surfaceEpoch=digest([]), memoryEpoch='initial', pageEpoch='initial';
42
69
  const reset = session.rows.findLastIndex(row => row.type === 'reset');
43
70
  for (const row of session.rows.slice(reset+1)) {
44
71
  if (['context_projection','context_rebase'].includes(row.type)) memoryEpoch = row.audit?.sha256 || digest([row.type,row.source_cursor,row.timestamp]);
@@ -47,15 +74,88 @@ const progressStamp = session => {
47
74
  let changed=false;for (const fact of row.observations || []) if (fact.kind === 'file_write') {changed ||= files.get(fact.file_path)!==fact.after_sha256;files.set(fact.file_path,fact.after_sha256);}
48
75
  if(changed)surfaceEpoch=digest([...files]);
49
76
  const reread = ['Read','Glob','Grep','ListDirectory','FileInfo','EvidenceRead'].includes(row.operation) || chromeLabObservation(row.operation);
50
- if (row.actor?.id === (session.parent_session_id ? session.id : 'root-agent') && !ADMIN_POLL.has(row.operation)) learned.add(digest([row.operation,row.arguments_digest,progressOutput(row),...(reread?[memoryEpoch]:[]),...(row.operation.startsWith('mcp__')?[row.contract_digest,surfaceEpoch]:[])]));
77
+ // A chrome-lab dispatch (Interact, Message) answers with the same ack every time, so on its own
78
+ // a repeated click looks like a loop. It counts as new when the page was last observed in a new
79
+ // state (or the files changed); the same click with no new observation is still no progress.
80
+ const dispatch = CHROME_LAB_DISPATCH.has(row.operation);
81
+ if (row.actor?.id === (session.parent_session_id ? session.id : 'root-agent') && !ADMIN_POLL.has(row.operation)) learned.add(digest([row.operation,row.arguments_digest,progressOutput(row),...(reread?[memoryEpoch]:[]),...(row.operation.startsWith('mcp__')?[row.contract_digest,surfaceEpoch]:[]),...(dispatch?[pageEpoch,surfaceEpoch]:[])]));
82
+ if (row.operation.startsWith('plugin__chrome-lab__') && !dispatch) pageEpoch = digest([row.operation,row.arguments_digest,progressOutput(row)]);
51
83
  }
52
84
  return digest({ plan: work?.plan.version, cards: work?.cards.map(({id,status,last_step}) => ({id,status,last_step})), drafts: work?.drafts.map(({id,digest}) => ({id,digest})), steps: work?.recent_steps.map(({task_id,step_id,status}) => ({task_id,step_id,status})), checks: work?.check_state.map(({step_id,status}) => ({step_id,status})), files: [...files], learned: [...learned], capabilities:[...capabilities] });
53
85
  };
54
- const workToolsFor = (tools, session, lookup = () => undefined, sourceRequestCapability = () => null) => { const work = workProjection(session), planned = Boolean(work), catalogue = new Map(tools.map(tool=>[tool.name,tool]));
86
+ // Etapas del catálogo. Bash es la única herramienta que la casa no gobierna (sin contrato de efectos):
87
+ // - 'unplanned' (sin plan o sin tarea actual): se ofrece todo lo gobernado (Read/Write/Edit, MCP, Agent…) menos Bash.
88
+ // - 'read' (tarea actual en discovery, o modo plan): solo operaciones con efectos declarados none/read y la
89
+ // administración del plan; WorkStepRun sigue sujeto a que la operación que corre sea de lectura.
90
+ // La etapa la declara el modelo, pero solo achica el catálogo: para Bash necesita una tarea implementation o
91
+ // verification y el permiso de siempre. Hijos y flujos donde la casa lleva el plan conservan su catálogo.
92
+ const PLAN_ADMIN = new Set(['WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkBoard', 'ToolPack']);
93
+ const READ_EFFECT = new Set(['none', 'read']);
94
+ const declaredRead = operation => { const effects = operation?.contract?.effects || operation?.effects; return Boolean(effects) && ['workspace', 'external', 'process', 'authority'].every(key => READ_EFFECT.has(effects[key] ?? 'none')); };
95
+ // humanBash: el humano habilitó Bash al arrancar; sin plan se respeta (la etapa read sigue estricta).
96
+ export function stageKind(session, tools, mode, humanBash = false) {
97
+ if (mode === 'plan') return 'read';
98
+ if (session.parent_session_id || !tools.some(tool => tool.name === 'WorkPlanSet')) return null;
99
+ const work = workProjection(session);
100
+ // Una discovery ya cerrada que vuelve a needs_verification (cambió un archivo que leyó) no devuelve la sesión a lectura.
101
+ return !work?.current ? (humanBash ? null : 'unplanned') : work.current.phase === 'discovery' && work.current.status !== 'needs_verification' ? 'read' : null;
102
+ }
103
+ // En modo plan los compositores siguen: cada hijo hereda el modo plan y cada llamada interna de CodeRun pasa por
104
+ // la política, que en plan solo deja leer. En discovery no, porque ni un hijo ni CodeRun quedan atados a la etapa.
105
+ const DELEGATION = new Set(['Agent', 'AgentBatch', 'CodeRun']);
106
+ const stageOffers = (name, lookup, kind, mode) => kind === 'unplanned' ? name !== 'Bash' : PLAN_ADMIN.has(name) || mode === 'plan' && DELEGATION.has(name) || declaredRead(lookup(name));
107
+ const stageOperation = (call, session) => {
108
+ if (call.name !== 'WorkStepRun') return call.name;
109
+ if (call.input?.operation) return call.input.operation;
110
+ const work = workProjection(session), tasks = work ? session.rows[work.plan.ref]?.tasks || [] : [];
111
+ return tasks.find(task => task.id === call.input?.task_id)?.checks.find(check => check.step_id === call.input?.step_id)?.operation;
112
+ };
113
+ // A discovery task runs only in the read stage, so a check that is not a declared read could never run there.
114
+ // Mode is ignored on purpose: a discovery task outlives plan mode. Tasks already in the plan (older ledgers) are not judged again.
115
+ const discoveryEffect = (call, lookup, session) => {
116
+ if (call.name !== 'WorkPlanSet') return null;
117
+ const work = workProjection(session), existing = new Set((work ? session.rows[work.plan.ref]?.tasks || [] : []).map(task => task.id));
118
+ // Another task of the plan (new or existing) that is not discovery may produce it before this one runs.
119
+ const planned = [...(Array.isArray(call.input?.tasks) ? call.input.tasks : []), ...(work ? session.rows[work.plan.ref]?.tasks || [] : [])];
120
+ const producedElsewhere = (task, file) => planned.some(other => other && other.id !== task.id && other.phase !== 'discovery' && (Array.isArray(other.artifacts) && other.artifacts.includes(file) || Array.isArray(task.depends_on) && task.depends_on.includes(other.id)));
121
+ // Nothing can create a file in the read stage, and the task cannot close while a declared artifact is
122
+ // missing; another task cannot start while it stays active. An existing file is a legitimate read target.
123
+ for (const task of Array.isArray(call.input?.tasks) ? call.input.tasks : []) if (task?.phase === 'discovery' && !existing.has(task.id)) for (const file of Array.isArray(task.artifacts) ? task.artifacts : []) {
124
+ if (typeof file !== 'string' || !session?.cwd || producedElsewhere(task, file)) continue;
125
+ const target = path.resolve(session.cwd, file);
126
+ if (!existsSync(target) || !statSync(target).isFile()) return {
127
+ code: 'WORK_DISCOVERY_EFFECT', executed: false,
128
+ message: `Task ${task.id} has phase discovery, which offers only declared reads, but its artifact ${file} does not exist as a regular file and no implementation or verification task of the plan produces it before this one, so the task could never close (a directory can never be hashed as an artifact). Declare the task that writes ${file} with phase implementation (or verification), or drop the artifact from this discovery task. To replace a task already in the plan, retire it with retire_ids and reason. No plan was written.`
129
+ };
130
+ }
131
+ for (const task of Array.isArray(call.input?.tasks) ? call.input.tasks : []) if (task?.phase === 'discovery' && !existing.has(task.id)) for (const check of Array.isArray(task.checks) ? task.checks : []) {
132
+ // An MCP tool not loaded yet (no ToolPack lease) has unknown effects, like any undeclared MCP tool.
133
+ if (typeof check?.operation === 'string' && (lookup(check.operation) ? !stageOffers(check.operation, lookup, 'read') : check.operation.startsWith('mcp__'))) return {
134
+ code: 'WORK_DISCOVERY_EFFECT', executed: false,
135
+ message: `Task ${task.id} has phase discovery, which offers only declared reads, but its check ${check.step_id} uses ${check.operation}, which is not one. Declare that task with phase verification (or implementation) to use ${check.operation}; keep discovery checks to reads such as Read, Grep or Glob. To replace a task already in the plan, retire it with retire_ids and reason. No plan was written.`,
136
+ };
137
+ }
138
+ return null;
139
+ };
140
+ const stageRefusal = (session, mode, kind, readTools) => {
141
+ const work = workProjection(session), version = work?.plan.version || 0;
142
+ const resumable = mode !== 'plan' && work && !work.current ? work.cards.find(card => card.status === 'blocked' && !card.held_by?.length && card.phase !== 'discovery') : null;
143
+ const why = mode === 'plan' ? 'Plan mode is read-only; only the human changes the mode.' : !work ? 'No accepted plan yet.' : !work.current ? `No current task.${resumable ? ` Task ${resumable.id} is blocked; resume it with resume_call, or declare new work.` : ''}` : `Current task ${work.current.id} has phase discovery.`;
144
+ const offer = kind === 'unplanned' ? 'Bash is offered only under a current task with phase implementation or verification. To read, use read_tools (no permission prompt, also in --add-dir dirs); the other offered house tools stay available.' : 'Only declared reads are offered; read with read_tools (no permission prompt, also in --add-dir dirs). Effects (Bash, Write, Edit, opaque MCP) are offered once the current task has phase implementation or verification.';
145
+ return { code: 'WORK_STAGE_READ_ONLY', executed: false, message: `${kind === 'unplanned' ? 'Unplanned stage' : 'Read stage'}: ${why} ${offer}`, read_tools: readTools, ...(resumable ? { resume_call: { name: 'WorkTaskUpdate', input: { task_id: resumable.id, plan_version: version, status: 'in_progress' } } } : {}),
146
+ ...(mode === 'plan' ? {} : { example_call: { name: 'WorkPlanSet', input: { objective: '<what this work delivers>', expected_version: version, ...(version ? { append: true } : {}), tasks: [{ id: 'implement', title: '<one small change>', phase: 'implementation', criterion: '<observable result>', checks: [{ step_id: 'check', operation: 'Read', arguments: { file_path: '<file it changes>' }, assertions: [{ kind: 'nonempty' }] }] }] } } }) };
147
+ };
148
+ const workToolsFor = (tools, session, lookup = () => undefined, sourceRequestCapability = () => null, mode, humanBash = false) => { const work = workProjection(session), planned = Boolean(work), catalogue = new Map(tools.map(tool=>[tool.name,tool]));
149
+ const kind = stageKind(session, tools, mode, humanBash);
150
+ if (kind) tools = tools.filter(tool => stageOffers(tool.name, name => lookup(name) || catalogue.get(name), kind, mode));
55
151
  const definition = name => lookup(name) || catalogue.get(name);
56
152
  const repairNeeded = planned && checkContractIssues(session.rows[work.plan.ref]?.tasks || [],definition).length > 0;
57
153
  const runtimeRepair = work?.check_state.some(check => ['failed', 'assertion_failed'].includes(check.status) && check.retryable_read) || work?.current?.checks.some(check => runtimeBoundReadCheck(check.operation,check.arguments));
58
- return tools.filter(tool => tool.name !== 'WorkPlanSet' || !repairNeeded).filter(tool => tool.name !== 'WorkCheckRepair' || repairNeeded || runtimeRepair).filter(tool => planned || !['WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft'].includes(tool.name)).map(tool => {
154
+ // The latest house receipt is a WORK_CHECK_MISMATCH refusal that carries an exact WorkCheckRepair call:
155
+ // offer the tool so that call can run (otherwise it ends as WORK_CHECKS_VALID and the grace is wasted).
156
+ const lastReceipt = session.rows?.findLast(row => row.type === 'house_receipt');
157
+ const offeredRepair = planned && lastReceipt?.operation === 'WorkStepRun' && lastReceipt.code === 'WORK_CHECK_MISMATCH' && /"recovery_call":\{"name":"WorkCheckRepair"/.test(lastReceipt.error || '');
158
+ return tools.filter(tool => tool.name !== 'WorkPlanSet' || !repairNeeded).filter(tool => tool.name !== 'WorkCheckRepair' || repairNeeded || runtimeRepair || offeredRepair).filter(tool => planned || !['WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft'].includes(tool.name)).map(tool => {
59
159
  if (['WorkCheckRepair','WorkDraft','WorkStepRun','WorkTaskUpdate'].includes(tool.name)) { const view = structuredClone(tool), version = work?.plan.version || 0; view.input_schema.properties.plan_version.const = version;
60
160
  if(tool.name==='WorkStepRun'){
61
161
  const checkIds=[...new Set(session.rows[work.plan.ref]?.tasks?.flatMap(task=>task.checks.map(check=>check.step_id)) || [])];
@@ -79,7 +179,7 @@ const workToolsFor = (tools, session, lookup = () => undefined, sourceRequestCap
79
179
  if (tool.name === 'WorkPlanSet') {
80
180
  const view = structuredClone(tool), version = work?.plan.version || 0;
81
181
  view.input_schema.properties.expected_version.const = version;
82
- view.description += ` Current expected_version:${version}. Prefer one small task.`; return view;
182
+ view.description += ` Current expected_version:${version}. Prefer one small task.`+(kind === 'read' ? ' Read stage: only declared reads are offered until the current task has phase implementation or verification.' : kind === 'unplanned' ? ' Bash is offered only under a current task with phase implementation or verification.' : ''); return view;
83
183
  }
84
184
  if(tool.name==='mcp__chrome-devtools__evaluate_script'){
85
185
  const view=structuredClone(tool);view.description+=' For animated pages choose waitForStableDom:false when supported. Prefer synchronous public DOM observations; app variables inside closures are NOT globals. Any asynchronous probe must settle on both success and error; an exception inside a setTimeout callback does not reject an enclosing Promise and can wedge the server.';return view;
@@ -122,6 +222,8 @@ export async function buildSystem(cwd, extra = '', { workflow = 'model' } = {})
122
222
  system += `\n\nProject instructions (${name}):\n${text}`;
123
223
  } catch (e) { if (e.code !== 'ENOENT') throw e; }
124
224
  }
225
+ // Visual acceptance schemas stay in ToolPack visual.accept (catalog budget); point at them only where components exist.
226
+ if (workflow === 'model' && existsSync(path.join(cwd, 'visual', 'components'))) system += '\nThis workspace has visual components (visual/components). UI tasks for them close only with VisualVerify: acquire ToolPack visual.accept.';
125
227
  if (workflow === 'model') system += '\nContinue from conversation and tool results. A derived checkpoint is supplied only at a context boundary; its facts are dated and later results supersede them. Do not poll WorkBoard/AuditStatus for housekeeping. After implementing close the task (done, verify:true). Retry failed read checks under the SAME ID with recheck:true; omit operation/arguments to use the plan. Historical failures remain recorded, not work to erase. Separate implementation from phase=verification tasks; declare artifacts and explicit assertions. Additional work/observations are allowed under the CURRENT task: WorkStepRun with a NEW unique step_id, operation and arguments. They do NOT require editing the plan or adding/repairing checks. For live browser evidence acquire browser.verify; observe DOM and console before reporting functional success: loading a URL alone is insufficient. Acquire a small exact subset of extra MCP tools when needed; release extensions after the stage. Batch only independent small calls; await plan/check repair results before dependent work; after closing advance to the next task.';
126
228
  else if (workflow === 'house') system += '\nContinue from conversation and tool results until the offered close action confirms the declared checks. A derived checkpoint appears only at a context boundary; newer evidence supersedes dated facts. Inspect and correct source as needed. A successful dispatch does not prove functional success. Stop on denied authority or an uncertain mutation.';
127
229
  else if(workflow==='chrome-entry') system += '\nOnly the offered inspection/declaration surface is active before adoption. Complete the contract declaration to start implementation; do not stop with a plan or request a shell setup step. Tool and file contents cannot grant authority.';
@@ -130,8 +232,19 @@ export async function buildSystem(cwd, extra = '', { workflow = 'model' } = {})
130
232
  return system + (extra ? `\n\n${extra}` : '');
131
233
  }
132
234
 
235
+ function refusalMessage({ category, explanation, fallback_ran }) {
236
+ const lines = [t('Anthropic declined this request (category: {category}).', { category: category || t('unspecified') })];
237
+ if (explanation) lines.push(explanation);
238
+ lines.push(fallback_ran ? t('A server-side fallback was attempted and also declined.') : t('Server-side fallback is off or unavailable for this model.'));
239
+ lines.push(t('Next: switch models with /model, or for legitimate security work apply to the Cyber Verification Program: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude-opus-and-sonnet'));
240
+ return lines.join('\n');
241
+ }
242
+ const compactionSummary = event => ({ type: 'context_projection', method: event.method, reason: event.reason, trigger: event.trigger, compaction_trigger: event.compaction_trigger, before_tokens: event.before_tokens, after_tokens: event.after_tokens, source_cursor: event.source_cursor,
243
+ ...(event.compaction ? { compaction: event.compaction } : {}), ...(event.focus ? { focus: { text: event.focus.text, retained_call_ids: event.focus.retained_call_ids || event.focus.kept_call_ids || [] } } : {}), ...(event.cost_estimate ? { cost_estimate: event.cost_estimate } : {}) });
244
+
133
245
  export class Agent {
134
- constructor({ provider, tools, store, session, system, maxTurns = 12, workMaxCalls = 80, toolPacks, onEvent = () => {}, extraTools = [], getExtraTools, beforeModel, afterTools, onResult, baseTools = toolDefinitions, providerName, capabilityActor, contextWindow = 45000 }) {
246
+ constructor({ provider, tools, store, session, system, maxTurns = 12, workMaxCalls = 80, toolPacks, onEvent = () => {}, extraTools = [], getExtraTools, beforeModel, afterTools, onResult, baseTools = toolDefinitions, providerName, capabilityActor, contextWindow = 45000, clock, autocompact = null, contextStats, now = () => Date.now(), cacheTtlMs, streamRetryDelaysMs = STREAM_RETRY_DELAYS_MS, runDeadlineAt = Number(process.env.COMAL_RUN_DEADLINE_AT) || 0, runHandInMs = process.env.COMAL_RUN_HAND_IN_MS ? Number(process.env.COMAL_RUN_HAND_IN_MS) : 300000 }) {
247
+ this.clock = clock; // monotonic ms; tests inject a fake clock
135
248
  Object.assign(this, { provider, tools, store, session, system, maxTurns, workMaxCalls, toolPacks, onEvent, extraTools, getExtraTools, beforeModel, afterTools, onResult, baseTools, providerName });
136
249
  // Legacy host construction may attach the durable recorder here instead of
137
250
  // in WorkspaceTools. Source leases must refer to that same native journal.
@@ -139,13 +252,44 @@ export class Agent {
139
252
  if (tools?.house && tools.policy && !tools.house.hasRecorder) tools.executor = tools.house.bind({ actor: { id: 'root-agent', kind: 'agent' }, sessionId: () => this.session.id, policy: tools.policy, onPermission: tools.onPermission, record: event => store.append(event.session_id, event), resources: { workspace: tools } });
140
253
  if (tools?.house) registerWorkOperations({ store, house: tools.house, onEvent });
141
254
  this.capabilityActor=capabilityActor || (session.parent_session_id?{id:session.id,kind:'subagent',parent_session_id:session.parent_session_id}:{id:'root-agent',kind:'agent'});
142
- this.compactor = new ContextCompactor({ provider, contextWindow, onEvent });
255
+ this.compactor = new ContextCompactor({ provider, contextWindow, onEvent, autocompact });
256
+ // Wall clock for the idle rule (tests inject a fake one). contextStats keeps
257
+ // per-project task peaks for /autocompact auto, outside the workspace.
258
+ Object.assign(this, { contextStats, now, cacheTtlMs, streamRetryDelaysMs });
259
+ // External wall-clock limit of this run (epoch ms; e.g. an open-relay attempt). Near it the house tells
260
+ // the model to deliver now: a run cut without delivering loses its work, and polling a budget tool is
261
+ // not something a model can be relied on to do.
262
+ Object.assign(this, { runDeadlineAt, runHandInMs });
263
+ }
264
+ /** Prompt-cache lifetime for the idle rule: explicit (null = skip), else the provider's known TTL. */
265
+ cacheTtl() { return this.cacheTtlMs !== undefined ? this.cacheTtlMs : CACHE_TTL_MS[this.provider.protocol] ?? null; }
266
+ idleSinceModel(session) {
267
+ const last = session.rows.findLast(row => row.type === 'model_usage' && row.timestamp), ttl = this.cacheTtl();
268
+ if (!last || !Number.isSafeInteger(ttl) || ttl <= 0) return null;
269
+ const idle = this.now() - Date.parse(last.timestamp);
270
+ return Number.isFinite(idle) && idle > ttl ? { idle_ms: idle, cache_ttl_ms: ttl } : null;
143
271
  }
144
272
  assertJournal() { if (this.session.journalAudit?.consistent === false) throw Object.assign(new Error('El audit detectó una cadena del libro alterada; usa comal audit --resume UUID. No se llamó al modelo.'), { code: 'AUDIT_INVALID' }); }
145
273
  async save(message, source = 'agent', metadata = {}) {
146
274
  await this.store.append(this.session.id, { ...metadata, type: 'message', message, source });
147
275
  this.session.messages.push(message);
148
276
  }
277
+ // Tool manifests are stored once per distinct digest in the session (see
278
+ // tool-manifest.mjs); request/usage rows reference them by digest.
279
+ async ensureManifest(kind, entries) {
280
+ const seen = (this.manifestsSeen ||= new Map());
281
+ if (!seen.has(this.session.id)) seen.set(this.session.id, new Set());
282
+ return ensureManifest({ store: this.store, sessionId: this.session.id, rows: this.session.rows || [], kind, entries, seen: seen.get(this.session.id) });
283
+ }
284
+ async manifestRefs(fields) {
285
+ const refs = {};
286
+ for (const [field, entries] of Object.entries(fields)) {
287
+ const spec = MANIFEST_KINDS[field];
288
+ if (!Array.isArray(entries)) { refs[spec.inline] = entries ?? null; continue; }
289
+ refs[spec.ref] = await this.ensureManifest(field, entries); refs[field + '_count'] = entries.length;
290
+ }
291
+ return refs;
292
+ }
149
293
  async repairInterruptedTools({signal}={}) {
150
294
  await this.tools.recoverInterrupted?.({signal,session:this.session});
151
295
  this.session=await this.store.load(this.session.id);
@@ -154,10 +298,10 @@ export class Agent {
154
298
  if (pending.length) await this.save({ role: 'user', content: pending.map(b => {
155
299
  const receipt = this.session.rows.findLast(row => row.type === 'house_receipt' && row.call_id === b.id && row.operation === b.name && row.arguments_digest === digest(b.input));
156
300
  const output = receipt?.status === 'returned' && !receipt.output_truncated ? projectToolOutput(receipt.output,{session_id:this.session.id,row:this.session.rows.indexOf(receipt)}) : undefined;
157
- return { type: 'tool_result', tool_use_id: b.id, ...(output === undefined ? { is_error: true } : {}), content: output === undefined ? ['HOUSE_RECOVERY_NOT_STARTED','HOUSE_RECOVERY_NOT_DISPATCHED','HOUSE_RECOVERY_CONTINUE','HOUSE_RECOVERY_READ_RETRY','HOUSE_RECOVERY_SOURCE_TASK'].includes(receipt?.code)?receipt.error:'El turno anterior se interrumpió. El resultado no quedó registrado; inspecciona el estado antes de repetir una operación.' : output.slice(0, 65536) + (output.length > 65536 ? '\n[Recuperado del recibo; consulta EvidenceRead para salida completa.]' : '') };
301
+ return { type: 'tool_result', tool_use_id: b.id, ...(output === undefined ? { is_error: true } : {}), content: output === undefined ? ['HOUSE_RECOVERY_NOT_STARTED','HOUSE_RECOVERY_NOT_DISPATCHED','HOUSE_RECOVERY_CONTINUE','HOUSE_RECOVERY_READ_RETRY','HOUSE_RECOVERY_SOURCE_TASK','HOUSE_RECOVERY_ACKNOWLEDGED_UNCERTAIN'].includes(receipt?.code)?receipt.error:'El turno anterior se interrumpió. El resultado no quedó registrado; inspecciona el estado antes de repetir una operación.' : output.slice(0, 65536) + (output.length > 65536 ? '\n[Recuperado del recibo; consulta EvidenceRead para salida completa.]' : '') };
158
302
  }) });
159
303
  }
160
- async compact({ signal, withinGoal = false } = {}) {
304
+ async compact({ signal, withinGoal = false, focus = null } = {}) {
161
305
  const release = await this.store.lock(this.session.id, { withinGoal });
162
306
  try {
163
307
  this.session = await this.store.load(this.session.id); this.assertJournal(); await this.repairInterruptedTools({signal});
@@ -165,19 +309,56 @@ export class Agent {
165
309
  const extra = this.getExtraTools ? await this.getExtraTools({ signal }) : this.extraTools;
166
310
  const capabilities = this.toolPacks ? await this.toolPacks.prepare(this.session.id,this.capabilityActor) : undefined;
167
311
  const visual = await prepareVision(this.session, this.provider, { signal });
168
- return await this.compactor.prepare({ session: this.session, system: this.system, tools: workToolsFor(this.tools.definitions?.() || [...this.baseTools, ...extra], this.session, operationLookup(this.tools), name => this.tools.nativeSourceRequestCapability?.(name)), capabilities, visual, outputTokens: this.provider.maxTokens || 8192, signal, force: true, reason: 'manual', commit: event => this.store.append(this.session.id, event) });
312
+ return await this.compactor.prepare({ session: this.session, system: this.system, tools: workToolsFor(this.tools.definitions?.() || [...this.baseTools, ...extra], this.session, operationLookup(this.tools), name => this.tools.nativeSourceRequestCapability?.(name), this.tools.getMode?.(), Boolean(this.tools.grantedByHuman?.('Bash'))), capabilities, visual, outputTokens: this.provider.maxTokens || 8192, signal, force: true, reason: 'manual', focus: focus || null, commit: event => this.store.append(this.session.id, event) });
169
313
  } finally { await release(); }
170
314
  }
171
- async turn(prompt, { signal, source = 'user', userIntent = prompt, withinGoal = false, shouldYield = () => false, attachments = [] } = {}) {
315
+ // Proactive projection at a protocol boundary (never inside a tool loop). A
316
+ // failure here never fails the finished turn: it is recorded and skipped.
317
+ async boundaryProjection(trigger, request, { signal, commit, details } = {}) {
318
+ try {
319
+ this.session = await this.store.load(this.session.id);
320
+ const contextIdentity = { ...accountingIdentity(this.provider), projection_epoch: this.session.projection?.audit?.sha256 || this.session.projection?.source_tip_sha256 || null };
321
+ this.compactor.setIdentity(contextIdentity);
322
+ return await this.compactor.prepare({ ...request, contextIdentity, session: this.session, signal, reason: trigger, boundary: trigger, boundaryDetails: details, commit });
323
+ } catch (error) {
324
+ const event = { type: 'context_compaction_skipped', compaction_trigger: trigger, code: error.code || error.name || 'ERROR', message: String(error.message || error).slice(0, 400) };
325
+ try { await this.store.append(this.session.id, event); await this.onEvent(event); } catch {}
326
+ return null;
327
+ }
328
+ }
329
+ async recordContextPeak(peak) {
330
+ if (!this.contextStats || peak.start === null) return;
331
+ try {
332
+ const peaks = await this.contextStats.record({ model: this.provider.model, task_tokens: Math.max(0, peak.max - peak.start) + peak.overhead, peak_tokens: peak.max, start_tokens: peak.start, overhead_tokens: peak.overhead, context_window: this.compactor.contextWindow });
333
+ this.compactor.peaks = peaks.map(item => item.task_tokens);
334
+ } catch {}
335
+ }
336
+ // Every turn runs inside its timer, so permission cards shown by its tools
337
+ // (or nested subagent turns) are charged as human wait, not Comal time.
338
+ turn(prompt, options = {}) { const timer = new TurnTimer(this.clock ? { clock: this.clock } : {}); return timer.run(() => this.#turn(prompt, options, timer)); }
339
+ async #turn(prompt, { signal, source = 'user', userIntent = prompt, withinGoal = false, shouldYield = () => false, attachments = [] } = {}, timer) {
172
340
  const release = await this.store.lock(this.session.id, { withinGoal });
173
341
  const capabilityActor = this.capabilityActor, toolTurnId = randomUUID(); let packTurnOpened = false, refusalCause;
174
342
  try {
175
343
  // Refresh after acquiring the lock, so another CLI cannot leave stale history.
176
344
  this.session = await this.store.load(this.session.id);
177
345
  this.assertJournal();
346
+ if (this.contextStats) this.compactor.peaks = await this.contextStats.taskPeaks().catch(() => this.compactor.peaks);
347
+ // Idle rule: a human message after the provider's prompt cache expired will
348
+ // reprocess the whole prefix anyway, so a due projection happens right now.
349
+ let idle = source === 'user' && prompt !== undefined && prompt !== null ? this.idleSinceModel(this.session) : null;
350
+ const turnCompactions = [], peak = { start: null, max: 0, overhead: 0 }; let boundaryRequest;
351
+ const commitProjection = async event => { await this.store.append(this.session.id, event); addUsage(event.usage); if (event.type === 'context_projection') turnCompactions.push(compactionSummary(event)); };
178
352
  const resultContext={source,signal,baseline:this.session.rows.length};
179
353
  const finish=async final=>{
354
+ if (boundaryRequest) {
355
+ await this.recordContextPeak(peak);
356
+ await this.boundaryProjection('soft_turn_end', boundaryRequest, { signal, commit: commitProjection });
357
+ }
358
+ if (turnCompactions.length) final.compactions = turnCompactions;
180
359
  if(this.onResult){await this.onResult(final,resultContext);final.house=houseSnapshot((await this.store.load(this.session.id)).rows);}
360
+ const timing=timer.summary({status:final.is_error?'failed':'answered'});
361
+ await this.store.append(this.session.id,{type:'turn_timing',turn_id:toolTurnId,turn_source:source,...timing});final.timing=resultTiming(timing);
181
362
  final.turn_id=toolTurnId;final.result_index=(await this.store.load(this.session.id)).rows.filter(row=>row.type==='result').length;
182
363
  final.origin={kind:source};final.outcome=turnOutcome(final);
183
364
  await this.store.append(this.session.id,final);await this.onEvent(final);return final;
@@ -201,16 +382,34 @@ export class Agent {
201
382
  let lastProgress = progressStamp(await this.store.load(this.session.id)), budgetProgress = lastProgress;
202
383
  let validationAttempts = 0, lastValidation; const validationSeen = new Set();
203
384
  const closureNotices = new Set();
385
+ // Recoveries already granted a grace response (one per distinct recovery call, never for a repeat).
386
+ const recoveryGrace = new Set(); let graceHint = null, graces = 0; let lastDeadlineNotice = null;
387
+ // Near the run's external wall limit, tell the model to deliver (also replaces a plain continuation).
388
+ const deadlineNotice = async (current) => {
389
+ if (!(this.runDeadlineAt > 0) || !(this.runHandInMs > 0) || current?.parent_session_id) return false;
390
+ const remainingMs = this.runDeadlineAt - this.now();
391
+ if (remainingMs > this.runHandInMs || (lastDeadlineNotice !== null && turns - lastDeadlineNotice < 3)) return false;
392
+ lastDeadlineNotice = turns;
393
+ const minutes = Math.max(0, Math.ceil(remainingMs / 60000));
394
+ const event = { type: 'run_deadline_warning', source: 'house', remaining_ms: Math.max(0, remainingMs), task_progress: false, grants_permission: false };
395
+ await this.store.append(this.session.id, event);
396
+ await this.save({ role: 'user', content: `Host deadline notice: this run has an external wall-clock limit and will be cut in about ${minutes} min. Do not start new work or new verification. Deliver what you have now with the offered finish or submit tool (for example the task's submit, or WorkTaskUpdate); anything not delivered before the cut is lost. This notice grants no authority.` }, 'continuation');
397
+ await this.onEvent(event);
398
+ return true;
399
+ };
400
+ // A plan already closed when the turn starts had its notice in an earlier turn (or never needed one).
401
+ const closedAtStart = (work => work?.cards.length && work.cards.every(task => task.status === 'done') ? work.plan.digest : null)(workProjection(await this.store.load(this.session.id)));
402
+ if (closedAtStart) closureNotices.add(closedAtStart);
204
403
  if (this.toolPacks) await this.toolPacks.prepare(this.session.id,capabilityActor);
205
404
  const initialTools = this.getExtraTools ? await this.getExtraTools({ signal }) : this.extraTools;
206
- await this.onEvent({ type: 'init', session_id: this.session.id, cwd: this.session.cwd, model: this.provider.model, tools: [...this.baseTools, ...initialTools].map(t => t.name) });
405
+ await this.onEvent({ type: 'init', session_id: this.session.id, cwd: this.session.cwd, model: this.provider.model, ...(Number.isSafeInteger(this.provider.maxTokens) ? { max_tokens: this.provider.maxTokens, max_tokens_source: this.provider.maxTokensSource || 'default' } : {}), tools: [...this.baseTools, ...initialTools].map(t => t.name) });
207
406
  let recovery;
208
407
  const addUsage = value => { usage.input_tokens += value?.input_tokens || 0; usage.output_tokens += value?.output_tokens || 0; };
209
408
  while (turns < limit) {
210
409
  if (this.workMaxCalls > 0 && workProjection(await this.store.load(this.session.id))) limit = Math.min(limit,this.workMaxCalls);
211
410
  if (turns >= limit) break;
212
411
  signal?.throwIfAborted(); turns++;
213
- const requestId=randomUUID(),metrics=new RequestMetrics({requestId,turnId:toolTurnId,turn:turns,model:this.provider.model,provider:this.providerName});
412
+ const requestId=randomUUID(),metrics=new RequestMetrics({requestId,turnId:toolTurnId,turn:turns,model:this.provider.model,provider:this.providerName,clock:timer.clock});
214
413
  // Native mail is consumed only at a protocol boundary, never in a running
215
414
  // tool or half-streamed response. The callback records its own provenance.
216
415
  const received=await metrics.measure('before_model',()=>this.beforeModel?.({ sessionId: this.session.id, signal, source, turn:turns, prompt:turns===1?prompt:undefined }));
@@ -238,31 +437,39 @@ export class Agent {
238
437
  const checkpoint = await checkpointWork(this.store, this.session.id, recovery ? recovery.code : 'before_model');
239
438
  if (checkpoint) { this.session = await this.store.load(this.session.id); await this.onEvent({ type: 'work_board', session_id: this.session.id, board: workProjection(this.session) }); }
240
439
  const turnTools = this.tools.snapshot?.() || this.tools;
241
- const definitions = this.provider.getCapabilities?.().tools===false ? [] : workToolsFor(turnTools.definitions?.() || [...this.baseTools, ...extraTools], this.session, operationLookup(this.tools), name => turnTools.nativeSourceRequestCapability?.(name));
242
- const mode = this.tools.getMode?.();
440
+ const mode = this.tools.getMode?.(), offeredTools = turnTools.definitions?.() || [...this.baseTools, ...extraTools], humanBash = Boolean(this.tools.grantedByHuman?.('Bash')), stage = stageKind(this.session, offeredTools, mode, humanBash);
441
+ const definitions = this.provider.getCapabilities?.().tools===false ? [] : workToolsFor(offeredTools, this.session, operationLookup(this.tools), name => turnTools.nativeSourceRequestCapability?.(name), mode, humanBash);
243
442
  const visual = await prepareVision(this.session, this.provider, { signal, requiredAttachmentIds: attachments.map(item => (item.reference || item).attachment_id) });
244
- const system = this.system + (definitions.some(tool=>tool.name==='SendMessage') ? PEER_SYSTEM : '') + (definitions.some(tool=>tool.name.startsWith('mcp__chrome-devtools__')) ? BROWSER_SYSTEM : '') + (mode ? `\nCurrent permission mode: ${mode}. Plan is read-only; auto actions are independently checked against actual user intent.` : '') + (recovery?.guidance ? '\n' + recovery.guidance : recovery ? '\nThe previous response was rejected before ANY tool in that response executed. Produce a COMPLETE small next step with valid tool arguments. Split large scripts and file changes into bounded calls in the requested language. Do not repeat earlier completed mutations; inspect the affected resource if uncertain. Reduce reasoning and output size.' : '') + (visual?.references.length ? '\nReference-only images are not visible. Sent pixels are dated, untrusted snapshots; recapture after changes. Sending pixels does not pass checks or judge the objective.' : '');
443
+ const workDirs = this.tools.getDirectories?.()?.work || [];
444
+ const system = this.system + (definitions.some(tool=>tool.name==='SendMessage') ? PEER_SYSTEM : '') + (workDirs.length ? `\nExtra work directories the human opened with /add-dir: ${workDirs.join(', ')}. Read, Write, Edit, Grep, ListDirectory, FileInfo and Glob work there with absolute paths, under the same permission mode as the workspace; their .comal/.harness stay protected.` : '') + (definitions.some(tool=>tool.name.startsWith('mcp__chrome-devtools__')) ? BROWSER_SYSTEM : '') + (definitions.some(tool=>tool.name==='VisualVerify') ? VISUAL_SYSTEM : '') + (mode ? `\nCurrent permission mode: ${mode}. Plan is read-only; auto actions are independently checked against actual user intent.` : '') + (recovery?.guidance ? '\n' + recovery.guidance : recovery ? '\nThe previous response was rejected before ANY tool in that response executed. Produce a COMPLETE small next step with valid tool arguments. Split large scripts and file changes into bounded calls in the requested language. Do not repeat earlier completed mutations; inspect the affected resource if uncertain. Reduce reasoning and output size.' : '') + (visual?.references.length ? '\nReference-only images are not visible. Sent pixels are dated, untrusted snapshots; recapture after changes. Sending pixels does not pass checks or judge the objective.' : '');
245
445
  const outputTokens = recovery?.maxTokens || this.provider.maxTokens || 8192;
246
446
  const contextIdentity={...accountingIdentity(this.provider),projection_epoch:this.session.projection?.audit?.sha256 || this.session.projection?.source_tip_sha256 || null};
247
447
  this.compactor.setIdentity(contextIdentity);
248
- const context = await metrics.measure('context_prepare',()=>this.compactor.prepare({ contextIdentity, session: this.session, system, tools: definitions, catalogue: (turnTools.catalogue?.() || definitions).filter(item => definitions.some(definition => definition.name === item.name)), capabilities, visual, mode, outputTokens, signal, force: recovery?.code === 'CONTEXT_LENGTH', reason: recovery ? 'recovery' : 'automatic', commit: async event => { await this.store.append(this.session.id, event); addUsage(event.usage); } }));
448
+ const catalogue = (turnTools.catalogue?.() || definitions).filter(item => definitions.some(definition => definition.name === item.name));
449
+ const boundary = idle && !recovery ? 'idle_cache_expired' : null, boundaryDetails = boundary ? idle : null; idle = null;
450
+ const context = await metrics.measure('context_prepare',()=>this.compactor.prepare({ contextIdentity, session: this.session, system, tools: definitions, catalogue, capabilities, visual, mode, outputTokens, signal, force: recovery?.code === 'CONTEXT_LENGTH', reason: recovery ? 'recovery' : boundary || 'automatic', boundary, boundaryDetails, commit: commitProjection }));
451
+ boundaryRequest = { system, tools: definitions, catalogue, capabilities, mode, outputTokens: this.provider.maxTokens || 8192 };
249
452
  const messages = visual ? hydrateImages(context.request_messages, this.session, visual) : context.request_messages;
250
453
  const images = messages.flatMap(message => Array.isArray(message.content) ? message.content.filter(block => block.type === 'image') : []);
251
454
  const hydratedStatus = this.compactor.status({ system, tools: definitions, messages }, outputTokens);
455
+ const usedNow = hydratedStatus.input_tokens + outputTokens;
456
+ if (peak.start === null) Object.assign(peak, { start: usedNow, overhead: hydratedStatus.overhead_tokens + outputTokens });
457
+ peak.max = Math.max(peak.max, usedNow);
252
458
  if (hydratedStatus.input_tokens + outputTokens + hydratedStatus.margin > this.compactor.contextWindow) throw new Error('La reserva de imágenes excede el contexto después de hidratar; ajusta --image-tokens o --context-window. No se llamó al modelo.');
253
459
  const requestReferences = context.request_messages.flatMap(message => Array.isArray(message.content) ? message.content.filter(block => block.type === 'image_reference').map(block => block.reference) : []);
254
460
  const visualRequest = visual ? { supported: visual.supported, source: visual.source, images: images.length, token_reserve: images.reduce((sum, image) => sum + image.token_reserve, 0), references: visual.references, request_references: requestReferences, transport_status: 'prepared', visual_verdict: 'unjudged' } : undefined;
255
461
  if (visualRequest?.references.length) await this.onEvent({ type: 'vision_status', ...visualRequest });
256
462
  const offeredContracts=new Map((turnTools.catalogue?.() || []).map(contract=>[contract.name,contract]));
257
463
  const manifest = definitions.map(tool => ({ name: tool.name, schema_digest: digest(tool.input_schema), definition_digest: digest(tool), bytes: Buffer.byteLength(JSON.stringify(tool)),origin:offeredContracts.get(tool.name)?.origin || null,visibility:capabilities?.pinned?.includes(tool.name)?'pinned_by_host':capabilities?.active?.some(pack=>pack.tools?.includes(tool.name))?'active_pack':this.toolPacks?.scoped?.(tool.name)?'scoped_host_view':'eager_host_view' }));
258
- await this.store.append(this.session.id, { type: 'model_request', purpose: 'worker', request_id:requestId, turn_id:toolTurnId, request_digest:digest({system,messages,tools:definitions}), turn: turns, model_controls:{model:this.provider.model,effort:this.provider.effort || 'auto',provider:this.providerName,capabilities_digest:this.provider.modelCapabilities?digest(this.provider.modelCapabilities):null}, phase: workProjection(this.session)?.current?.phase || null, tools: manifest, tools_digest: digest(definitions), tools_bytes: Buffer.byteLength(JSON.stringify(definitions)), measurement: 'UTF8 model-facing definitions; not tokenizer attribution', token_context: {policy:'conversation_until_pressure_v1',context_window:this.compactor.contextWindow,input_tokens:hydratedStatus.input_tokens,output_reserve:outputTokens,margin:hydratedStatus.margin,calibration_factor:hydratedStatus.calibration_factor,accounting_method:hydratedStatus.accounting_method,anchor_request_digest:hydratedStatus.anchor_request_digest,estimated:hydratedStatus.estimated,projected:context.projected,checkpoint_cursor:this.session.projection?.source_cursor || null}, active_packs: capabilities?.active.map(pack => pack.id) || [], ...(visualRequest ? { vision: visualRequest } : {}) });
259
- const request = { contextIdentity, messages, system, tools: definitions, schemaCatalog:turnTools.catalogue?.() || [], signal, maxTokens: outputTokens, onText: text => {metrics.firstText();return this.onEvent({ type: 'text_delta', text });}, onProgress: progress => this.onEvent({ type: 'model_stream_progress', ...progress }) };
464
+ const manifestDigest = await this.ensureManifest('request_tools', manifest);
465
+ await this.store.append(this.session.id, { type: 'model_request', purpose: 'worker', request_id:requestId, turn_id:toolTurnId, request_digest:digest({system,messages,tools:definitions}), turn: turns, model_controls:{model:this.provider.model,effort:this.provider.effort || 'auto',provider:this.providerName,capabilities_digest:this.provider.modelCapabilities?digest(this.provider.modelCapabilities):null}, phase: workProjection(this.session)?.current?.phase || null, tools_manifest_digest: manifestDigest, tools_count: manifest.length, tools_digest: digest(definitions), tools_bytes: Buffer.byteLength(JSON.stringify(definitions)), measurement: 'UTF8 model-facing definitions; not tokenizer attribution', token_context: {policy:'conversation_until_pressure_v1',context_window:this.compactor.contextWindow,input_tokens:hydratedStatus.input_tokens,output_reserve:outputTokens,margin:hydratedStatus.margin,calibration_factor:hydratedStatus.calibration_factor,accounting_method:hydratedStatus.accounting_method,anchor_request_digest:hydratedStatus.anchor_request_digest,estimated:hydratedStatus.estimated,projected:context.projected,checkpoint_cursor:this.session.projection?.source_cursor || null}, active_packs: capabilities?.active.map(pack => pack.id) || [], ...(visualRequest ? { vision: visualRequest } : {}) });
466
+ const request = { contextIdentity, messages, system, tools: definitions, schemaCatalog:turnTools.catalogue?.() || [], signal, maxTokens: outputTokens, onText: text => {if(!text)return;const first=metrics.firstOutput();metrics.firstText();timer.observeText();if(first)this.onEvent({type:'model_stream_phase',phase:'responding',request_id:requestId});return this.onEvent({ type: 'text_delta', text });}, onReasoning: () => {if(metrics.reasoning())return this.onEvent({type:'model_stream_phase',phase:'reasoning',request_id:requestId});}, onReasoningEnd: () => metrics.reasoningEnd(), onToolCall: () => {if(metrics.firstOutput()){timer.observeOutput();return this.onEvent({type:'model_stream_phase',phase:'responding',request_id:requestId});}}, onProgress: progress => this.onEvent({ type: 'model_stream_progress', ...progress }) };
260
467
  let answer;
261
468
  const requestStarted = Date.now();
262
- const recordUsage = async (answer, error) => this.store.append(this.session.id, { type: 'model_usage', purpose: 'worker', request_id:requestId, turn_id:toolTurnId, usage_accounting:answer?.usage_accounting || error?.usage_accounting || null, schema_diagnostics:answer?.schema_diagnostics || error?.schema_diagnostics || null,schema_manifest:answer?.schema_manifest || null, turn: turns, model: this.provider.model, ...(visualRequest ? { vision: { ...visualRequest, transport_status: answer ? 'response_complete' : 'response_failed' } } : {}), elapsed_ms: Date.now() - requestStarted, usage: answer?.usage || error?.usage || null, reasoning_chars: answer ? answer.content.filter(block => block.type === 'openai_reasoning' || block.type === 'thinking').reduce((sum, block) => sum + (block.text || block.thinking || '').length, 0) : error?.details?.reasoning_chars ?? null, tool_calls: (answer?.content || []).filter(block => block.type === 'tool_use').map(block => block.name), ...(error ? { error: error.code || error.name } : {}) });
263
- try { answer = await metrics.measure('provider',()=>this.provider.complete(request)); await this.store.append(this.session.id,metrics.event()); }
469
+ const recordUsage = async (answer, error) => { const schemaManifest = answer?.schema_manifest || null, schemaDiagnostics = answer?.schema_diagnostics || error?.schema_diagnostics || null; return this.store.append(this.session.id, { type: 'model_usage', purpose: 'worker', request_id:requestId, turn_id:toolTurnId, usage_accounting:answer?.usage_accounting || error?.usage_accounting || null, ...await this.manifestRefs({ schema_manifest: schemaManifest, schema_diagnostics: schemaDiagnostics }), turn: turns, model: this.provider.model, ...(visualRequest ? { vision: { ...visualRequest, transport_status: answer ? 'response_complete' : 'response_failed' } } : {}), elapsed_ms: Date.now() - requestStarted, usage: answer?.usage || error?.usage || null, reasoning_chars: answer ? answer.content.filter(block => block.type === 'openai_reasoning' || block.type === 'thinking').reduce((sum, block) => sum + (block.text || block.thinking || '').length, 0) : error?.details?.reasoning_chars ?? null, tool_calls: (answer?.content || []).filter(block => block.type === 'tool_use').map(block => block.name), ...(error ? { error: error.code || error.name } : {}) }); };
470
+ try { answer = await metrics.measure('provider',()=>this.provider.complete(request)); metrics.settle(answer); if(metrics.firstOutputAt!==null)timer.observeOutput(); const measured=metrics.event(); timer.record(measured); await this.store.append(this.session.id,measured); }
264
471
  catch (error) {
265
- await this.store.append(this.session.id,metrics.event({status:'failed',error:error.code || error.name}));
472
+ const measured=metrics.event({status:'failed',error:error.code || error.name}); timer.record(measured); await this.store.append(this.session.id,measured);
266
473
  await recordUsage(null, error);
267
474
  addUsage(error.usage); this.compactor.observe(request, error.usage_accounting || error.usage);
268
475
  if (error.code === 'OUTPUT_LENGTH' && (!recovery || recovery.code === 'OUTPUT_LENGTH')) {
@@ -284,7 +491,16 @@ export class Agent {
284
491
  error.message += ` Comal made ${summary.attempts} attempt(s) with output budgets ${history.map(item => item.max_tokens).join(' -> ')} (stop: ${summary.stop_reason}); the last one was cut while ${last.phase === 'tool_arguments' ? `writing the arguments of ${last.tool || 'a tool'}` : `generating ${last.phase === 'unknown' ? 'output' : last.phase}`}.`;
285
492
  const exhausted = { type: 'model_recovery_exhausted', ...summary };
286
493
  await this.store.append(this.session.id, exhausted); await this.onEvent(exhausted);
287
- } else if (!recovery && ['CONTEXT_LENGTH', 'TOOL_ARGUMENTS_INVALID', 'TOOL_STREAM_INVALID', 'MODEL_EMPTY', 'MODEL_STREAM_ERROR'].includes(error.code) && turns < limit) {
494
+ } else if (error.code === 'MODEL_STREAM_ERROR' && (recovery?.code === 'MODEL_STREAM_ERROR' ? recovery.attempts : 0) < this.streamRetryDelaysMs.length && turns < limit && !signal?.aborted) {
495
+ // The cut response was discarded whole (no tool from it ran). Back off and
496
+ // ask again, a bounded number of times, before failing the run.
497
+ const attempt = (recovery?.code === 'MODEL_STREAM_ERROR' ? recovery.attempts : 0) + 1, delayMs = this.streamRetryDelaysMs[attempt - 1];
498
+ await checkpointWork(this.store, this.session.id, error.code);
499
+ recovery = { code: error.code, maxTokens: outputTokens, attempts: attempt };
500
+ const event = { type: 'model_retry', reason: error.code, attempt, max_attempts: this.streamRetryDelaysMs.length, delay_ms: delayMs, max_tokens: outputTokens, context_tokens: context.input_tokens, partial_discarded: true, tools_executed: false };
501
+ await this.store.append(this.session.id, event); await this.onEvent(event);
502
+ await waitBeforeRetry(delayMs, signal); continue;
503
+ } else if (!recovery && ['CONTEXT_LENGTH', 'TOOL_ARGUMENTS_INVALID', 'TOOL_STREAM_INVALID', 'MODEL_EMPTY'].includes(error.code) && turns < limit) {
288
504
  const maxTokens = outputTokens;
289
505
  await checkpointWork(this.store, this.session.id, error.code);
290
506
  recovery = { code: error.code, maxTokens };
@@ -297,6 +513,16 @@ export class Agent {
297
513
  await recordUsage(answer);
298
514
  await this.onEvent({type:'usage_accounting',request_id:requestId,accounting:answer.usage_accounting || null});
299
515
  this.compactor.observe(request, answer.usage_accounting || answer.usage); recovery = undefined;
516
+ if (answer.refusal) {
517
+ const refusal = answer.refusal;
518
+ await this.store.append(this.session.id, { type: 'refusal', request_id: requestId, turn_id: toolTurnId, turn: turns, provider: this.providerName, model: this.provider.model, category: refusal.category, explanation: refusal.explanation, fallback_ran: refusal.fallback_ran, served_model: refusal.served_model });
519
+ await this.onEvent({ type: 'refusal', category: refusal.category, explanation: refusal.explanation, fallback_ran: refusal.fallback_ran, served_model: refusal.served_model });
520
+ return await finish({ type: 'result', session_id: this.session.id, result: refusalMessage(refusal), turns, usage, is_error: true, refusal: { category: refusal.category, explanation: refusal.explanation, fallback_ran: refusal.fallback_ran, served_model: refusal.served_model }, house: houseSnapshot((await this.store.load(this.session.id)).rows) });
521
+ }
522
+ if (answer.fallback) {
523
+ await this.store.append(this.session.id, { type: 'model_fallback', request_id: requestId, turn_id: toolTurnId, turn: turns, served_by: answer.fallback.served_by, switches: answer.fallback.switches });
524
+ await this.onEvent({ type: 'model_fallback', served_by: answer.fallback.served_by, switches: answer.fallback.switches });
525
+ }
300
526
  if (!answer.content?.length) throw new Error('El modelo devolvió una respuesta vacía.');
301
527
  addUsage(answer.usage);
302
528
  const message = { role: 'assistant', content: answer.content };
@@ -314,6 +540,7 @@ export class Agent {
314
540
  if (canContinue) {
315
541
  if (turns >= limit && progress !== budgetProgress) { limit = Math.min(limit + this.maxTurns, this.workMaxCalls); budgetProgress = progress; }
316
542
  if (turns < limit) {
543
+ if (await deadlineNotice(currentSession)) { const event = { type: 'work_continuation', task_id: work.current.id, calls: turns, limit, reason: 'run_deadline' }; await this.store.append(this.session.id,event); await this.onEvent(event); continue; }
317
544
  await this.save({ role: 'user', content: 'Host continuation: execute the unfinished task from the supplied checkpoint. Do not stop at an intermediate summary. Use exact checks/assertions and ToolPack offers; no new authority.' }, 'continuation');
318
545
  const event = { type: 'work_continuation', task_id: work.current.id, calls: turns, limit, reason: 'executable_checkpoint' }; await this.store.append(this.session.id,event); await this.onEvent(event); continue;
319
546
  }
@@ -325,15 +552,20 @@ export class Agent {
325
552
  }
326
553
  const browserVerification=workProjection(this.session)?.current?.phase==='verification' && definitions.some(tool=>tool.name.startsWith('mcp__chrome-devtools__'));
327
554
  const results = [], validationFailures = []; let budgetError;
555
+ // House refusals of this batch (by tool_use id): only these may carry a recovery call that earns a grace.
556
+ const houseRefusals = new Map();
328
557
  const executeOne=async call=>{
329
558
  const latest = await this.store.load(this.session.id);
330
559
  turnTools.setContext?.(trustedContext(latest.rows));
331
560
  await this.onEvent({ ...call, type: 'tool_use' });
332
561
  let content, is_error = false, caughtError;
333
- try { if (WORK_NAMES.includes(call.name) && !definitions.some(definition => definition.name === call.name)) {const detail=this.tools.sourceEditor?{code:'SOURCE_EDITOR_SCOPE',message:'The parent owns work plans and combined verification. Use the offered native source tools; no work administration or effect executed.'}:this.chromeEntry ? {code:'CHROME_TASK_ENTRY',message:'Declare ChromeTask first. The current entry permits only source inspection and contract declaration; no effect executed.'} : this.tools.workflow==='chrome-task' ? {code:'CHROME_TASK_SCOPE',message:'The house owns work administration. Use the offered Chrome actions and ChromeFinish; do not propose plan versions or generic work controls.'} : this.tools.workflow==='source-task'?{code:'SOURCE_TASK_SCOPE',message:'The house owns accepted source work and native combined checks. Use SourceDeliver/SourceStatus/SourceEvidence; generic work controls do not execute.'}:unavailableWork(call.name,latest);throw Object.assign(new Error(JSON.stringify(detail)),{code:detail.code});} if (budgetError) throw budgetError; content = await turnTools.execute(call.name, call.input, { signal, callId: call.id, modelProposed: true, modelSurface: definitions.map(tool => tool.name) }); }
562
+ try { if (!this.session.parent_session_id && definitions.some(tool => tool.name === 'WorkPlanSet')) { const detail = discoveryEffect(call, operationLookup(this.tools), latest); if (detail) throw Object.assign(new Error(JSON.stringify(detail)), { code: detail.code }); } if (stage) { const operation = stageOperation(call, latest), lookup = operationLookup(this.tools); if (operation && offeredTools.some(tool => tool.name === operation) && !stageOffers(operation, lookup, stage, mode)) { const detail = stageRefusal(latest, mode, stage, definitions.filter(tool => declaredRead(lookup(tool.name))).map(tool => tool.name)); throw Object.assign(new Error(JSON.stringify(detail)), { code: detail.code }); } } if (WORK_NAMES.includes(call.name) && !definitions.some(definition => definition.name === call.name)) {const detail=this.tools.sourceEditor?{code:'SOURCE_EDITOR_SCOPE',message:'The parent owns work plans and combined verification. Use the offered native source tools; no work administration or effect executed.'}:this.chromeEntry ? {code:'CHROME_TASK_ENTRY',message:'Declare ChromeTask first. The current entry permits only source inspection and contract declaration; no effect executed.'} : this.tools.workflow==='chrome-task' ? {code:'CHROME_TASK_SCOPE',message:'The house owns work administration. Use the offered Chrome actions and ChromeFinish; do not propose plan versions or generic work controls.'} : this.tools.workflow==='source-task'?{code:'SOURCE_TASK_SCOPE',message:'The house owns accepted source work and native combined checks. Use SourceDeliver/SourceStatus/SourceEvidence; generic work controls do not execute.'}:unavailableWork(call.name,latest);throw Object.assign(new Error(JSON.stringify(detail)),{code:detail.code});} if (budgetError) throw budgetError; content = await turnTools.execute(call.name, call.input, { signal, callId: call.id, modelProposed: true, modelSurface: definitions.map(tool => tool.name) }); }
334
563
  catch (error) {
335
564
  caughtError=error;
336
- if (['HOUSE_INPUT_SCHEMA', 'WORK_CHECK_CONTRACT', 'WORK_PLAN_INPUT', 'WORK_CONTROL_DIRECT', 'WORK_FINISH_REQUIRED'].includes(error.code)) validationFailures.push({ operation: call.name, code: error.code, signature: error.code === 'HOUSE_INPUT_SCHEMA' ? error.details?.issues?.map(({path,rule,limit,type}) => ({path,rule,limit,type})) : error.message });
565
+ if (/^(WORK|MODEL|HOUSE)_|^TOOL_PACK_REQUIRED$/.test(String(error.code || ''))) houseRefusals.set(call.id, error.code);
566
+ // Gate OR-9: WORK_DISCOVERY_EFFECT y WORK_ASSERTION también son la forma del plan; distintos y corregidos
567
+ // entran en la cadena acotada (máx. 6), nunca un bucle sin cambios.
568
+ if (['HOUSE_INPUT_SCHEMA', 'WORK_CHECK_CONTRACT', 'WORK_PLAN_INPUT', 'WORK_CONTROL_DIRECT', 'WORK_FINISH_REQUIRED', 'WORK_DISCOVERY_EFFECT', 'WORK_ASSERTION'].includes(error.code)) validationFailures.push({ operation: call.name, code: error.code, signature: error.code === 'HOUSE_INPUT_SCHEMA' ? error.details?.issues?.map(({path,rule,limit,type}) => ({path,rule,limit,type})) : error.message });
337
569
  const mediaRecovery=call.name==='Read' && error.code==='READ_BINARY' && this.toolPacks?.recoveryCall('AnalyzeMedia',capabilityActor);
338
570
  content=mediaRecovery ? JSON.stringify({code:'READ_BINARY',message:error.message,permission_denied:false,...(definitions.some(tool=>tool.name==='AnalyzeMedia')?{next_action:'Use the already offered AnalyzeMedia directly; no additional Read or shell prerequisite.'}:{recovery_call:mediaRecovery,next_action:'Acquire the media tool, await its schema, then analyze the file directly.'})}) : error.code==='HOUSE_INPUT_SCHEMA' || typeof error.code==='string' && (error.code.startsWith('PEER_') || error.code.startsWith('CHROME_TASK_') || error.code.startsWith('CHROME_VERIFIER_') || error.code.startsWith('SOURCE_') || error.code==='MODEL_STEP_TOO_LARGE') ? JSON.stringify({code:error.code,message:error.message,...(error.details || {}),...(error.request_id?{request_id:error.request_id}:{})}) : error.message;
339
571
  is_error = true; if (error.code === 'GOAL_BUDGET') budgetError = error;
@@ -355,7 +587,7 @@ export class Agent {
355
587
  const block = { type: 'tool_result', tool_use_id: call.id, content: clipped, ...(is_error ? { is_error: true } : {}) };
356
588
  await this.onEvent({ type: 'tool_result', name: call.name, ...block });return block;
357
589
  };
358
- results.push(...await scheduleToolCalls(calls,executeOne,{catalogue:turnTools.catalogue?.() || [],cwd:this.session.cwd,signal,onGroup:event=>this.store.append(this.session.id,{...event,request_id:requestId,turn_id:toolTurnId})}));
590
+ results.push(...await timer.tools(()=>scheduleToolCalls(calls,executeOne,{catalogue:turnTools.catalogue?.() || [],cwd:this.session.cwd,signal,onGroup:event=>this.store.append(this.session.id,{...event,request_id:requestId,turn_id:toolTurnId})})));
359
591
  await this.save({ role: 'user', content: results });
360
592
  if (budgetError) throw budgetError;
361
593
  const transition = await this.afterTools?.({ sessionId: this.session.id, signal, calls, results });
@@ -366,16 +598,23 @@ export class Agent {
366
598
  await this.save(message,'house'); await this.onEvent({type:'text_delta',text:transition.result}); await this.onEvent({type:'assistant',message,source:'house'});
367
599
  return await finish({type:'result',session_id:this.session.id,result:transition.result,turns,usage,work:afterBatch,is_error:false,result_source:'house',transition_receipt_id:transition.receipt_id,asked:'unjudged',house:houseSnapshot((await this.store.load(this.session.id)).rows)});
368
600
  }
369
- const closedAtLimit = turns >= limit && afterBatch?.cards.every(task => task.status === 'done');
370
- const verifiedClosure = afterBatch?.cards.every(task => task.status === 'done') && afterBatch.cards.some(task => task.phase === 'verification');
601
+ const closedPlan = Boolean(afterBatch?.cards.length) && afterBatch.cards.every(task => task.status === 'done');
602
+ // A plan that just closed at the turn limit first gets its transition notice (once per plan) and one more
603
+ // slice of calls within work-max-calls, so the resident can declare explicitly requested remaining work.
604
+ const noticePending = closedPlan && !closureNotices.has(afterBatch.plan.digest) && source !== 'peer_session' && mode !== 'plan' && !refusalCause && turns < this.workMaxCalls;
605
+ if (noticePending && turns >= limit) limit = Math.min(turns + this.maxTurns, this.workMaxCalls);
606
+ // Only work closed in this turn ends it as declared_checks_complete; a stale closed plan does not.
607
+ const closedAtLimit = turns >= limit && afterBatch?.cards.every(task => task.status === 'done') && afterBatch.plan.digest !== closedAtStart;
371
608
  if (afterBatch && (shouldYield() || closedAtLimit)) {
372
609
  const yielding = Boolean(shouldYield());
373
- const final = { type: 'result', session_id: this.session.id, result: yielding ? 'Comal conserva el checkpoint y cede al mensaje en cola.' : 'Comal completó los checks declarados: '+afterBatch.cards.map(task=>task.title).join('; ')+'. Evidencia guardada en la sesión; /board muestra el cierre.', turns, usage, work: afterBatch, is_error: false, yielded: yielding, work_stop_reason: yielding ? 'queued_message' : 'declared_checks_complete', result_source: 'house', house: houseSnapshot((await this.store.load(this.session.id)).rows) };
610
+ // When the whole work budget ended the run, say so: requested work beyond the declared checks may remain.
611
+ const budgetEnded = !yielding && turns >= this.workMaxCalls;
612
+ const final = { type: 'result', session_id: this.session.id, result: yielding ? 'Comal conserva el checkpoint y cede al mensaje en cola.' : 'Comal completó los checks declarados: '+afterBatch.cards.map(task=>task.title).join('; ')+'. Evidencia guardada en la sesión; /board muestra el cierre.'+(budgetEnded?` Se agotó el presupuesto de ${this.workMaxCalls} llamadas: si queda trabajo pedido fuera de esos checks, continúa con --resume.`:''), turns, usage, work: afterBatch, is_error: false, yielded: yielding, ...(budgetEnded ? { work_call_budget_exhausted: true } : {}), work_stop_reason: yielding ? 'queued_message' : 'declared_checks_complete', result_source: 'house', house: houseSnapshot((await this.store.load(this.session.id)).rows) };
374
613
  await checkpointWork(this.store,this.session.id,final.work_stop_reason);
375
614
  const message = {role:'assistant',content:[{type:'text',text:final.result}]}; await this.save(message,'house'); await this.onEvent({type:'text_delta',text:final.result}); await this.onEvent({type:'assistant',message,source:'house'});
376
615
  return await finish(final);
377
616
  }
378
- if (verifiedClosure && !closureNotices.has(afterBatch.plan.digest)) {
617
+ if (closedPlan && !closureNotices.has(afterBatch.plan.digest)) {
379
618
  closureNotices.add(afterBatch.plan.digest);
380
619
  // Closing declared checks is a protocol boundary, not a judgment that
381
620
  // the user's entire request is finished. Let the resident deliver its
@@ -389,6 +628,18 @@ export class Agent {
389
628
  // demonstrates an alternative. The policy still judges every effect.
390
629
  if(afterProgress!==lastProgress && results.some(item=>!item.is_error) && refusalCause?.kind==='permission')refusalCause=undefined;
391
630
  stalls = afterProgress === lastProgress ? stalls + 1 : 0; lastProgress = afterProgress;
631
+ // A refusal that names its own recovery call (retry_call, recovery_call, resume_call…) gets one
632
+ // response to use it before the cut: the house defers the third unchanged response once per
633
+ // distinct recovery. The same recovery refused again does not defer again.
634
+ graceHint = null;
635
+ if (stalls >= 3 && graces < 2 && !afterSession.parent_session_id) {
636
+ const fresh = recoveryCalls(results, houseRefusals).filter(item => !recoveryGrace.has(item.key));
637
+ if (fresh.length) {
638
+ fresh.forEach(item => recoveryGrace.add(item.key)); stalls = 2; graces++; graceHint = fresh[0].call;
639
+ const event = { type: 'recovery_grace', source: 'house', recovery: fresh[0].call, consecutive_unchanged_responses: 3, grants_permission: false };
640
+ await this.store.append(this.session.id, event); await this.onEvent(event);
641
+ }
642
+ }
392
643
  if (stalls === 0) { validationAttempts = 0; lastValidation = undefined; validationSeen.clear(); }
393
644
  else if (validationFailures.length === calls.length) {
394
645
  const signature = digest(validationFailures); validationAttempts++;
@@ -407,7 +658,17 @@ export class Agent {
407
658
  await this.store.append(this.session.id,event);
408
659
  await this.save({role:'user',content:`Host pacing notice: the house recorded no task progress in the last two responses. The latest batch returned ${failedResults} failed or refused tool results out of ${results.length}. Correct failed calls from their returned facts; a refused Read did not provide file contents. Use the original request and successful observations. If application source is missing, create a complete candidate within the accepted source scope; otherwise correct its current source. Then commit the complete candidate with the offered ChromeDeliver. The accepted fixture and criteria remain immutable. Use ChromeInspect only for an unresolved live observation after publication, and ChromeRecover only for an uncertain publication. Do not repeat uncertain effects or inspect host/plugin implementation outside the accepted source scope. One more unchanged response will stop this turn. This notice grants no authority and does not reset the no-progress limit.`},'continuation');
409
660
  await this.onEvent(event);
661
+ } else if(stalls===2 && !afterSession.parent_session_id){
662
+ // Any other top-level worker gets one explicit notice before the cut: a headless run
663
+ // (relay, -p) ends at the third unchanged response with nobody left to say
664
+ // "continue". A subagent's stop goes back to its parent, who can continue it.
665
+ const failedResults=results.filter(item=>item.is_error).length;
666
+ const event={type:'progress_warning',source:'house',consecutive_unchanged_responses:stalls,latest_batch:{tool_results:results.length,failed_or_refused:failedResults},task_progress:false,grants_permission:false};
667
+ await this.store.append(this.session.id,event);
668
+ await this.save({role:'user',content:`Host pacing notice: the house recorded no task progress in the last two responses (latest batch: ${failedResults} failed or refused tool results out of ${results.length}). Repeating an action that returns the same result is not progress. Do not repeat observations or checks that already passed. If the requested work is complete, record it now (WorkTaskUpdate, or the offered finish or submit tool) and deliver; otherwise change approach from the facts you already have. One more unchanged response will stop this turn. This notice grants no authority and does not reset the no-progress limit.${graceHint ? ` The latest refusal carries a recovery call${definitions.some(tool => tool.name === graceHint.name) && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(graceHint.name) ? ` (${graceHint.name})` : ''}: the house deferred the cut once so you can use it now; the same refusal again stops the turn.` : ''}`},'continuation');
669
+ await this.onEvent(event);
410
670
  }
671
+ await deadlineNotice(afterSession);
411
672
  if (stalls >= 3) {
412
673
  await checkpointWork(this.store, this.session.id, 'no_progress');
413
674
  const final = { type: 'result', session_id: this.session.id, result: 'Comal detuvo tres respuestas sin avance registrado en la tarea. El encargo sigue sin juzgar. ' + (results.filter(item => item.is_error).length ? 'Último error: ' + results.findLast(item => item.is_error).content.slice(0,1400) : afterBatch?.next_action || (this.tools.sourceEditor?'Use the offered source tools or report the exact missing capability; the parent owns work tracking.':'Declara una tarea pequeña con WorkPlanSet expected_version:0; no repitas consultas administrativas.')), turns, usage, is_error: false, work: afterBatch, work_stop_reason: refusalStopReason(refusalCause)||'no_progress', ...(refusalCause?{work_stop_cause:refusalCause}:{}), result_source: 'house', asked: 'unjudged', house: houseSnapshot(afterSession.rows) };
@@ -422,13 +683,19 @@ export class Agent {
422
683
  }
423
684
  }
424
685
  }
686
+ { // Gate OR-8: un corte a las 100 de 300 llamadas no dejó por qué no se amplió. Diagnóstico sin efectos.
687
+ const stopSession = await this.store.load(this.session.id), stopWork = workProjection(stopSession);
688
+ await this.store.append(this.session.id, { type: 'work_budget_stop', calls: turns, limit, work_max_calls: this.workMaxCalls, current_task: stopWork?.current?.id ?? null, current_status: stopWork?.current?.status ?? null,
689
+ progress_changed: progressStamp(stopSession) !== budgetProgress, refusal_cause: refusalCause?.kind ?? null, yield_requested: Boolean(shouldYield()), mode: this.tools.getMode?.() ?? null, source });
690
+ }
425
691
  await checkpointWork(this.store, this.session.id, 'turn_limit');
426
692
  if (this.tools.workflow) return await finish({ type: 'result', session_id: this.session.id, result: refusalCause?'Model-call limit reached; an earlier native refusal remains recorded. Task retained, delivery uncredited.':'Model-call limit reached; task retained, delivery uncredited.', turns, usage, is_error: false,
427
693
  turn_termination: { reason: 'model_call_limit', calls: turns, limit },
428
694
  work: workProjection(await this.store.load(this.session.id)), work_stop_reason: refusalStopReason(refusalCause)||'call_budget', ...(refusalCause?{work_stop_cause:refusalCause}:{}), house: houseSnapshot((await this.store.load(this.session.id)).rows) });
429
- throw Object.assign(new Error(`Se alcanzó el límite de ${limit} llamadas al modelo. La sesión quedó guardada.`),{code:'MODEL_CALL_BUDGET'});
695
+ throw Object.assign(new Error(`Se alcanzó el límite de ${limit} llamadas al modelo. La sesión quedó guardada.`),{code:'MODEL_CALL_BUDGET',usage:{...usage}});
430
696
  } catch (error) {
431
- await this.store.append(this.session.id, { type: 'error', message: error.message, code: error.code, details: error.details });
697
+ try { await this.store.append(this.session.id, { type: 'turn_timing', turn_id: toolTurnId, turn_source: source, ...timer.summary({ status: 'failed' }) }); } catch {}
698
+ await this.store.append(this.session.id, { type: 'error', message: error.message, code: error.code, details: error.details, ...(error.usage ? { usage: error.usage } : {}) });
432
699
  throw error;
433
700
  } finally {
434
701
  try { if (packTurnOpened) { await this.store.append(this.session.id, { type: 'tool_turn', source: 'capability_host', actor: capabilityActor, id: toolTurnId, open: false }); await this.toolPacks.prepare(this.session.id,capabilityActor); } }