@animalabs/agent-framework 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +11 -4
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/server.d.ts.map +1 -1
  6. package/dist/src/api/server.js +41 -4
  7. package/dist/src/api/server.js.map +1 -1
  8. package/dist/src/framework.d.ts +319 -20
  9. package/dist/src/framework.d.ts.map +1 -1
  10. package/dist/src/framework.js +1500 -182
  11. package/dist/src/framework.js.map +1 -1
  12. package/dist/src/index.d.ts +2 -2
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/index.d.ts +2 -0
  16. package/dist/src/mcpl/index.d.ts.map +1 -1
  17. package/dist/src/mcpl/index.js.map +1 -1
  18. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  19. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  20. package/dist/src/mcpl/reference-fetcher.js +20 -9
  21. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  22. package/dist/src/mcpl/references.d.ts +29 -6
  23. package/dist/src/mcpl/references.d.ts.map +1 -1
  24. package/dist/src/mcpl/references.js +57 -16
  25. package/dist/src/mcpl/references.js.map +1 -1
  26. package/dist/src/mcpl/server-connection.d.ts +28 -1
  27. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  28. package/dist/src/mcpl/server-connection.js +32 -4
  29. package/dist/src/mcpl/server-connection.js.map +1 -1
  30. package/dist/src/module-registry.d.ts +9 -3
  31. package/dist/src/module-registry.d.ts.map +1 -1
  32. package/dist/src/module-registry.js +3 -2
  33. package/dist/src/module-registry.js.map +1 -1
  34. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/index.js +46 -4
  36. package/dist/src/modules/workspace/index.js.map +1 -1
  37. package/dist/src/modules/workspace/observe.d.ts +13 -3
  38. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/observe.js +24 -4
  40. package/dist/src/modules/workspace/observe.js.map +1 -1
  41. package/dist/src/modules/workspace/reconcile.d.ts +14 -0
  42. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/reconcile.js +10 -2
  44. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  45. package/dist/src/operator-log.d.ts +41 -0
  46. package/dist/src/operator-log.d.ts.map +1 -1
  47. package/dist/src/operator-log.js.map +1 -1
  48. package/dist/src/provider-waits.d.ts +216 -0
  49. package/dist/src/provider-waits.d.ts.map +1 -0
  50. package/dist/src/provider-waits.js +482 -0
  51. package/dist/src/provider-waits.js.map +1 -0
  52. package/dist/src/refusal-reactions.d.ts +14 -12
  53. package/dist/src/refusal-reactions.d.ts.map +1 -1
  54. package/dist/src/refusal-reactions.js +14 -12
  55. package/dist/src/refusal-reactions.js.map +1 -1
  56. package/dist/src/tool-result-guard.d.ts +30 -13
  57. package/dist/src/tool-result-guard.d.ts.map +1 -1
  58. package/dist/src/tool-result-guard.js +71 -25
  59. package/dist/src/tool-result-guard.js.map +1 -1
  60. package/dist/src/types/events.d.ts +12 -0
  61. package/dist/src/types/events.d.ts.map +1 -1
  62. package/dist/src/types/module.d.ts +12 -4
  63. package/dist/src/types/module.d.ts.map +1 -1
  64. package/dist/src/types/trace.d.ts +14 -3
  65. package/dist/src/types/trace.d.ts.map +1 -1
  66. package/dist/test/discord-awareness-outbox.test.js +3 -0
  67. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  68. package/dist/test/explicit-prose-routing.test.js +0 -2
  69. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  70. package/dist/test/framework.test.js +160 -1
  71. package/dist/test/framework.test.js.map +1 -1
  72. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  73. package/dist/test/helpers/coalescing-fixture.js +3 -2
  74. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  75. package/dist/test/inference-exhaustion-rewind.test.js +142 -14
  76. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  77. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  78. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  79. package/dist/test/inference-failure-bounds.test.js +1201 -0
  80. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  81. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  82. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  83. package/dist/test/late-events-after-stop.test.js +122 -0
  84. package/dist/test/late-events-after-stop.test.js.map +1 -0
  85. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  86. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  87. package/dist/test/mcpl-coalescing.test.js +78 -16
  88. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  89. package/dist/test/mcpl-config-diagnostics.test.js +6 -3
  90. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  91. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  92. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  93. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  94. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  95. package/dist/test/mcpl-reference-fields.test.js +69 -2
  96. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  97. package/dist/test/mcpl-ws-transport.test.js +103 -0
  98. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  99. package/dist/test/ops-alert.test.js +2 -1
  100. package/dist/test/ops-alert.test.js.map +1 -1
  101. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  102. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  103. package/dist/test/placed-reaction-baseline.test.js +312 -0
  104. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  105. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  106. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  107. package/dist/test/provider-waits.test.d.ts +2 -0
  108. package/dist/test/provider-waits.test.d.ts.map +1 -0
  109. package/dist/test/provider-waits.test.js +2196 -0
  110. package/dist/test/provider-waits.test.js.map +1 -0
  111. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  112. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  113. package/dist/test/reaction-baseline-platform.test.js +124 -0
  114. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  115. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  116. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  117. package/dist/test/save-recent-image.test.js +138 -2
  118. package/dist/test/save-recent-image.test.js.map +1 -1
  119. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  120. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  121. package/dist/test/surgery-marks-receipt.test.js +540 -0
  122. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  123. package/dist/test/tool-result-guard.test.js +221 -7
  124. package/dist/test/tool-result-guard.test.js.map +1 -1
  125. package/dist/test/workspace-three-way.test.js +115 -0
  126. package/dist/test/workspace-three-way.test.js.map +1 -1
  127. package/dist/tsconfig.tsbuildinfo +1 -1
  128. package/package.json +1 -1
  129. package/src/agent.ts +4 -1
  130. package/src/api/server.ts +43 -4
  131. package/src/framework.ts +1538 -189
  132. package/src/index.ts +7 -2
  133. package/src/mcpl/index.ts +2 -0
  134. package/src/mcpl/reference-fetcher.ts +18 -8
  135. package/src/mcpl/references.ts +57 -13
  136. package/src/mcpl/server-connection.ts +55 -4
  137. package/src/module-registry.ts +17 -9
  138. package/src/modules/workspace/index.ts +44 -4
  139. package/src/modules/workspace/observe.ts +31 -5
  140. package/src/modules/workspace/reconcile.ts +23 -2
  141. package/src/operator-log.ts +30 -0
  142. package/src/provider-waits.ts +523 -0
  143. package/src/refusal-reactions.ts +14 -12
  144. package/src/tool-result-guard.ts +72 -24
  145. package/src/types/events.ts +12 -0
  146. package/src/types/module.ts +12 -4
  147. package/src/types/trace.ts +14 -3
