@sema-agent/core 7.21.0 → 7.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +1 -0
  4. package/dist/agents/subagent.js +2 -1
  5. package/dist/core/ask-origin.d.ts +1 -0
  6. package/dist/core/ask-origin.js +3 -2
  7. package/dist/core/auto-mode-rebuild.js +6 -2
  8. package/dist/core/auto-mode.d.ts +40 -18
  9. package/dist/core/auto-mode.js +63 -45
  10. package/dist/core/checkpoint-store.d.ts +8 -8
  11. package/dist/core/engine-notice.d.ts +49 -1
  12. package/dist/core/engine-notice.js +14 -1
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +4 -0
  15. package/dist/core/mcp.d.ts +65 -0
  16. package/dist/core/mcp.js +190 -41
  17. package/dist/core/memory-engine/consolidation.d.ts +5 -0
  18. package/dist/core/memory-engine/consolidation.js +7 -3
  19. package/dist/core/memory-engine/engine.d.ts +8 -0
  20. package/dist/core/memory-engine/engine.js +25 -19
  21. package/dist/core/memory-engine/layout.d.ts +47 -9
  22. package/dist/core/memory-engine/layout.js +157 -57
  23. package/dist/core/memory-engine/session-incarnation.d.ts +27 -0
  24. package/dist/core/memory-engine/session-incarnation.js +63 -0
  25. package/dist/core/parent-spec-seat.d.ts +15 -3
  26. package/dist/core/parent-spec-seat.js +5 -0
  27. package/dist/core/permission-rule-model.d.ts +1 -2
  28. package/dist/core/retention.d.ts +8 -1
  29. package/dist/core/retention.js +2 -0
  30. package/dist/core/runner/classifier-ladder.js +1 -0
  31. package/dist/core/runner/contracts.d.ts +11 -0
  32. package/dist/core/runner/mcp-redial.d.ts +109 -0
  33. package/dist/core/runner/mcp-redial.js +60 -0
  34. package/dist/core/runner/memory-consolidation-driver.d.ts +7 -4
  35. package/dist/core/runner/memory-consolidation-driver.js +1 -1
  36. package/dist/core/runner/prepare-caps-and-workflow.js +9 -12
  37. package/dist/core/runner/prepare-hands-readface.d.ts +3 -0
  38. package/dist/core/runner/prepare-hands-readface.js +13 -3
  39. package/dist/core/runner/prepare-memory.js +3 -3
  40. package/dist/core/runner/prepare-protocol-tools.d.ts +6 -1
  41. package/dist/core/runner/prepare-protocol-tools.js +20 -38
  42. package/dist/core/runner/prepare-task.js +3 -3
  43. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  44. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  45. package/dist/core/runner/run-identity-wiring.d.ts +3 -1
  46. package/dist/core/runner/run-identity-wiring.js +4 -1
  47. package/dist/core/runner/run-settle-and-teardown.d.ts +3 -1
  48. package/dist/core/runner/run-settle-and-teardown.js +6 -2
  49. package/dist/core/runner/run-terminal-adoption.d.ts +4 -1
  50. package/dist/core/runner/run-terminal-adoption.js +2 -6
  51. package/dist/core/runner/runtask.js +8 -5
  52. package/dist/core/runner/stream-lifecycle-verbs.d.ts +5 -1
  53. package/dist/core/runner/stream-lifecycle-verbs.js +23 -2
  54. package/dist/core/runner-deps.d.ts +17 -8
  55. package/dist/core/skill-listing-budget.d.ts +5 -4
  56. package/dist/core/task-result.d.ts +3 -2
  57. package/dist/core/task-spec.d.ts +2 -1
  58. package/dist/core/task-stream.d.ts +26 -0
  59. package/dist/core/tool-spec.d.ts +5 -1
  60. package/dist/core/trace.d.ts +5 -0
  61. package/dist/core/types.d.ts +2 -2
  62. package/dist/core/types.js +1 -1
  63. package/dist/core/wiring-manifest.d.ts +23 -0
  64. package/dist/core/wiring-manifest.js +3 -0
  65. package/dist/index.d.ts +4 -3
  66. package/dist/index.js +2 -2
  67. package/dist/orchestration/workflow-governance.js +1 -1
  68. package/dist/orchestration/workflow-primitives.d.ts +2 -2
  69. package/dist/tools/fs/bash-program-position.d.ts +26 -5
  70. package/dist/tools/fs/bash-program-position.js +63 -16
  71. package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -17
  72. package/dist/tools/fs/bash-readonly-classifier.js +134 -55
  73. package/dist/tools/fs/fs-bash.js +43 -18
  74. package/dist/tools/fs/index.d.ts +4 -2
  75. package/dist/tools/fs/read-deny.d.ts +25 -11
  76. package/dist/tools/fs/read-deny.js +5 -1
  77. package/dist/tools/fs/safety.d.ts +1 -4
  78. package/package.json +1 -1
  79. package/test/export-surface.snapshot.json +9 -1
  80. package/wire-changes/core@7.21.1.json +41 -0
  81. package/wire-changes/core@7.22.0.json +637 -0
@@ -102,11 +102,11 @@ export type PlatformLimitReason = Extract<ResourceLimitReason, "env_lifetime" |
102
102
  export interface SafetyAxis {
103
103
  /** design/70: the tool is egress-marked (`ToolSpec.egress` — an external write: push, open PR, send). */
104
104
  egress?: boolean;
105
- /** design/77 §4: this call carries an irreversibility MANDATE — the tool's own `always`/`maybe` tier, the
106
- * operator's `shellGate:"always"`, the probe's structural verdict (mandated / unanswered), or the shell
107
- * gate's own tighten under its `"maybe"` tier; a policy ask the doctrine's probe neither tightened nor
108
- * mandated carries no bit (its park is the budgetable kind). */
105
+ /** design/77 §4: an irreversibility MANDATE **of the TOOL's own** (its `always`/`maybe` tier); a SHELL SEAT raise is NOT this axis, see {@link shell}. */
109
106
  irreversible?: boolean;
107
+ /** The engine's SHELL SEAT raised this ask. Its question is about TEXT — not about undoing an act — so
108
+ * the two axes are mutually exclusive. */
109
+ shell?: boolean;
110
110
  }
111
111
  /**
112
112
  * design/80 §D-E: a DETERMINISTIC, REDACTED risk summary attached AT MINT to an escalation
@@ -948,10 +948,10 @@ export interface CheckpointState {
948
948
  /**
949
949
  * design/199 — the READ-face containment state at suspend (v9,
950
950
  * {@link FACE_CHECKPOINT_VERSION}): the RESOLVED face plus the NORMALIZED deny-set ADDITIONS in
951
- * force (deployment ∪ task layers; the built-in table is engine code, never frozen here — a newer
952
- * engine's built-ins apply on resume as everywhere else). Written ONLY when the posture is
953
- * non-default (face "open", or additions present) — an absent section IS the default posture and
954
- * resumes as roots + built-ins on any worker. Resume folds STRICTER-WINS: face = roots unless the
951
+ * force (deployment ∪ task layers; the built-in table is engine code under the RESUMING deployment's
952
+ * own tier selection — empty unless it made one — never frozen here). Written ONLY when the posture
953
+ * is non-default (face "open", or additions present) — an absent section IS the default posture and
954
+ * resumes as roots plus whatever built-in tiers the resuming worker selected. Resume folds STRICTER-WINS: face = roots unless the
955
955
  * section says open AND the live resolution says open; deny additions = frozen ∪ live (add-only,
956
956
  * monotone — a resume can never widen what the cut leg could read).
957
957
  *
@@ -109,6 +109,18 @@ export interface EngineNotice {
109
109
  * its explicit {@link NOTICE_AUDIENCE} row (#433 made the registry total over the catalog —
110
110
  * no engine-minted code is audience-defaulted any more). The refusal itself
111
111
  * (`mcp.server_revoked`) is a tool RESULT code, not a notice.
112
+ * - `"mcp.server_redialed"` — a host asked this run to RE-DIAL one MCP server (the run
113
+ * handle's `reconnectMcpServer` verb) and this is what came of it. `detail: { server, outcome,
114
+ * added, removed, reason?, sessionId, runId }` — `outcome` is the result's own three-word answer
115
+ * (`reconnected` / `refused` / `not_declared`) and `added`/`removed` are COUNTS of namespaced
116
+ * tool names, never the names themselves (a server's tool names are server-authored text and
117
+ * this is an operator line, not a disclosure channel). ONE code rather than a success code and a
118
+ * failure code: the outcome is data about one event, not two kinds of event, and both would
119
+ * carry the same presentation tier — a consumer alerting on failures reads one field.
120
+ * `"operator"` audience: re-dialing is deployment plumbing and a failed one leaves the server
121
+ * DOWN, which only the operator can act on. Dedup unit: once per ATTEMPT — never folded per run,
122
+ * because two attempts on one server are two different answers and folding the second would hide
123
+ * the one that says the server is still down.
112
124
  * - `"mcp.injection_dropped"` (L-167) — an entry of the user's own request-lane MCP injection
113
125
  * was NOT mounted for the leg. The HOST owns the drop sites and supplies the facts; CORE composes
114
126
  * the notice ({@link import("./mcp-injection-drop.js").mcpInjectionDroppedNotice}, the one mint).
@@ -707,6 +719,28 @@ export declare function deliverEngineNotice(onNotice: ((notice: EngineNotice) =>
707
719
  * one repeated disclosure about one run. The account is per-RUN by its own contract sentence ("at most
708
720
  * once per family, per run"), so it must carry the run's own id. Same omit-never-fabricate rule.
709
721
  */