package/src/index.ts CHANGED
@@ -92,7 +92,7 @@ export type { McplServerConfig } from './mcpl/index.js';
92
92
  // health probes, protocol harnesses — can dial an MCPL server exactly the
93
93
  // way the framework host does, over stdio or WebSocket)
94
94
  export { McplServerConnection } from './mcpl/index.js';
95
- export type { McplHostCapabilities } from './mcpl/index.js';
95
+ export type { McplHostCapabilities, McplConnectionCloseInfo } from './mcpl/index.js';
96
96
 
97
97
  // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host
98
98
  // needs to write McplServerConfig.toolLifecycle policy and class tables, and
@@ -143,7 +143,12 @@ export type {
143
143
  } from './recovery/discord-awareness-outbox.js';
144
144
  // Live operator surgery (rollback / suppress) and its durable action log
145
145
  export { OperatorLog, OperatorActionError, defaultOperatorLogPath } from './operator-log.js';
146
- export type { OperatorLogEntry, OperatorLogInput, OperatorRequester } from './operator-log.js';
146
+ export type {
147
+ OperatorLogEntry,
148
+ OperatorLogInput,
149
+ OperatorRequester,
150
+ SurgeryMarkerReceipt,
151
+ } from './operator-log.js';
147
152
  export { createOfflineRecoveryBranch } from './recovery/offline-branch.js';