722
+ /** What a settled leg still owes a person BEFORE the compensation lane has had its say: the facts the
723
+ * terminal-adoption lane holds, carried to the settle lane that closes the account
724
+ * ({@link settleOwedUserInput}). */
725
+ export interface OwedUserInputAccount {
726
+ /** The harness's at-agent_end snapshot, held because the tail still had something to say about it. */
727
+ readonly held: {
728
+ steer: number;
729
+ followUp: number;
730
+ } | undefined;
731
+ /** What a durable park's migration carried onto the new checkpoint, by family. */
732
+ readonly migrated: {
733
+ steer: number;
734
+ followUp: number;
735
+ };
736
+ /** The resumed leg's undelivered continuation frames, and how many of them the migration carried. */
737
+ readonly continuation: {
738
+ frames: ReadonlyArray<{
739
+ seq?: number;
740
+ }>;
741
+ carried: number;
742
+ };
743
+ }
710
744
  /**
711
745
  * What this leg still OWES a person, one count per family — the input to {@link undrainedUserInputNotices}.
712
746
  * TWO lanes accept caller input and either can be holding some when a run ends: the harness's live queue
@@ -727,11 +761,25 @@ export declare function owedUserInputCounts(held: {
727
761
  seq?: number;
728
762
  }>;
729
763
  carried: number;
730
- rowMayReturn: boolean;
764
+ rowReturned: boolean;
731
765
  }): {
732
766
  steer: number;
733
767
  followUp: number;
734
768
  };
769
+ /** Close the account — the ONE settlement point, called AFTER the reopen compensation with what that
770
+ * compensation actually did. Ordering is the whole rule: the counts are arithmetic, and the only fact they
771
+ * cannot be computed without is one the lane before this one does not have yet. EXACTLY ONCE per account,
772
+ * which is what lets the teardown call it again as a backstop for a settle lane that threw on its way here
773
+ * (an account nobody closes is the silence this seat exists to end; a second row would be the same loss
774
+ * counted twice). Keyed on the account OBJECT — one per run, minted by the lane that assembles it. NEVER
775
+ * THROWS: a station calls it from a teardown path where the run is already failing, and a disclosure that
776
+ * takes the cleanup down with it is worse than the silence it was fixing (the absent-sink arm of
777
+ * {@link deliverEngineNotice} ends at a bare `console.warn`, which a host-replaced console can throw from). */
778
+ export declare function settleOwedUserInput(account: OwedUserInputAccount, rowReturned: boolean, ids: {
779
+ taskId?: string;
780
+ sessionId?: string;
781
+ runId?: string;
782
+ }, onNotice: ((notice: EngineNotice) => void) | undefined): void;
735
783
  export declare function undrainedUserInputNotices(counts: {
736
784
  steer: number;
737
785
  followUp: number;
@@ -32,12 +32,25 @@ export function deliverEngineNotice(onNotice, notice) {
32
32
  console.warn(notice.message);
33
33
  }
34
34
  export function owedUserInputCounts(held, migrated, continuation) {
35
- const owedContinuation = (continuation.rowMayReturn ? continuation.frames.filter((f) => f.seq === undefined).length : continuation.frames.length) - continuation.carried;
35
+ const owedContinuation = (continuation.rowReturned ? continuation.frames.filter((f) => f.seq === undefined).length : continuation.frames.length) - continuation.carried;
36
36
  return {
37
37
  steer: Math.max(0, (held?.steer ?? 0) - migrated.steer) + Math.max(0, owedContinuation),
38
38
  followUp: Math.max(0, (held?.followUp ?? 0) - migrated.followUp),
39
39
  };
40
40
  }
41
+ export function settleOwedUserInput(account, rowReturned, ids, onNotice) {
42
+ if (SETTLED_OWED_ACCOUNTS.has(account))
43
+ return;
44
+ SETTLED_OWED_ACCOUNTS.add(account);
45
+ try {
46
+ const owed = owedUserInputCounts(account.held, account.migrated, { ...account.continuation, rowReturned });
47
+ for (const notice of undrainedUserInputNotices(owed, ids.taskId, ids.sessionId, ids.runId))
48
+ deliverEngineNotice(onNotice, notice);
49
+ }
50
+ catch {
51
+ }
52
+ }
53
+ const SETTLED_OWED_ACCOUNTS = new WeakSet();
41
54
  export function undrainedUserInputNotices(counts, taskId, sessionId, runId) {
42
55
  const tid = taskId !== undefined ? { taskId } : {};
43
56
  const sid = sessionId !== undefined ? { sessionId } : {};
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -18,6 +18,8 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
18
18
  "memory.layout_unowned_root_content",
19
19
  "memory.tail",
20
20
  "memory.pollution_mark_failed",
21
+ "memory.session_incarnation_mismatch",
22
+ "memory.session_incarnation_unreadable",
21
23
  "memory.session_polluted",
22
24
  "memory.harvest_quarantined",
23
25
  "memory.delegation_static_mark_waived",
@@ -132,6 +134,7 @@ export const ENGINE_NOTICE_CODES = [
132
134
  "delegation.ask_unresolvable",
133
135
  "mcp.injection_dropped",
134
136
  "mcp.revocation_probe_failed",
137
+ "mcp.server_redialed",
135
138
  "workflow.governance_key_stripped",
136
139
  "workflow.agent_option_ignored",
137
140
  "memory.session_polluted",
@@ -214,6 +217,7 @@ const NOTICE_AUDIENCE_TABLE = {
214
217
  "checkpoint.execution_outcome_unrecorded": "operator",
215
218
  "mcp.injection_dropped": "user",
216
219
  "mcp.revocation_probe_failed": "operator",
220
+ "mcp.server_redialed": "operator",
217
221
  "workflow.governance_key_stripped": "operator",
218
222
  "workflow.agent_option_ignored": "operator",
219
223
  "memory.content_class_declared": "operator",
@@ -190,6 +190,25 @@ export interface MaterializedMcp {
190
190
  * misroute).
191
191
  */
192
192
  refresh: (server?: string) => Promise<McpRefreshResult[]>;
193
+ /**
194
+ * RE-DIAL one named server — the action {@link refresh} deliberately is not. It closes
195
+ * whatever handle the server still has and dials it again through the SAME `connectServer` the
196
+ * materialization used, so the schema gate, the namespacing, the axis derivation and the metadata
197
+ * cache seeding cannot drift between a first connect and a re-dial: one function, not one
198
+ * convention.
199
+ *
200
+ * ONE SERVER, NAMED — CC 2.1.259's control-channel request carries a REQUIRED `serverName` and has
201
+ * no all-servers form, and an all-servers arm would close HEALTHY connections to re-dial them (a
202
+ * stdio server owning a device or a port is exactly the one that cannot be re-dialed while its
203
+ * predecessor still holds the device). CLOSE-THEN-DIAL, so a re-dial that FAILS leaves the server
204
+ * DISCONNECTED until another re-dial or the next leg's materialize, and calls in flight on the
205
+ * replaced connection fail with it — inherent to re-dialing, and CC's behavior too.
206
+ *
207
+ * NOT A MODEL FACE (CC has no such tool either): a verb that can leave a connection down is a host
208
+ * act, and its consumer entrance is the run handle's `reconnectMcpServer`. LEG-LOCAL like
209
+ * {@link refresh}: the result rides no checkpoint, and a durable resume takes a fresh listing.
210
+ */
211
+ reconnect: (server: string) => Promise<McpReconnectResult>;
193
212
  dispose: () => Promise<void>;
194
213
  }
195
214
  /** One per-server entry of {@link MaterializedMcp.refresh}. `added`/`removed` are namespaced
@@ -244,6 +263,52 @@ export interface McpRefreshResult {
244
263
  */
245
264
  listingIncomplete?: McpListingIncomplete;
246
265
  }
266
+ /**
267
+ * The result of {@link MaterializedMcp.reconnect} — ONE server, because the face takes one.
268
+ *
269
+ * `outcome` is the whole top level, three words, so a consumer mapping it onto a three-state wire
270
+ * answer needs no table: `reconnected` is the only success, and everything that did not reconnect is
271
+ * either `refused` (the engine declined, or the dial/first listing failed — `reason` says which) or
272
+ * `not_declared` (no server of that name in this leg's spec). Sub-reasons ride `reason`; a fourth
273
+ * word would be a new state for every downstream to learn.
274
+ *
275
+ * @contract mcp.redial.roster_assertion — a re-dial result carries `tools` IF AND ONLY IF it asserts a
276
+ * roster for its `prefix` domain, and that one rule is what every consumer of it reads: the fresh intake
277
+ * on `reconnected`, the EMPTY roster on a dial that failed after the old connection was already closed
278
+ * (the server is down, so its tools are withdrawn rather than left mounted and unusable), and NOTHING at
279
+ * all on an arm that never touched the connection — an unknown name, a revoked server, a re-dial already
280
+ * in flight, a disposed materialization — whose mounted roster is left exactly as it was.
281
+ */
282
+ export interface McpReconnectResult {
283
+ server: string;
284
+ /** The consumer's splice domain, same meaning and same mint as {@link McpRefreshResult.prefix}. */
285
+ prefix: string;
286
+ outcome: "reconnected" | "refused" | "not_declared";
287
+ /** Why, on every non-`reconnected` outcome (neutralized + bounded when it embeds remote text).
288
+ * Never the only machine-readable fact: see {@link status}. */
289
+ reason?: string;
290
+ toolCount: number;
291
+ /** Namespaced names this re-dial added / took away relative to what the server had mounted a moment
292
+ * ago — a first successful dial adds its whole roster, a failed dial removes it. */
293
+ added: string[];
294
+ removed: string[];
295
+ /** See the type docstring: present iff this result asserts a roster for `prefix`. */
296
+ tools?: AgentTool[];
297
+ axes?: McpToolAxis[];
298
+ dropped?: McpDroppedTool[];
299
+ /** The OPERATOR lines this re-dial produced (an incomplete listing walk, a kept-but-defective tool
300
+ * schema, a spec naming tools a resources-only server cannot serve). Also appended to
301
+ * {@link MaterializedMcp.warnings}, which the runner drains ONCE at prepare — so the copy here is
302
+ * what the run handle's verb forwards to `onError(phase:"mcp")`. One production, two drains. */
303
+ warnings?: string[];
304
+ /** The NEW connect-time status record (also swapped into {@link MaterializedMcp.statuses}), present
305
+ * whenever the dial was attempted: a consumer keys its next action off `errorCode` / `httpStatus`
306
+ * (a 401 is a re-authorization door, a `spawn_failed` a missing binary), not off {@link reason}. */
307
+ status?: McpServerStatus;
308
+ /** The re-dial's own `tools/list` walk did not finish — {@link McpRefreshResult.listingIncomplete}
309
+ * minus the retention arm: a re-dial has no previous listing on this connection to retain from. */
310
+ listingIncomplete?: McpListingIncomplete;
311
+ }
247
312
  /** design/99 §E9 — projected per-server MCP status (see {@link MaterializedMcp.statuses}). NOTE: `serverInfo`
248
313
  * and `error` are SERVER-controlled strings (verbatim from the remote) — UNTRUSTED; a consumer rendering them
249
314
  * into a TUI/log must treat them as such (a misbehaving server could embed ANSI/break-out sequences). `toolNames`
package/dist/core/mcp.js CHANGED
@@ -709,7 +709,52 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
709
709
  const droppedTools = [];
710
710
  let disposing = false;
711
711
  const serverHandles = [];
712
- const dialKinds = entries.map((spec) => {
712
+ const redialsInFlight = new Set();
713
+ const commitConnectedServer = (spec, s, transportKind) => {
714
+ const raised = [];
715
+ clients.push(s.client);
716
+ const handle = { name: spec.name, spec, client: s.client, health: s.health, tools: s.tools, listedRaw: s.listedTools, transportKind, announce: s.announce, instructions: s.instructions };
717
+ const hIdx = serverHandles.findIndex((h) => h.name === spec.name);
718
+ if (hIdx === -1)
719
+ serverHandles.push(handle);
720
+ else
721
+ serverHandles[hIdx] = handle;
722
+ const hasInstructions = s.instructions !== undefined;
723
+ const announceClose = () => {
724
+ if (disposing)
725
+ return;
726
+ s.status.transportClosed = true;
727
+ if (hasInstructions)
728
+ instructionsDelta.pendingRemovals.push(spec.name);
729
+ };
730
+ s.announce.fn = announceClose;
731
+ if (s.health.dead)
732
+ announceClose();
733
+ if (s.resourceServer) {
734
+ const rIdx = resourceServers.findIndex((rs) => rs.server === spec.name);
735
+ if (rIdx === -1)
736
+ resourceServers.push(s.resourceServer);
737
+ else
738
+ resourceServers[rIdx] = s.resourceServer;
739
+ }
740
+ for (const d of s.dropped)
741
+ droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
742
+ for (const a of s.schemaAdvisories)
743
+ raised.push(schemaAdvisoryWarning(spec.name, a.tool, a.reason));
744
+ if (s.toolsCapabilityAbsent === true && spec.allowTools !== undefined && spec.allowTools.length > 0) {
745
+ raised.push(toolsCapabilityAbsentWarning(spec.name));
746
+ }
747
+ if (s.listingIncomplete)
748
+ raised.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
749
+ warnings.push(...raised);
750
+ const sIdx = statuses.findIndex((st) => st.name === spec.name);
751
+ if (sIdx === -1)
752
+ statuses.push(s.status);
753
+ else
754
+ statuses[sIdx] = s.status;
755
+ return raised;
756
+ };
757
+ const readDialKind = (spec) => {
713
758
  try {
714
759
  const kind = spec.transport.kind;
715
760
  return kind === "stdio" || kind === "http" ? kind : undefined;
@@ -717,7 +762,33 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
717
762
  catch {
718
763
  return undefined;
719
764
  }
720
- });
765
+ };
766
+ const dialKinds = entries.map(readDialKind);
767
+ const prefixOf = (name) => mintNamespacePrefix(MCP_NAMESPACE, name);
768
+ const reconcileInstructions = (name, before, after, connected) => {
769
+ let owedRemoval = false;
770
+ for (let i = instructionsDelta.pendingRemovals.length - 1; i >= 0; i--) {
771
+ if (instructionsDelta.pendingRemovals[i] !== name)
772
+ continue;
773
+ instructionsDelta.pendingRemovals.splice(i, 1);
774
+ owedRemoval = true;
775
+ }
776
+ let hadPendingAdd = false;
777
+ for (let i = instructionsDelta.pendingAdds.length - 1; i >= 0; i--) {
778
+ if (instructionsDelta.pendingAdds[i].server !== name)
779
+ continue;
780
+ instructionsDelta.pendingAdds.splice(i, 1);
781
+ hadPendingAdd = true;
782
+ }
783
+ if (after !== undefined) {
784
+ instructionsDelta.pendingAdds.push({ server: name, text: after });
785
+ return;
786
+ }
787
+ if (connected)
788
+ return;
789
+ if (before !== undefined || owedRemoval || hadPendingAdd)
790
+ instructionsDelta.pendingRemovals.push(name);
791
+ };
721
792
  const settled = await Promise.allSettled(entries.map((spec, i) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked, dialKinds[i])));
722
793
  try {
723
794
  for (let i = 0; i < entries.length; i++) {
@@ -725,36 +796,12 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
725
796
  const spec = entries[i];
726
797
  if (r.status === "fulfilled") {
727
798
  const s = r.value;
728
- clients.push(s.client);
729
- serverHandles.push({ name: spec.name, spec, client: s.client, health: s.health, tools: s.tools, listedRaw: s.listedTools, transportKind: dialKinds[i] });
730
799
  tools.push(...s.tools);
731
800
  toolAxes.push(...s.axes);
732
801
  if (s.instructions) {
733
802
  serverInstructions.push({ server: spec.name, text: s.instructions });
734
803
  }
735
- const hasInstructions = s.instructions !== undefined;
736
- const announceClose = () => {
737
- if (disposing)
738
- return;
739
- s.status.transportClosed = true;
740
- if (hasInstructions)
741
- instructionsDelta.pendingRemovals.push(spec.name);
742
- };
743
- s.announce.fn = announceClose;
744
- if (s.health.dead)
745
- announceClose();
746
- if (s.resourceServer)
747
- resourceServers.push(s.resourceServer);
748
- for (const d of s.dropped)
749
- droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
750
- for (const a of s.schemaAdvisories)
751
- warnings.push(schemaAdvisoryWarning(spec.name, a.tool, a.reason));
752
- if (s.toolsCapabilityAbsent === true && spec.allowTools !== undefined && spec.allowTools.length > 0) {
753
- warnings.push(toolsCapabilityAbsentWarning(spec.name));
754
- }
755
- if (s.listingIncomplete)
756
- warnings.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
757
- statuses.push(s.status);
804
+ commitConnectedServer(spec, s, dialKinds[i]);
758
805
  }
759
806
  else {
760
807
  warnings.push(asServerWarning(spec, r.reason));
@@ -770,6 +817,89 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
770
817
  await Promise.allSettled(settled.map((r) => (r.status === "fulfilled" ? r.value.client.close() : Promise.resolve())));
771
818
  throw mergeErr;
772
819
  }
820
+ const redialOnce = async (server) => {
821
+ const prefix = prefixOf(server);
822
+ const declined = (outcome, reason) => ({ server, prefix, outcome, reason, toolCount: 0, added: [], removed: [] });
823
+ if (disposing)
824
+ return declined("refused", "this materialization is being disposed — the task is tearing down");
825
+ const spec = entries.find((e) => e.name === server);
826
+ if (spec === undefined)
827
+ return declined("not_declared", `no MCP server named ${inlineUntrusted(server, 120)} is declared for this task`);
828
+ if (isServerRevoked(server))
829
+ return declined("refused", "server revoked by the operator — the re-dial did not contact it");
830
+ if (redialsInFlight.has(server))
831
+ return declined("refused", "a re-dial of this server is already in flight — wait for it to settle");
832
+ redialsInFlight.add(server);
833
+ try {
834
+ const hIdx = serverHandles.findIndex((h) => h.name === server);
835
+ const prior = hIdx === -1 ? undefined : serverHandles[hIdx];
836
+ const priorNames = prior ? prior.tools.map((t) => t.name) : [];
837
+ const priorInstructions = prior?.instructions;
838
+ if (prior) {
839
+ prior.announce.fn = undefined;
840
+ serverHandles.splice(hIdx, 1);
841
+ const cIdx = clients.indexOf(prior.client);
842
+ if (cIdx !== -1)
843
+ clients.splice(cIdx, 1);
844
+ await prior.client.close().catch(() => undefined);
845
+ }
846
+ const dialKind = readDialKind(spec);
847
+ let connected;
848
+ try {
849
+ connected = await connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked, dialKind);
850
+ }
851
+ catch (err) {
852
+ const { kind, delivered, httpStatus } = classifyMcpFailure(err, { phase: "connect", transport: dialKind });
853
+ const status = { name: server, status: "failed", errorCode: kind, delivered, ...(httpStatus !== undefined ? { httpStatus } : {}), error: namedMcpFailureText(err) };
854
+ const sIdx = statuses.findIndex((st) => st.name === server);
855
+ if (sIdx === -1)
856
+ statuses.push(status);
857
+ else
858
+ statuses[sIdx] = status;
859
+ const raised = asServerWarning(spec, err);
860
+ warnings.push(raised);
861
+ reconcileInstructions(server, priorInstructions, undefined, false);
862
+ return {
863
+ server,
864
+ prefix,
865
+ outcome: "refused",
866
+ reason: inlineUntrusted(namedMcpFailureText(err), 240),
867
+ toolCount: 0,
868
+ added: [],
869
+ removed: priorNames,
870
+ tools: [],
871
+ axes: [],
872
+ dropped: [],
873
+ warnings: [raised.message],
874
+ status,
875
+ };
876
+ }
877
+ if (disposing) {
878
+ await connected.client.close().catch(() => undefined);
879
+ return declined("refused", "this materialization was disposed while the re-dial was in flight — the new connection was closed");
880
+ }
881
+ const raised = commitConnectedServer(spec, connected, dialKind);
882
+ reconcileInstructions(server, priorInstructions, connected.instructions, true);
883
+ const newNames = connected.tools.map((t) => t.name);
884
+ return {
885
+ server,
886
+ prefix,
887
+ outcome: "reconnected",
888
+ toolCount: newNames.length,
889
+ added: newNames.filter((n) => !priorNames.includes(n)),
890
+ removed: priorNames.filter((n) => !newNames.includes(n)),
891
+ tools: connected.tools,
892
+ axes: connected.axes,
893
+ dropped: connected.dropped.map((d) => ({ server: inlineUntrusted(server, 160), ...d })),
894
+ ...(raised.length > 0 ? { warnings: raised.map((w) => w.message) } : {}),
895
+ status: connected.status,
896
+ ...(connected.listingIncomplete !== undefined ? { listingIncomplete: connected.listingIncomplete } : {}),
897
+ };
898
+ }
899
+ finally {
900
+ redialsInFlight.delete(server);
901
+ }
902
+ };
773
903
  return {
774
904
  tools,
775
905
  toolAxes,
@@ -779,8 +909,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
779
909
  droppedTools,
780
910
  statuses,
781
911
  refresh: async (server) => {
782
- const prefixOf = (name) => mintNamespacePrefix(MCP_NAMESPACE, name);
783
- const targets = server !== undefined ? serverHandles.filter((h) => h.name === server) : serverHandles;
912
+ const targets = server !== undefined ? serverHandles.filter((h) => h.name === server) : [...serverHandles];
784
913
  const results = [];
785
914
  if (server !== undefined && targets.length === 0) {
786
915
  return [{ server, prefix: prefixOf(server), status: "not_connected", toolCount: 0, added: [], removed: [] }];
@@ -834,8 +963,25 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
834
963
  });
835
964
  }
836
965
  }
966
+ for (let i = 0; i < results.length; i++) {
967
+ const entry = results[i];
968
+ if (entry.status !== "refreshed")
969
+ continue;
970
+ if (serverHandles.find((h) => h.name === entry.server)?.tools === entry.tools)
971
+ continue;
972
+ results[i] = {
973
+ server: entry.server,
974
+ prefix: entry.prefix,
975
+ status: "failed",
976
+ toolCount: 0,
977
+ added: [],
978
+ removed: [],
979
+ error: "this server was re-dialed while the refresh was reading another server — the listing is from the connection that re-dial replaced, so it was discarded",
980
+ };
981
+ }
837
982
  return results;
838
983
  },
984
+ reconnect: redialOnce,
839
985
  dispose: async () => {
840
986
  disposing = true;
841
987
  await Promise.allSettled(clients.map((c) => c.close()));
@@ -1063,13 +1209,13 @@ function renderDirChildren(server, uri, children, flags) {
1063
1209
  };
1064
1210
  }
1065
1211
  function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1066
- const listable = resourceServers.filter((rs) => rs.listAllowed);
1067
- const readable = resourceServers.filter((rs) => rs.readAllowed);
1068
- if (listable.length === 0 && readable.length === 0)
1212
+ const listable = () => resourceServers.filter((rs) => rs.listAllowed);
1213
+ const readable = () => resourceServers.filter((rs) => rs.readAllowed);
1214
+ if (listable().length === 0 && readable().length === 0)
1069
1215
  return { tools: [], axes: [] };
1070
1216
  const tools = [];
1071
1217
  const axes = [];
1072
- if (listable.length > 0) {
1218
+ if (listable().length > 0) {
1073
1219
  tools.push({
1074
1220
  name: LIST_MCP_RESOURCES,
1075
1221
  description: "List the resources available from connected MCP servers (uri / name / description). Pass `server` to limit " +
@@ -1082,9 +1228,10 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1082
1228
  },
1083
1229
  execute: async (_id, params, signal) => {
1084
1230
  const { server } = (params ?? {});
1085
- const targets = server ? listable.filter((rs) => rs.server === server) : listable;
1231
+ const live = listable();
1232
+ const targets = server ? live.filter((rs) => rs.server === server) : live;
1086
1233
  if (server && targets.length === 0) {
1087
- return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with listable resources. Available: ${serverNames(listable)}.` }], details: undefined, terminate: false };
1234
+ return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with listable resources. Available: ${serverNames(live)}.` }], details: undefined, terminate: false };
1088
1235
  }
1089
1236
  const sections = [];
1090
1237
  const all = [];
@@ -1146,7 +1293,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1146
1293
  });
1147
1294
  axes.push({ name: LIST_MCP_RESOURCES, effect: "read" });
1148
1295
  }
1149
- if (readable.length > 0) {
1296
+ if (readable().length > 0) {
1150
1297
  tools.push({
1151
1298
  name: READ_MCP_RESOURCE,
1152
1299
  description: "Read a specific MCP resource by `server` + `uri` (from ListMcpResourcesTool). Its content is external/untrusted data.",
@@ -1164,9 +1311,10 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1164
1311
  const { server, uri } = (params ?? {});
1165
1312
  if (!server || !uri)
1166
1313
  return { content: [{ type: "text", text: "Error: `server` and `uri` are required." }], details: undefined, terminate: false };
1167
- const rs = readable.find((r) => r.server === server);
1314
+ const live = readable();
1315
+ const rs = live.find((r) => r.server === server);
1168
1316
  if (!rs)
1169
- return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with readable resources. Available: ${serverNames(readable)}.` }], details: undefined, terminate: false };
1317
+ return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with readable resources. Available: ${serverNames(live)}.` }], details: undefined, terminate: false };
1170
1318
  const what = `The read of resource ${inlineUntrusted(uri)}`;
1171
1319
  if (isServerRevoked(server)) {
1172
1320
  return {
@@ -1203,7 +1351,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1203
1351
  });
1204
1352
  axes.push({ name: READ_MCP_RESOURCE, effect: "read" });
1205
1353
  }
1206
- if (listable.length > 0) {
1354
+ if (listable().length > 0) {
1207
1355
  tools.push({
1208
1356
  name: READ_MCP_RESOURCE_DIR,
1209
1357
  description: "List the MCP resources UNDER a directory `uri` on `server` — its child resources (those whose uri is " +
@@ -1223,9 +1371,10 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1223
1371
  const { server, uri } = (params ?? {});
1224
1372
  if (!server || !uri)
1225
1373
  return { content: [{ type: "text", text: "Error: `server` and `uri` are required." }], details: undefined, terminate: false };
1226
- const rs = listable.find((r) => r.server === server);
1374
+ const live = listable();
1375
+ const rs = live.find((r) => r.server === server);
1227
1376
  if (!rs)
1228
- return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with listable resources. Available: ${serverNames(listable)}.` }], details: undefined, terminate: false };
1377
+ return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with listable resources. Available: ${serverNames(live)}.` }], details: undefined, terminate: false };
1229
1378
  const what = `The directory listing of ${inlineUntrusted(uri)}`;
1230
1379
  if (isServerRevoked(server)) {
1231
1380
  return {
@@ -302,11 +302,16 @@ export type ConsolidationGateRead = {
302
302
  * ONE source for "is a run owed", so the edge can never sit disarmed while the trigger believes nothing is owed.
303
303
  */
304
304
  export declare function recommendationUnconsumed(row: Pick<ConsolidationGateRow, "lastRecommendedAt" | "lastRunAt">): boolean;
305
+ /** §2.2 — the ONE reading of "how many distinct sessions has this scope banked since its last completed run", for every gate comparing it against `minSessionsBetweenRuns` and every sentence reporting it. The stored `count` includes the most recent harvest's session, and that one is never material to a decision taken on its own behalf: this bookkeeping also runs on the suspend path, so it may still be running, and at a terminal harvest its notes are the ones just written. Banked is one less than stored; an absent row banks nothing.
306
+ * Never re-derived — two gates comparing two numbers is how a run is admitted for a crossing that was never minted, and how a refusal reports a figure the recommendation notice contradicts. */
307
+ export declare function sessionsSinceExcludingCurrent(row: Pick<ConsolidationGateRow, "sessions"> | undefined): number;
305
308
  /**
306
309
  * §2.2 — the engine-minted session count (harvest-tail bookkeeping; ≤1 row write per terminal
307
310
  * harvest; the OFF mode never calls this — D-9a zero-write). Returns the recommendation edge:
308
311
  * `recommended` is true exactly ONCE per threshold crossing (once-per-越线沿; a completed run
309
312
  * resets the edge).
313
+ *
314
+ * The ring records the calling session (dedup), so `count` INCLUDES the caller; the gate here and the number beside it are {@link sessionsSinceExcludingCurrent}, which takes it back out.
310
315
  */
311
316
  export declare function recordConsolidationSession(controlDir: string, scope: string, sessionId: string, opts: ScreenedConsolidationOptions, now: () => number): {
312
317
  recommended: boolean;
@@ -150,19 +150,23 @@ export function consolidationGateFileExists(controlDir) {
150
150
  export function recommendationUnconsumed(row) {
151
151
  return row.lastRecommendedAt !== undefined && (row.lastRunAt === undefined || row.lastRecommendedAt > row.lastRunAt);
152
152
  }
153
+ export function sessionsSinceExcludingCurrent(row) {
154
+ return Math.max(0, (row?.sessions.count ?? 0) - 1);
155
+ }
153
156
  export function recordConsolidationSession(controlDir, scope, sessionId, opts, now) {
154
157
  return updateConsolidationGate(controlDir, (file) => {
155
158
  const row = file.scopes[scope] ?? freshRow();
156
159
  if (row.sessions.ring.includes(sessionId)) {
157
- return { result: { recommended: false, sessionsSince: row.sessions.count } };
160
+ return { result: { recommended: false, sessionsSince: sessionsSinceExcludingCurrent(row) } };
158
161
  }
159
162
  row.sessions.count += 1;
160
163
  row.sessions.ring.push(sessionId);
161
164
  if (row.sessions.ring.length > CONSOLIDATION_SESSION_RING_MAX)
162
165
  row.sessions.ring.splice(0, row.sessions.ring.length - CONSOLIDATION_SESSION_RING_MAX);
163
166
  const at = now();
167
+ const banked = sessionsSinceExcludingCurrent(row);
164
168
  const timeOpen = row.lastRunAt === undefined || at - row.lastRunAt >= opts.minRunIntervalMs;
165
- const sessionsOpen = row.sessions.count >= opts.minSessionsBetweenRuns;
169
+ const sessionsOpen = banked >= opts.minSessionsBetweenRuns;
166
170
  const edgeArmed = !recommendationUnconsumed(row);
167
171
  let recommended = false;
168
172
  if (timeOpen && sessionsOpen && edgeArmed) {
@@ -170,7 +174,7 @@ export function recordConsolidationSession(controlDir, scope, sessionId, opts, n
170
174
  row.lastRecommendedAt = at;
171
175
  }
172
176
  file.scopes[scope] = row;
173
- return { next: file, result: { recommended, sessionsSince: row.sessions.count } };
177
+ return { next: file, result: { recommended, sessionsSince: banked } };
174
178
  });
175
179
  }
176
180
  export function recordActiveSetBaseline(controlDir, scope, size, ratioCap, _now) {