148
153
  export type {
149
154
  OfflineRecoveryBranchOptions,
package/src/mcpl/index.ts CHANGED
@@ -107,6 +107,8 @@ export {
107
107
 
108
108
  // Server connection and registry
109
109
  export { McplServerConnection } from './server-connection.js';
110
+ export type { McplConnectionCloseInfo } from './server-connection.js';
111
+ export type { TransportCloseInfo } from './transport.js';
110
112
  export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js';
111
113
 
112
114
  // Feature set management (permission layer)
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { createHash } from 'node:crypto';
31
31
  import type { ReferenceRecord } from './references.js';
32
- import { isReferenceExpired } from './references.js';
32
+ import { EXPIRY_REASON, isReferenceExpired, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel } from './references.js';
33
33
 
34
34
  /** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
35
35
  export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
@@ -79,13 +79,14 @@ export function sniffMime(data: Buffer): string | null {
79
79
  return null;
80
80
  }
81
81
 
82
- const EXT_BY_MIME: Record<string, string> = {
82
+ /** A Map, so a server's type such as `constructor` finds no inherited key. */
83
+ const EXT_BY_MIME = new Map<string, string>(Object.entries({
83
84
  'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
84
85
  'audio/flac': 'flac', 'audio/midi': 'mid', 'audio/ogg': 'ogg',
85
86
  'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
86
87
  'video/mp4': 'mp4', 'application/json': 'json', 'text/plain': 'txt',
87
88
  'application/pdf': 'pdf', 'application/zip': 'zip',
88
- };
89
+ }));
89
90
 
90
91
  /** ws→http / wss→https with default ports elided, for origin comparison. */
91
92
  export function normalizedOrigin(url: string): string | null {
@@ -111,9 +112,11 @@ export class ReferenceFetcher {
111
112
  private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
112
113
  ) {}
113
114
 
114
- /** Would the eager (autofetch) default fetch this record? */
115
+ /** Would the eager (autofetch) default fetch this record? Not one the
116
+ * fetcher refuses as expired, which would only take an eager slot. */
115
117
  isEagerEligible(record: ReferenceRecord): boolean {
116
118
  if (record.fetchedPath) return false;
119
+ if (isReferenceExpired(record.testimony)) return false;
117
120
  if (record.serverId === undefined) return false;
118
121
  const binding = this.resolveServer(record.serverId);
119
122
  if (!binding) return false;
@@ -161,7 +164,8 @@ export class ReferenceFetcher {
161
164
 
162
165
  const refusal = this.originCheck(record, binding);
163
166
  if (refusal) return { ok: false, error: refusal };
164
- if (isReferenceExpired(t)) return { ok: false, error: 'reference expired (or unparseable expiry — fail closed)' };
167
+ const expiry = referenceExpiry(t);
168
+ if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
165
169
 
166
170
  const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
167
171
  // Vector 4: a claim already over the ceiling refuses without dialing.
@@ -215,9 +219,15 @@ export class ReferenceFetcher {
215
219
  digestVerified = true;
216
220
  }
217
221
 
218
- const headerMime = resp.headers.get('content-type')?.split(';')[0].trim() || null;
219
- const observedMime = sniffMime(data) ?? headerMime ?? t.mimeType ?? 'application/octet-stream';
220
- const ext = EXT_BY_MIME[observedMime] ?? EXT_BY_MIME[headerMime ?? ''] ?? EXT_BY_MIME[t.mimeType ?? ''] ?? 'bin';
222
+ // The response's header and the server's claimed type are both the
223
+ // server's words, and either can become fetch_reference's mime_type,
224
+ // the saved type and the record's verified type, so each is shown as a
225
+ // label is. One left empty once sanitized counts as absent.
226
+ const header = resp.headers.get('content-type');
227
+ const headerMime = header !== null ? sanitizeLabel(header.split(';')[0], REFERENCE_LIMITS.mimeType) || null : null;
228
+ const claimedMime = t.mimeType !== undefined ? sanitizeLabel(t.mimeType, REFERENCE_LIMITS.mimeType) || undefined : undefined;
229
+ const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
230
+ const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
221
231
  // §7.3: host-generated storage name; the server's `name` is display-only.
222
232
  const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
223
233
  if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
@@ -13,7 +13,8 @@
13
13
  * - `disposition:"never"` withholds payload AND uri from model context
14
14
  * unconditionally — enforced in code here, not by caller discipline (§5);
15
15
  * - stub size is independent of every server-supplied field length (§5);
16
- * - unparseable `expiresAt` fails closed (§7.4);
16
+ * - unparseable `expiresAt` fails closed (§7.4), and so does one rejected
17
+ * as a field: it's absent as testimony, never read as immortal;
17
18
  * - unknown block types never propagate `undefined` (the pre-existing
18
19
  * convertBlock switches had no default — a latent corruption this module
19
20
  * retires).
@@ -141,16 +142,42 @@ export function isInlineContradiction(block: unknown): boolean {
141
142
  export const INLINE_WITHHELD_TEXT =
142
143
  '[inline content withheld: nonconformant bulk disposition on inline data]';
143
144
 
144
- export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt'>, nowMs = Date.now()): boolean {
145
- if (t.expiresAt === undefined) return false;
145
+ /**
146
+ * Why a reference counts as expired, or null when it doesn't: its expiresAt
147
+ * has passed, doesn't parse, or was rejected as a field (§7.4, §8). A
148
+ * rejected expiry (past its schema maximum, or not a string, null included)
149
+ * is absent as testimony under the one invalid-field rule, but reading that
150
+ * absence as "never expires" would let field invalidity widen exposure:
151
+ * like an unparseable one, it fails closed. A reference that claims no
152
+ * expiry has none.
153
+ */
154
+ export function referenceExpiry(
155
+ t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now(),
156
+ ): 'rejected' | 'unparseable' | 'passed' | null {
157
+ if (t.rejectedFields.includes('expiresAt')) return 'rejected';
158
+ if (t.expiresAt === undefined) return null;
146
159
  const exp = Date.parse(t.expiresAt);
147
- return Number.isNaN(exp) ? true : exp <= nowMs;
160
+ if (Number.isNaN(exp)) return 'unparseable';
161
+ return exp <= nowMs ? 'passed' : null;
162
+ }
163
+
164
+ export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now()): boolean {
165
+ return referenceExpiry(t, nowMs) !== null;
148
166
  }
149
167
 
150
- /** Strip C0/C1 controls plus bidi marks/overrides/isolates; bound length.
168
+ /** Each expiry case as the stub and the fetcher's refusal both say it. */
169
+ export const EXPIRY_REASON = {
170
+ passed: 'expired: its expiresAt has passed',
171
+ unparseable: 'treated as expired: its expiresAt does not parse (fail closed)',
172
+ rejected: `treated as expired: its expiresAt was rejected as a field, over ${REFERENCE_LIMITS.expiresAt} characters or not a string (fail closed)`,
173
+ } as const;
174
+
175
+ /** Strip control characters (C0, DEL, C1) and every bidi control (marks,
176
+ * embeddings, overrides, isolates, and the Arabic letter mark), trim the
177
+ * whitespace (a BOM included) left at either end, and bound the length.
151
178
  * Labels are labels — storage naming is host-generated elsewhere (§7.3). */
152
179
  export function sanitizeLabel(s: string, maxChars: number): string {
153
- const cleaned = s.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
180
+ const cleaned = s.replace(/[\p{Cc}\p{Bidi_Control}]/gu, '').trim();
154
181
  if (cleaned.length <= maxChars) return cleaned;
155
182
  let cut = cleaned.slice(0, Math.max(0, maxChars - 1));
156
183
  // never split a surrogate pair at the truncation boundary
@@ -187,9 +214,10 @@ const MAX_RECORDS = 2000;
187
214
  /**
188
215
  * Host-private store of received references. Raw URIs live here and in stubs
189
216
  * never (under `never`) or by policy only (`ref`/absent). Ids are stable and
190
- * never reused within the process (vector 20); eviction is FIFO past
191
- * MAX_RECORDS and a stale lookup returns undefined — a defined miss, never a
192
- * different record.
217
+ * never reused within the process (vector 20). Past MAX_RECORDS the record
218
+ * registered least recently is evicted (registering a uri the registry holds
219
+ * makes its record the newest), and a stale lookup returns undefined — a
220
+ * defined miss, never a different record.
193
221
  *
194
222
  * Process-wide singleton by design: ids are host-local *names*, not
195
223
  * capabilities, and the record itself carries serverId for any future
@@ -222,6 +250,9 @@ export class ReferenceRegistry {
222
250
  // describes verified bytes on disk and keeps the testimony they were
223
251
  // verified against.
224
252
  if (!existing.fetchedPath) existing.testimony = testimony;
253
+ // Newest again, so a uri just mentioned is the last to be evicted.
254
+ this.records.delete(existingId);
255
+ this.records.set(existingId, existing);
225
256
  return existing;
226
257
  }
227
258
  }
@@ -257,6 +288,12 @@ export const referenceRegistry = new ReferenceRegistry();
257
288
  // Stub building (§5)
258
289
  // ============================================================================
259
290
 
291
+ /** A reference's type as its stub shows one, or '' when nothing is left of
292
+ * it once sanitized (the stub then leaves it out). */
293
+ export function referenceTypeLabel(mimeType: string | undefined): string {
294
+ return mimeType ? sanitizeLabel(mimeType, STUB_FIELD_CHARS) : '';
295
+ }
296
+
260
297
  /**
261
298
  * Build the model-visible stub for a reference. Output length is bounded by
262
299
  * host constants and independent of every server-supplied field length.
@@ -267,18 +304,25 @@ export const referenceRegistry = new ReferenceRegistry();
267
304
  export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string {
268
305
  const record = referenceRegistry.register(t, serverId);
269
306
  const parts: string[] = [];
270
- if (t.name) parts.push(sanitizeLabel(t.name, STUB_FIELD_CHARS));
307
+ // A label is shown only when something is left of it once sanitized.
308
+ const name = t.name ? sanitizeLabel(t.name, STUB_FIELD_CHARS) : '';
309
+ if (name) parts.push(name);
271
310
  const meta: string[] = [];
272
- if (t.mimeType) meta.push(sanitizeLabel(t.mimeType, STUB_FIELD_CHARS));
311
+ const mimeType = referenceTypeLabel(t.mimeType);
312
+ if (mimeType) meta.push(mimeType);
273
313
  if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`);
274
314
  if (meta.length) parts.push(meta.join(', '));
275
- if (provenance) parts.push(sanitizeLabel(provenance, STUB_FIELD_CHARS));
315
+ const origin = provenance ? sanitizeLabel(provenance, STUB_FIELD_CHARS) : '';
316
+ if (origin) parts.push(origin);
276
317
  if (record.fetchedPath) {
277
318
  // Already materialized by the host-mediated fetcher: hand the model the
278
319
  // workspace path (host-generated, §7.3) so existing tools just work.
279
320
  parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
280
321
  } else {
281
- parts.push('fetch with fetch_reference');
322
+ // An expired reference says so rather than inviting a fetch_reference
323
+ // call the fetcher can only refuse.
324
+ const expiry = referenceExpiry(record.testimony);
325
+ parts.push(expiry ? EXPIRY_REASON[expiry] : 'fetch with fetch_reference');
282
326
  }
283
327
  return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`;
284
328
  }
@@ -48,6 +48,28 @@ import type {
48
48
  import { McplMethod } from './types.js';
49
49
  import type { ToolLifecycleParams, ToolObserveRule } from './tool-lifecycle.js';
50
50
 
51
+ /**
52
+ * Payload of the connection `'close'` event (third argument; the first two
53
+ * stay `(code, signal)` for existing listeners). Lets a host see WHY the
54
+ * link went down — in particular a WebSocket close code such as 4001 from
55
+ * a server that refused or expired the credential — so it can refresh what
56
+ * `accessProvider` will hand the next dial instead of replaying it.
57
+ */
58
+ export interface McplConnectionCloseInfo {
59
+ /** Transport kind that closed; null when the connection never had one
60
+ * (a disconnected stub torn down before its first reconnect). */
61
+ transport: McplTransport['kind'] | null;
62
+ /** WebSocket close code (e.g. 4001) or stdio child exit code; null when unknown. */
63
+ code: number | null;
64
+ /** Signal that ended a stdio child; null otherwise. */
65
+ signal: NodeJS.Signals | null;
66
+ /** WebSocket close reason, or a short description of the cause. */
67
+ reason?: string;
68
+ /** True when the host closed the link (`close()` / `reconnectAfterFailure()`),
69
+ * false when the transport dropped unexpectedly. */
70
+ byHost: boolean;
71
+ }
72
+
51
73
  /** Timeout for the initialize handshake in milliseconds.
52
74
  * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
53
75
  const INITIALIZE_TIMEOUT_MS = 30_000;
@@ -93,7 +115,7 @@ interface RequestOptions {
93
115
  * - `'channels-changed'` — Server sent `channels/changed`
94
116
  * - `'channels-incoming'` — Server sent `channels/incoming`
95
117
  * - `'error'` — Connection-level error
96
- * - `'close'` — Connection closed
118
+ * - `'close'` — Connection closed: `(code, signal, info: McplConnectionCloseInfo)`
97
119
  * - `'connect-failed'` — Initial connect failed; background retry scheduled
98
120
  * - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
99
121
  * - `'reconnect'` — Background reconnect succeeded
@@ -209,6 +231,9 @@ export class McplServerConnection extends EventEmitter {
209
231
  * observable even though its original promise has already been rejected. */
210
232
  private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
211
233
  private closed = false;
234
+ /** Set by `reconnectAfterFailure()` before it closes a live transport, so
235
+ * the transport close handler reports that teardown as `byHost: true`. */
236
+ private hostCloseRequested = false;
212
237
  private closePromise: Promise<void> | null = null;
213
238
  private closeIncomplete = false;
214
239
  private reconnectPromise: Promise<void> | null = null;
@@ -864,7 +889,7 @@ export class McplServerConnection extends EventEmitter {
864
889
  if (this.reconnectPromise) await this.reconnectPromise;
865
890
  if (this.transport) await this.transport.close();
866
891
  this.closeIncomplete = false;
867
- this.emit('close');
892
+ this.emit('close', null, null, this.closeInfoByHost());
868
893
  });
869
894
  this.closePromise = attempt;
870
895
  void attempt.catch(() => {
@@ -886,14 +911,26 @@ export class McplServerConnection extends EventEmitter {
886
911
  return;
887
912
  }
888
913
  if (this.transport) {
914
+ this.hostCloseRequested = true;
889
915
  await this.transport.close();
890
916
  return;
891
917
  }
892
918
  this.closed = true;
893
- this.emit('close');
919
+ this.emit('close', null, null, this.closeInfoByHost());
894
920
  this.scheduleReconnect();
895
921
  }
896
922
 
923
+ /** 'close' payload for a host-initiated teardown (no transport close code). */
924
+ private closeInfoByHost(): McplConnectionCloseInfo {
925
+ return {
926
+ transport: this.transport?.kind ?? null,
927
+ code: null,
928
+ signal: null,
929
+ reason: 'closed by host',
930
+ byHost: true,
931
+ };
932
+ }
933
+
897
934
  /**
898
935
  * Schedule a background reconnection attempt.
899
936
  *
@@ -1285,6 +1322,7 @@ export class McplServerConnection extends EventEmitter {
1285
1322
  * and — when enabled — schedules a background reconnect.
1286
1323
  */
1287
1324
  private setupLifecycle(transport: McplTransport): void {
1325
+ this.hostCloseRequested = false;
1288
1326
  transport.on('error', (err: Error) => {
1289
1327
  this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
1290
1328
  });
@@ -1306,7 +1344,20 @@ export class McplServerConnection extends EventEmitter {
1306
1344
  this.pendingRequests.clear();
1307
1345
  this.orphanedRequests.clear();
1308
1346
 
1309
- this.emit('close', info.code ?? null, info.signal ?? null);
1347
+ // Forward the transport's close info so listeners can tell a server
1348
+ // that refused the credential (WebSocket 4001) from a crash, and act
1349
+ // before the reconnect below redials. A teardown the host asked for
1350
+ // via reconnectAfterFailure() arrives here too; report it as such.
1351
+ const byHost = this.hostCloseRequested;
1352
+ this.hostCloseRequested = false;
1353
+ const closeInfo: McplConnectionCloseInfo = {
1354
+ transport: transport.kind,
1355
+ code: info.code ?? null,
1356
+ signal: info.signal ?? null,
1357
+ reason: info.reason ?? (byHost ? 'closed by host' : undefined),
1358
+ byHost,
1359
+ };
1360
+ this.emit('close', closeInfo.code, closeInfo.signal, closeInfo);
1310
1361
 
1311
1362
  // Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
1312
1363
  if (this.reconnectEnabled) {
@@ -26,6 +26,12 @@ import type {
26
26
  import type { Agent } from './agent.js';
27
27
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
28
28
 
29
+ /** How the registry queues a module's event: its push names the module. A
30
+ * `ProcessQueue` serves too, ignoring the name. */
31
+ export interface ModuleQueue {
32
+ push(event: ProcessEvent, moduleName?: string): void;
33
+ }
34
+
29
35
  const MODULE_STATE_PREFIX = 'modules/';
30
36
 
31
37
  /**
@@ -78,7 +84,7 @@ export class ModuleRegistry {
78
84
  private moduleContexts: Map<string, ModuleContextImpl> = new Map();
79
85
  private speechHandlers: SpeechHandler[] = [];
80
86
  private store: JsStore;
81
- private queue: ProcessQueue;
87
+ private queue: ModuleQueue;
82
88
  private getAgents: () => Agent[];
83
89
  private addMessageFn: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
84
90
  private editMessageFn: (id: MessageId, content: ContentBlock[]) => void;
@@ -86,7 +92,7 @@ export class ModuleRegistry {
86
92
  private getMessageFn: (id: MessageId) => StoredMessage | null;
87
93
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
88
94
 
89
- private pushEventFn: (event: ProcessEvent) => void;
95
+ private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
90
96
  private onTraceFn: (listener: TraceEventListener) => () => void;
91
97
  private callToolFn: (call: ToolCall) => Promise<ToolResult>;
92
98
  /** Framework ops-alert channel, surfaced to modules via
@@ -101,7 +107,7 @@ export class ModuleRegistry {
101
107
 
102
108
  constructor(
103
109
  store: JsStore,
104
- queue: ProcessQueue,
110
+ queue: ModuleQueue,
105
111
  options: {
106
112
  getAgents: () => Agent[];
107
113
  addMessage: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
@@ -109,7 +115,8 @@ export class ModuleRegistry {
109
115
  removeMessage: (id: MessageId) => void;
110
116
  getMessage: (id: MessageId) => StoredMessage | null;
111
117
  queryMessages: (filter: MessageQuery) => MessageQueryResult;
112
- pushEvent: (event: ProcessEvent) => void;
118
+ /** Called with the pushing module's name. */
119
+ pushEvent: (event: ProcessEvent, moduleName?: string) => void;
113
120
  onTrace: (listener: TraceEventListener) => () => void;
114
121
  callTool: (call: ToolCall) => Promise<ToolResult>;
115
122
  notifyOps?: (kind: string, agentName: string, message: string, data?: Record<string, unknown>) => void;
@@ -537,7 +544,7 @@ class ModuleContextImpl implements ModuleContext {
537
544
  private removeMessageFn: (id: MessageId) => void;
538
545
  private getMessageFn: (id: MessageId) => StoredMessage | null;
539
546
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
540
- private pushEventFn: (event: ProcessEvent) => void;
547
+ private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
541
548
  private onTraceFn: (listener: TraceEventListener) => () => void;
542
549
  private callToolFn: (call: ToolCall) => Promise<ToolResult>;
543
550
 
@@ -548,7 +555,7 @@ class ModuleContextImpl implements ModuleContext {
548
555
  moduleName: string,
549
556
  store: JsStore,
550
557
  stateId: string,
551
- queue: ProcessQueue,
558
+ queue: ModuleQueue,
552
559
  registry: ModuleRegistry,
553
560
  isRestart: boolean,
554
561
  getAgents: () => Agent[],
@@ -557,14 +564,15 @@ class ModuleContextImpl implements ModuleContext {
557
564
  removeMessage: (id: MessageId) => void,
558
565
  getMessage: (id: MessageId) => StoredMessage | null,
559
566
  queryMessages: (filter: MessageQuery) => MessageQueryResult,
560
- pushEvent: (event: ProcessEvent) => void,
567
+ pushEvent: (event: ProcessEvent, moduleName?: string) => void,
561
568
  onTrace: (listener: TraceEventListener) => () => void,
562
569
  callTool: (call: ToolCall) => Promise<ToolResult>
563
570
  ) {
564
571
  this.moduleName = moduleName;
565
572
  this.store = store;
566
573
  this.stateId = stateId;
567
- this.queue = queue;
574
+ // The deprecated handle: a push names this module, as pushEvent() does.
575
+ this.queue = { push: (event) => queue.push(event, moduleName) };
568
576
  this.registry = registry;
569
577
  this.isRestart = isRestart;
570
578
  this.getAgentsFn = getAgents;
@@ -747,7 +755,7 @@ class ModuleContextImpl implements ModuleContext {
747
755
  }
748
756
 
749
757
  pushEvent(event: ProcessEvent): void {
750
- this.pushEventFn(event);
758
+ this.pushEventFn(event, this.moduleName);
751
759
  }
752
760
 
753
761
  private persistExternalIdMap(): void {
@@ -39,7 +39,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
39
39
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
40
40
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
41
41
  import { BranchIntents } from './branch-intent.js';
42
- import { filesystemTrustsCtime, locateRoot } from './observe.js';
42
+ import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
43
43
  import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
44
44
  import {
45
45
  type MountRuntime,
@@ -1220,7 +1220,14 @@ export class WorkspaceModule implements Module {
1220
1220
  + 'A mount whose root is not the directory disk last agreed with (an unmounted drive, a replaced '
1221
1221
  + 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
1222
1222
  + 'directory replaced on purpose, or a drive back under a new device number, never for one still '
1223
- + 'unplugged, whose empty mountpoint would become the root.',
1223
+ + 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
1224
+ + 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
1225
+ + 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
1226
+ + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1227
+ + 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
1228
+ + 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
1229
+ + 'sets aside the patterns that cover it or a directory above it; one that covers only something '
1230
+ + 'inside it still applies, and what it covers is passed over.',
1224
1231
  inputSchema: {
1225
1232
  type: 'object' as const,
1226
1233
  properties: {
@@ -2313,7 +2320,9 @@ export class WorkspaceModule implements Module {
2313
2320
  };
2314
2321
  }
2315
2322
 
2316
- return { success: true, data: { path: input.path, deleted: true } };
2323
+ // Its whole receipt is what it changed: kept in the stub if the guard
2324
+ // withholds the result (agent-framework #277).
2325
+ return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2317
2326
  });
2318
2327
  }
2319
2328
 
@@ -2761,6 +2770,10 @@ export class WorkspaceModule implements Module {
2761
2770
  : {}),
2762
2771
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
2763
2772
  },
2773
+ // What disk lost, then what it was written: kept in the stub if the
2774
+ // guard withholds the result (agent-framework #277). What was left or
2775
+ // skipped, and why, is in the full result.
2776
+ keepWhenWithheld: ['deleted', 'materialized'],
2764
2777
  };
2765
2778
  }
2766
2779
 
@@ -2811,6 +2824,8 @@ export class WorkspaceModule implements Module {
2811
2824
  }> = [];
2812
2825
  const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
2813
2826
  const allIncomplete: Array<{ mount: string; path: string; reason: string }> = [];
2827
+ const allPassedOver: Array<{ mount: string; reason: string; count: number }> = [];
2828
+ const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
2814
2829
  const rootsAccepted: string[] = [];
2815
2830
  if (input.acceptRoot && input.path) {
2816
2831
  return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
@@ -2845,7 +2860,9 @@ export class WorkspaceModule implements Module {
2845
2860
  store.treeList(mount.treeStateId, prefix).length > 0 ||
2846
2861
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
2847
2862
  this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
2848
- const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn };
2863
+ // named: ignore patterns covering the path asked for don't apply
2864
+ // beneath it (a directory scope's walk; a path scope doesn't walk).
2865
+ const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
2849
2866
  if (!isDir) return this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
2850
2867
  const beneath = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }, opts);
2851
2868
  // Something at the path itself is no part of a directory scope: a file
@@ -2860,12 +2877,15 @@ export class WorkspaceModule implements Module {
2860
2877
  || this.intents.get(name)!.get(relativePath) !== null;
2861
2878
  if (!atPath) return beneath;
2862
2879
  const itself = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
2880
+ const passedOver = new Map(beneath.passedOver);
2881
+ for (const [reason, count] of itself.passedOver) passedOver.set(reason, (passedOver.get(reason) ?? 0) + count);
2863
2882
  return {
2864
2883
  ops: [...beneath.ops, ...itself.ops],
2865
2884
  discarded: [...beneath.discarded, ...itself.discarded],
2866
2885
  newConflicts: [...beneath.newConflicts, ...itself.newConflicts],
2867
2886
  reports: new Map([...beneath.reports, ...itself.reports]),
2868
2887
  incomplete: [...beneath.incomplete, ...itself.incomplete],
2888
+ passedOver,
2869
2889
  dirs: beneath.dirs,
2870
2890
  branchId: itself.branchId ?? beneath.branchId,
2871
2891
  };
@@ -2892,6 +2912,17 @@ export class WorkspaceModule implements Module {
2892
2912
  }
2893
2913
  }
2894
2914
  for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
2915
+ for (const [reason, count] of pass.passedOver) allPassedOver.push({ mount: name, reason, count });
2916
+ // A path named explicitly is synced even where the mount's ignore list
2917
+ // covers it, and the result says so: a sync without a path passes
2918
+ // over it, so nothing else keeps it current (agent-framework #276).
2919
+ if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
2920
+ allIgnored.push({
2921
+ mount: name,
2922
+ path: relativePath,
2923
+ note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
2924
+ });
2925
+ }
2895
2926
  }
2896
2927
 
2897
2928
  return {
@@ -2902,8 +2933,17 @@ export class WorkspaceModule implements Module {
2902
2933
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
2903
2934
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
2904
2935
  ...(allIncomplete.length > 0 ? { incomplete: allIncomplete } : {}),
2936
+ ...(allPassedOver.length > 0 ? { passedOver: allPassedOver } : {}),
2937
+ ...(allIgnored.length > 0 ? { ignored: allIgnored } : {}),
2905
2938
  ...(rootsAccepted.length > 0 ? { rootsAccepted } : {}),
2906
2939
  },
2940
+ // What the workspace took from disk, gave up, or now holds as a
2941
+ // conflict, per mount, and any root it accepted: kept in the stub if
2942
+ // the guard withholds the result (agent-framework #277). What the scan
2943
+ // skipped or couldn't see is in the full result.
2944
+ // A root it accepted is short and telling: first, so a long results
2945
+ // list can't cut it away.
2946
+ keepWhenWithheld: ['rootsAccepted', 'results'],
2907
2947
  };
2908
2948
  }
2909
2949
 
@@ -494,7 +494,9 @@ export async function observePath(view: MountView, rootReal: string, relativePat
494
494
  return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
495
495
  }
496
496
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
497
- if (!target.isFile()) return { kind: 'unobserved', reason: 'not a regular file' };
497
+ if (!target.isFile()) {
498
+ return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
499
+ }
498
500
  info = target;
499
501
  } else if (info.isDirectory()) {
500
502
  return { kind: 'unobserved', reason: 'a directory' };
@@ -682,12 +684,27 @@ export interface Walk {
682
684
  missing: Set<string>;
683
685
  /**
684
686
  * Where the walk didn't reach, and why. `error` and `cap` regions may hide
685
- * files the listing would show; `ignored` and `symlink` ones matter only
686
- * where tracked entries lie beneath them.
687
+ * files the listing would show. An `ignored` region is passed over by
688
+ * design: one that hides a tracked entry is reported as incomplete, and
689
+ * one that hides nothing is counted as passed over. A `symlink` region is
690
+ * never walked; the pass reports the link itself by path.
687
691
  */
688
692
  incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
689
693
  }
690
694
 
695
+ /**
696
+ * Whether a mount's ignore list covers a mount-relative path: the path itself
697
+ * or any directory above it, as the walk's own test reads each entry by its
698
+ * path and name.
699
+ */
700
+ export function coveredByIgnore(relativePath: string, patterns: string[]): boolean {
701
+ const segments = relativePath.split('/').filter(Boolean);
702
+ for (let i = 0; i < segments.length; i++) {
703
+ if (isIgnored(segments.slice(0, i + 1).join('/'), segments[i]!, patterns)) return true;
704
+ }
705
+ return false;
706
+ }
707
+
691
708
  /** Simple ignore patterns, as the watcher and the old walk read them. */
692
709
  export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean {
693
710
  for (const pattern of patterns) {
@@ -729,7 +746,10 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
729
746
  * the directory at its path: a directory moved or replaced meanwhile is an
730
747
  * incomplete region, contributing no names and proving nothing absent.
731
748
  */
732
- export async function walkScope(view: MountView, rootReal: string, scope: string, recursive: boolean, cap: number): Promise<Walk> {
749
+ export async function walkScope(
750
+ view: MountView, rootReal: string, scope: string, recursive: boolean, cap: number,
751
+ opts: { named?: boolean } = {},
752
+ ): Promise<Walk> {
733
753
  const walk: Walk = { files: new Set(), dirs: new Set(), others: new Map(), complete: new Set(), missing: new Set(), incomplete: [] };
734
754
  const top = await checkDir(view, rootReal, scope);
735
755
  if (top.kind === 'absent') {
@@ -741,6 +761,12 @@ export async function walkScope(view: MountView, rootReal: string, scope: string
741
761
  return walk;
742
762
  }
743
763
  let capped = false;
764
+ // A scope a path sync names is walked even where the ignore list covers
765
+ // it: a pattern covering the scope itself, or a directory above it, doesn't
766
+ // apply beneath it. So a named directory's contents are synced, as a named
767
+ // file is (agent-framework #276). A listing keeps the ignore: its pass
768
+ // would otherwise start tracking what it lists.
769
+ const ignore = opts.named ? view.ignore.filter((pattern) => !coveredByIgnore(scope, [pattern])) : view.ignore;
744
770
 
745
771
  const visit = async (dir: CheckedDir): Promise<void> => {
746
772
  const rel = dir.rel;
@@ -770,7 +796,7 @@ export async function walkScope(view: MountView, rootReal: string, scope: string
770
796
  let reachedCap = false;
771
797
  for (const entry of entries) {
772
798
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
773
- if (isIgnored(childRel, entry.name, view.ignore)) {
799
+ if (isIgnored(childRel, entry.name, ignore)) {
774
800
  others.push([childRel, 'ignored by the mount']);
775
801
  regions.push({ path: childRel, reason: 'ignored by the mount', kind: 'ignored' });
776
802
  continue;