@animalabs/agent-framework 0.11.0 → 0.13.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 (160) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +60 -4
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +72 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +873 -123
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +7 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +15 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/kv-unified-wire.d.ts +16 -0
  14. package/dist/src/kv-unified-wire.d.ts.map +1 -0
  15. package/dist/src/kv-unified-wire.js +2 -0
  16. package/dist/src/kv-unified-wire.js.map +1 -0
  17. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +188 -5
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  22. package/dist/src/mcpl/hook-orchestrator.js +28 -2
  23. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  24. package/dist/src/mcpl/push-handler.d.ts +6 -1
  25. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  26. package/dist/src/mcpl/push-handler.js +29 -2
  27. package/dist/src/mcpl/push-handler.js.map +1 -1
  28. package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
  29. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
  30. package/dist/src/mcpl/reference-fetcher.js +240 -0
  31. package/dist/src/mcpl/reference-fetcher.js.map +1 -0
  32. package/dist/src/mcpl/references.d.ts +125 -0
  33. package/dist/src/mcpl/references.d.ts.map +1 -0
  34. package/dist/src/mcpl/references.js +262 -0
  35. package/dist/src/mcpl/references.js.map +1 -0
  36. package/dist/src/mcpl/types.d.ts +32 -0
  37. package/dist/src/mcpl/types.d.ts.map +1 -1
  38. package/dist/src/mcpl/types.js.map +1 -1
  39. package/dist/src/modules/index.d.ts +2 -1
  40. package/dist/src/modules/index.d.ts.map +1 -1
  41. package/dist/src/modules/index.js +1 -1
  42. package/dist/src/modules/index.js.map +1 -1
  43. package/dist/src/modules/workspace/index.d.ts +125 -2
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +354 -58
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +7 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/tool-result-history.d.ts.map +1 -1
  51. package/dist/src/tool-result-history.js +14 -0
  52. package/dist/src/tool-result-history.js.map +1 -1
  53. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  54. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  55. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  56. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  57. package/dist/src/tune-out/coordinator.d.ts +144 -0
  58. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  59. package/dist/src/tune-out/coordinator.js +444 -0
  60. package/dist/src/tune-out/coordinator.js.map +1 -0
  61. package/dist/src/tune-out/tools.d.ts +43 -0
  62. package/dist/src/tune-out/tools.d.ts.map +1 -0
  63. package/dist/src/tune-out/tools.js +93 -0
  64. package/dist/src/tune-out/tools.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +15 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +6 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/usage-accounting.d.ts +5 -0
  70. package/dist/src/usage-accounting.d.ts.map +1 -0
  71. package/dist/src/usage-accounting.js +11 -0
  72. package/dist/src/usage-accounting.js.map +1 -0
  73. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  74. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  75. package/dist/test/channel-incoming-targeting.test.js +81 -0
  76. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  77. package/dist/test/conversation-routing.test.js +39 -0
  78. package/dist/test/conversation-routing.test.js.map +1 -1
  79. package/dist/test/kv-unified-wiring.test.d.ts +2 -0
  80. package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
  81. package/dist/test/kv-unified-wiring.test.js +56 -0
  82. package/dist/test/kv-unified-wiring.test.js.map +1 -0
  83. package/dist/test/mcpl-push-convert.test.d.ts +2 -0
  84. package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
  85. package/dist/test/mcpl-push-convert.test.js +42 -0
  86. package/dist/test/mcpl-push-convert.test.js.map +1 -0
  87. package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
  88. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
  89. package/dist/test/mcpl-reference-fetcher.test.js +199 -0
  90. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
  91. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  92. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  93. package/dist/test/per-agent-delivery.test.js +108 -0
  94. package/dist/test/per-agent-delivery.test.js.map +1 -0
  95. package/dist/test/present-while-acting.test.js +64 -0
  96. package/dist/test/present-while-acting.test.js.map +1 -1
  97. package/dist/test/tool-result-history.test.js +78 -0
  98. package/dist/test/tool-result-history.test.js.map +1 -1
  99. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  100. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  101. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  102. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  103. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  104. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  105. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  106. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  107. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  108. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  109. package/dist/test/tune-out-e2e.test.js +142 -0
  110. package/dist/test/tune-out-e2e.test.js.map +1 -0
  111. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  112. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  113. package/dist/test/tune-out-gate-composition.test.js +221 -0
  114. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  115. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  116. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  117. package/dist/test/tune-out-lifecycle.test.js +121 -0
  118. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  119. package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
  120. package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
  121. package/dist/test/turn-trigger-provenance.test.js +85 -0
  122. package/dist/test/turn-trigger-provenance.test.js.map +1 -0
  123. package/dist/test/usage-accounting.test.d.ts +2 -0
  124. package/dist/test/usage-accounting.test.d.ts.map +1 -0
  125. package/dist/test/usage-accounting.test.js +13 -0
  126. package/dist/test/usage-accounting.test.js.map +1 -0
  127. package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
  128. package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
  129. package/dist/test/workspace-branch-continuation.test.js +183 -0
  130. package/dist/test/workspace-branch-continuation.test.js.map +1 -0
  131. package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
  132. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
  133. package/dist/test/workspace-read-file-from-disk.test.js +174 -0
  134. package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
  135. package/dist/test/workspace-read-image.test.js +8 -0
  136. package/dist/test/workspace-read-image.test.js.map +1 -1
  137. package/dist/test/workspace-restore-order.test.js +1 -1
  138. package/dist/test/workspace-restore-order.test.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +2 -2
  141. package/src/agent.ts +77 -4
  142. package/src/framework.ts +953 -128
  143. package/src/gate/event-gate.ts +28 -3
  144. package/src/kv-unified-wire.ts +12 -0
  145. package/src/mcpl/channel-registry.ts +264 -8
  146. package/src/mcpl/hook-orchestrator.ts +29 -2
  147. package/src/mcpl/push-handler.ts +30 -2
  148. package/src/mcpl/reference-fetcher.ts +240 -0
  149. package/src/mcpl/references.ts +292 -0
  150. package/src/mcpl/types.ts +29 -0
  151. package/src/modules/index.ts +2 -1
  152. package/src/modules/workspace/index.ts +425 -66
  153. package/src/modules/workspace/types.ts +7 -0
  154. package/src/tool-result-history.ts +11 -0
  155. package/src/tool-wrapper-prose-guard.ts +38 -0
  156. package/src/tune-out/coordinator.ts +548 -0
  157. package/src/tune-out/tools.ts +129 -0
  158. package/src/types/agent.ts +16 -0
  159. package/src/types/framework.ts +7 -0
  160. package/src/usage-accounting.ts +8 -0
@@ -18,6 +18,30 @@ class WorkspaceImageReadError extends Error {
18
18
  this.name = 'WorkspaceImageReadError';
19
19
  }
20
20
  }
21
+ /**
22
+ * Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
23
+ * mount-prefixed path only — never absolute filesystem paths — so they can be
24
+ * surfaced to the agent or logged without leaking host layout.
25
+ */
26
+ export class WorkspaceReadError extends Error {
27
+ code;
28
+ stage;
29
+ errno;
30
+ constructor(code, message, stage,
31
+ /** Underlying errno code (e.g. 'ENOENT', 'ELOOP') when a syscall failed. */
32
+ errno) {
33
+ super(message);
34
+ this.code = code;
35
+ this.stage = stage;
36
+ this.errno = errno;
37
+ this.name = 'WorkspaceReadError';
38
+ }
39
+ }
40
+ function errnoOf(err) {
41
+ return typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string'
42
+ ? err.code
43
+ : undefined;
44
+ }
21
45
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
22
46
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
23
47
  const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
@@ -204,9 +228,13 @@ function validateJpeg(bytes, mountPrefixedPath) {
204
228
  const marker = bytes[offset];
205
229
  offset += 1;
206
230
  if (marker === JPEG_EOI) {
207
- if (!sawSof || !sawSos || offset !== bytes.length) {
231
+ if (!sawSof || !sawSos) {
208
232
  invalidImage('JPEG', mountPrefixedPath);
209
233
  }
234
+ // Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
235
+ // camera stills) pad each frame to a 4-byte boundary with NULs after
236
+ // the EOI marker, and every mainstream decoder stops at EOI. The image
237
+ // proper (SOI..EOI) has been fully validated by this point.
210
238
  return;
211
239
  }
212
240
  if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
@@ -516,6 +544,89 @@ export class WorkspaceModule {
516
544
  }
517
545
  }
518
546
  this.savedState = null;
547
+ // Absorb out-of-band branch switches (e.g. an offline repair that left
548
+ // the store on a child branch): if the pinned branch is a strict ancestor
549
+ // of the current branch and disk state is fully contained in the current
550
+ // branch's history, re-pin. Without this, every materialize refuses
551
+ // forever after a repair, even though nothing on disk can be clobbered.
552
+ this.healBranchPins();
553
+ }
554
+ /**
555
+ * True when everything last materialized to disk for a mount is part of the
556
+ * CURRENT branch's history — i.e. the current branch is a linear
557
+ * continuation of the pinned branch as of the pinned sequence.
558
+ *
559
+ * Walks the current branch's parent chain. Safe iff the pinned branch is on
560
+ * the chain AND every fork point along the way is at or after `pinnedSeq`
561
+ * (a fork before `pinnedSeq` means disk holds records the current branch
562
+ * never had — genuine divergence). A missing/GC'd intermediate branch or a
563
+ * child without fork metadata is treated as unsafe: ancestry can't be
564
+ * proven, so the guard stays closed and `force` is the escape hatch.
565
+ */
566
+ isLinearContinuation(store, pinnedBranchId, pinnedSeq) {
567
+ const current = store.currentBranch();
568
+ if (current.id === pinnedBranchId)
569
+ return true;
570
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
571
+ const visited = new Set();
572
+ let cursor = byId.get(current.id);
573
+ while (cursor && !visited.has(cursor.id)) {
574
+ visited.add(cursor.id);
575
+ if (cursor.id === pinnedBranchId)
576
+ return true;
577
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined)
578
+ return false;
579
+ if (cursor.branchPoint < pinnedSeq)
580
+ return false;
581
+ cursor = byId.get(cursor.parentId);
582
+ }
583
+ return false;
584
+ }
585
+ /**
586
+ * Why materializing this mount is blocked on the current branch, or null if
587
+ * it isn't. Single source of truth for the materialize guard AND the
588
+ * `canMaterialize` status field, so status can never report `true` for a
589
+ * mount that materialize would refuse (the pre-fix defect: status computed
590
+ * per-mount id-equality while the guard checked ALL mounts).
591
+ */
592
+ mountMaterializeBlockReason(store, mount) {
593
+ if (this.config.materializeOnlyActiveBranch === false)
594
+ return null;
595
+ if (!mount.lastMaterializedBranchId)
596
+ return null;
597
+ const current = store.currentBranch();
598
+ if (mount.lastMaterializedBranchId === current.id)
599
+ return null;
600
+ if (this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
601
+ return null;
602
+ }
603
+ const pinned = store.listBranches().find((b) => b.id === mount.lastMaterializedBranchId);
604
+ const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
605
+ return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
606
+ `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
607
+ `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`);
608
+ }
609
+ /**
610
+ * Re-pin mounts whose last-materialized branch is a proven ancestor of the
611
+ * current branch. Runs after persisted state is applied on restart, so an
612
+ * out-of-band branch switch (offline repair/treatment branches) heals at
613
+ * boot instead of wedging materialize until manual surgery.
614
+ */
615
+ healBranchPins() {
616
+ const store = this.store;
617
+ if (!store)
618
+ return;
619
+ const current = store.currentBranch();
620
+ for (const mount of this.mounts.values()) {
621
+ if (mount.lastMaterializedBranchId &&
622
+ mount.lastMaterializedBranchId !== current.id &&
623
+ this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
624
+ console.warn(`[workspace] Mount "${mount.config.name}": branch pin ${mount.lastMaterializedBranchId} → ` +
625
+ `${current.id} ("${current.name}") — current branch linearly continues the last ` +
626
+ `materialized branch (out-of-band switch, e.g. repair); disk state is preserved history.`);
627
+ mount.lastMaterializedBranchId = current.id;
628
+ }
629
+ }
519
630
  }
520
631
  /**
521
632
  * Start chokidar watchers and emit workspace:mounted events.
@@ -744,6 +855,7 @@ export class WorkspaceModule {
744
855
  properties: {
745
856
  path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
746
857
  mount: { type: 'string', description: 'Specific mount (optional)' },
858
+ force: { type: 'boolean', description: 'Materialize even if the current branch has diverged from the branch last written to disk (default false). Only needed for genuine divergence — descendant branches pass automatically.' },
747
859
  },
748
860
  },
749
861
  },
@@ -858,8 +970,17 @@ export class WorkspaceModule {
858
970
  /**
859
971
  * Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
860
972
  * absolute filesystem path. Returns null if the mount is unknown or the
861
- * resolved path escapes the mount root (path-traversal guard). Public API
862
- * for peer modules that need to read workspace files directly.
973
+ * resolved path escapes the mount root.
974
+ *
975
+ * **Lexical containment only.** The guard reasons about `..` segments in the
976
+ * path string; it knows nothing about what is on disk. A symlink inside the
977
+ * mount that targets an outside file passes this check, and a caller that
978
+ * then does `fs.readFile(path)` reads the outside content. Use the returned
979
+ * path for resolution-only purposes (write targets, nonexistent paths,
980
+ * display); for reading file content, use {@link readFileFromDisk}, which
981
+ * enforces the mount's `followSymlinks` policy and canonical containment.
982
+ *
983
+ * @deprecated for direct filesystem reads — call `readFileFromDisk()`.
863
984
  */
864
985
  resolveAbsolutePath(mountPrefixedPath) {
865
986
  try {
@@ -1030,72 +1151,97 @@ export class WorkspaceModule {
1030
1151
  }
1031
1152
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1032
1153
  }
1033
- async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
1154
+ /**
1155
+ * Open a mount-relative file for reading with the mount boundary enforced on
1156
+ * the *filesystem*, not just the path string:
1157
+ *
1158
+ * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1159
+ * symlink when the mount does not `followSymlinks`.
1160
+ * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1161
+ * holds against a symlink swapped in between lstat and open; where it
1162
+ * doesn't (Windows), re-`lstat` after opening instead.
1163
+ * 3. `realpath` both the mount root and the opened path and require canonical
1164
+ * containment — this is what catches an *intermediate* symlinked
1165
+ * directory escaping the mount, and a followed symlink whose target lands
1166
+ * outside, on every platform (junctions included).
1167
+ * 4. Confirm the descriptor and the canonical path are the same inode.
1168
+ *
1169
+ * `inspect` runs right after the descriptor is stat'd and before the
1170
+ * containment work, so callers can impose size policy in the same order the
1171
+ * image reader always has. The returned handle is the caller's to close.
1172
+ */
1173
+ async openContainedFile(mount, relativePath, mountPrefixedPath, inspect) {
1034
1174
  const lexicalPath = resolve(mount.config.path, relativePath);
1035
- const useNoFollow = !mount.config.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number';
1175
+ const follow = mount.config.followSymlinks === true;
1176
+ const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
1036
1177
  let fileInfo;
1037
1178
  try {
1038
1179
  fileInfo = await lstat(lexicalPath);
1039
1180
  }
1040
1181
  catch (err) {
1041
- if (isErrnoCode(err, 'ENOENT')) {
1042
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1182
+ const code = errnoOf(err);
1183
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
1184
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'lstat', code);
1043
1185
  }
1044
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1186
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'lstat', code);
1045
1187
  }
1046
1188
  if (fileInfo.isDirectory()) {
1047
- throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
1189
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat');
1048
1190
  }
1049
- if (fileInfo.isSymbolicLink() && !mount.config.followSymlinks) {
1050
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1191
+ if (fileInfo.isSymbolicLink() && !follow) {
1192
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1051
1193
  }
1052
1194
  let realMountRoot;
1053
1195
  try {
1054
1196
  realMountRoot = await realpath(mount.config.path);
1055
1197
  }
1056
- catch {
1057
- throw new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
1198
+ catch (err) {
1199
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
1058
1200
  }
1059
- let handle = null;
1201
+ let handle;
1060
1202
  try {
1061
1203
  handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1062
1204
  }
1063
1205
  catch (err) {
1064
- if (isErrnoCode(err, 'ENOENT')) {
1065
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1206
+ const code = errnoOf(err);
1207
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
1208
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'open', code);
1066
1209
  }
1067
- if (!mount.config.followSymlinks && isErrnoCode(err, 'ELOOP')) {
1068
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1210
+ if (!follow && code === 'ELOOP') {
1211
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'open', code);
1069
1212
  }
1070
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1213
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'open', code);
1071
1214
  }
1072
1215
  try {
1073
- const fileStat = await handle.stat();
1074
- if (fileStat.isDirectory()) {
1075
- throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
1216
+ let fileStat;
1217
+ try {
1218
+ fileStat = await handle.stat();
1076
1219
  }
1077
- if (!fileStat.isFile()) {
1078
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1220
+ catch (err) {
1221
+ const code = errnoOf(err);
1222
+ throw new WorkspaceReadError('not_found', code === 'ENOENT' ? `File not found: ${mountPrefixedPath}` : `Unable to read file: ${mountPrefixedPath}`, 'fstat', code);
1079
1223
  }
1080
- if (fileStat.size === 0) {
1081
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1224
+ if (fileStat.isDirectory()) {
1225
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1082
1226
  }
1083
- if (fileStat.size > maxSize) {
1084
- throw new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
1227
+ if (!fileStat.isFile()) {
1228
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
1085
1229
  }
1086
- if (!mount.config.followSymlinks && !useNoFollow) {
1230
+ inspect?.(fileStat);
1231
+ if (!follow && !useNoFollow) {
1087
1232
  let postOpenInfo;
1088
1233
  try {
1089
1234
  postOpenInfo = await lstat(lexicalPath);
1090
1235
  }
1091
1236
  catch (err) {
1092
- if (isErrnoCode(err, 'ENOENT')) {
1093
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1237
+ const code = errnoOf(err);
1238
+ if (code === 'ENOENT') {
1239
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'post_lstat', code);
1094
1240
  }
1095
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1241
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'post_lstat', code);
1096
1242
  }
1097
1243
  if (postOpenInfo.isSymbolicLink()) {
1098
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1244
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'post_lstat');
1099
1245
  }
1100
1246
  }
1101
1247
  let realFilePath;
@@ -1103,27 +1249,156 @@ export class WorkspaceModule {
1103
1249
  realFilePath = await realpath(lexicalPath);
1104
1250
  }
1105
1251
  catch (err) {
1106
- if (isErrnoCode(err, 'ENOENT')) {
1107
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1252
+ const code = errnoOf(err);
1253
+ if (code === 'ENOENT') {
1254
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'realpath', code);
1108
1255
  }
1109
- throw new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${mountPrefixedPath}`);
1256
+ throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1110
1257
  }
1111
1258
  if (!isContainedPath(realMountRoot, realFilePath)) {
1112
- throw new WorkspaceImageReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`);
1259
+ throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1113
1260
  }
1114
1261
  let pathStat;
1115
1262
  try {
1116
1263
  pathStat = await stat(realFilePath);
1117
1264
  }
1118
1265
  catch (err) {
1119
- if (isErrnoCode(err, 'ENOENT')) {
1120
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1266
+ const code = errnoOf(err);
1267
+ if (code === 'ENOENT') {
1268
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat', code);
1121
1269
  }
1122
- throw new WorkspaceImageReadError('not_found', `Unable to stat image file: ${mountPrefixedPath}`);
1270
+ throw new WorkspaceReadError('not_found', `Unable to stat file: ${mountPrefixedPath}`, 'stat', code);
1123
1271
  }
1124
1272
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
1125
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1273
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
1126
1274
  }
1275
+ return { handle, fileStat, realFilePath };
1276
+ }
1277
+ catch (err) {
1278
+ await handle.close();
1279
+ throw err;
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Read a workspace file from disk with the mount boundary enforced on the
1284
+ * filesystem (see {@link openContainedFile}). Public API for peer modules
1285
+ * that read mounted files outside the Chronicle tree — the safe replacement
1286
+ * for `resolveAbsolutePath()` + `fs.readFile()`, whose lexical-only guard let
1287
+ * an in-mount symlink smuggle outside content into context (agent-framework
1288
+ * #129, found via connectome-host #101).
1289
+ *
1290
+ * - Honors the mount's `followSymlinks` policy (default: refuse).
1291
+ * - With symlinks allowed, the canonical target must stay beneath the
1292
+ * canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
1293
+ * - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
1294
+ * ever loaded, and `truncated` reports that the file was larger.
1295
+ *
1296
+ * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1297
+ * mount, lexical traversal, unavailable mount, missing file, directory,
1298
+ * policy-denied symlink, outside-mount target, and file-changed-during-read.
1299
+ */
1300
+ async readFileFromDisk(mountPrefixedPath, options = {}) {
1301
+ if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
1302
+ throw new RangeError('readFileFromDisk: maxBytes must be a non-negative integer');
1303
+ }
1304
+ let mount;
1305
+ let relativePath;
1306
+ try {
1307
+ ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1308
+ }
1309
+ catch (err) {
1310
+ const message = err instanceof Error ? err.message : String(err);
1311
+ const code = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
1312
+ throw new WorkspaceReadError(code, message, 'parse');
1313
+ }
1314
+ if (!relativePath) {
1315
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
1316
+ }
1317
+ const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
1318
+ try {
1319
+ const size = fileStat.size;
1320
+ const cap = options.maxBytes;
1321
+ let bytes;
1322
+ let truncated = false;
1323
+ if (cap !== undefined && size > cap) {
1324
+ truncated = true;
1325
+ bytes = Buffer.alloc(cap);
1326
+ let offset = 0;
1327
+ while (offset < cap) {
1328
+ const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
1329
+ if (bytesRead === 0)
1330
+ break;
1331
+ offset += bytesRead;
1332
+ }
1333
+ if (offset < cap)
1334
+ bytes = bytes.subarray(0, offset);
1335
+ }
1336
+ else {
1337
+ bytes = await handle.readFile();
1338
+ }
1339
+ return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
1340
+ }
1341
+ catch (err) {
1342
+ if (err instanceof WorkspaceReadError)
1343
+ throw err;
1344
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
1345
+ }
1346
+ finally {
1347
+ await handle.close();
1348
+ }
1349
+ }
1350
+ /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
1351
+ imageErrorFromRead(err, mount, mountPrefixedPath) {
1352
+ const p = mountPrefixedPath;
1353
+ switch (err.code) {
1354
+ case 'mount_unavailable':
1355
+ return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
1356
+ case 'directory':
1357
+ return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
1358
+ case 'symlink':
1359
+ return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
1360
+ case 'escape':
1361
+ return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
1362
+ case 'changed':
1363
+ return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
1364
+ case 'not_found':
1365
+ default:
1366
+ if (err.message.startsWith('File not found')) {
1367
+ return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
1368
+ }
1369
+ if (err.stage === 'realpath') {
1370
+ return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
1371
+ }
1372
+ if (err.stage === 'stat') {
1373
+ return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
1374
+ }
1375
+ return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
1376
+ }
1377
+ }
1378
+ async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
1379
+ let opened;
1380
+ try {
1381
+ opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1382
+ if (fileStat.size === 0) {
1383
+ throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1384
+ }
1385
+ if (fileStat.size > maxSize) {
1386
+ throw new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
1387
+ }
1388
+ });
1389
+ }
1390
+ catch (err) {
1391
+ if (err instanceof WorkspaceImageReadError)
1392
+ throw err;
1393
+ if (err instanceof WorkspaceReadError)
1394
+ throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
1395
+ if (isErrnoCode(err, 'ENOENT')) {
1396
+ throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1397
+ }
1398
+ throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1399
+ }
1400
+ const { handle } = opened;
1401
+ try {
1127
1402
  const bytes = await handle.readFile();
1128
1403
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
1129
1404
  }
@@ -1136,7 +1411,7 @@ export class WorkspaceModule {
1136
1411
  throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1137
1412
  }
1138
1413
  finally {
1139
- await handle?.close();
1414
+ await handle.close();
1140
1415
  }
1141
1416
  }
1142
1417
  /**
@@ -1641,26 +1916,13 @@ export class WorkspaceModule {
1641
1916
  initialSyncDone: mount.initialSyncDone,
1642
1917
  currentBranch: currentBranch.name,
1643
1918
  lastMaterializedBranch: mount.lastMaterializedBranchId,
1644
- canMaterialize: !mount.lastMaterializedBranchId || mount.lastMaterializedBranchId === currentBranch.id,
1919
+ canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
1645
1920
  };
1646
1921
  }
1647
1922
  return { success: true, data: status };
1648
1923
  }
1649
1924
  async handleMaterialize(input) {
1650
1925
  const store = this.getStore();
1651
- // Guard: only materialize on the active branch
1652
- if (this.config.materializeOnlyActiveBranch !== false) {
1653
- const currentBranch = store.currentBranch();
1654
- for (const mount of this.mounts.values()) {
1655
- if (mount.lastMaterializedBranchId && mount.lastMaterializedBranchId !== currentBranch.id) {
1656
- return {
1657
- success: false,
1658
- error: `Cannot materialize: current branch "${currentBranch.name}" differs from last materialized branch. Switch back or set materializeOnlyActiveBranch: false.`,
1659
- isError: true,
1660
- };
1661
- }
1662
- }
1663
- }
1664
1926
  const allWritten = [];
1665
1927
  let mountsToMaterialize;
1666
1928
  if (input.mount) {
@@ -1675,6 +1937,35 @@ export class WorkspaceModule {
1675
1937
  .filter(([, m]) => m.config.mode === 'read-write')
1676
1938
  .map(([name, mount]) => ({ name, mount }));
1677
1939
  }
1940
+ // Branch guard, scoped to the mounts actually being materialized: a
1941
+ // linear continuation (current branch descends from the pinned branch at
1942
+ // or after the pinned seq) passes; genuine divergence refuses unless
1943
+ // force. One mount's stale pin must never block another mount.
1944
+ const blocked = [];
1945
+ for (const { name, mount } of mountsToMaterialize) {
1946
+ const reason = this.mountMaterializeBlockReason(store, mount);
1947
+ if (!reason)
1948
+ continue;
1949
+ if (input.force) {
1950
+ // Disk reflects another line of history, so an incremental diff from
1951
+ // the pinned seq is meaningless — reset tracking and re-materialize
1952
+ // the full tree, exactly like materializeMount() after a deliberate
1953
+ // branch switch.
1954
+ mount.lastMaterializedSeq = 0;
1955
+ mount.lastMaterializedBranchId = null;
1956
+ }
1957
+ else {
1958
+ blocked.push({ mount: name, reason });
1959
+ }
1960
+ }
1961
+ mountsToMaterialize = mountsToMaterialize.filter(({ name }) => !blocked.some((b) => b.mount === name));
1962
+ if (mountsToMaterialize.length === 0 && blocked.length > 0) {
1963
+ return {
1964
+ success: false,
1965
+ error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
1966
+ isError: true,
1967
+ };
1968
+ }
1678
1969
  for (const { name, mount } of mountsToMaterialize) {
1679
1970
  let paths;
1680
1971
  if (input.path) {
@@ -1688,8 +1979,12 @@ export class WorkspaceModule {
1688
1979
  watcher?.suppress(p);
1689
1980
  allWritten.push({ mount: name, path: p });
1690
1981
  }
1691
- // Track which branch we materialized on
1692
- if (written.length > 0) {
1982
+ // Track which branch we materialized on. Re-pin on a clean empty
1983
+ // materialize too (previously-pinned mount, nothing pending): disk
1984
+ // already reflects the current branch's tree, and leaving the old pin
1985
+ // would keep force required forever after a cross-branch materialize
1986
+ // that happened to write nothing.
1987
+ if (written.length > 0 || mount.lastMaterializedBranchId !== null) {
1693
1988
  mount.lastMaterializedBranchId = store.currentBranch().id;
1694
1989
  }
1695
1990
  }
@@ -1698,6 +1993,7 @@ export class WorkspaceModule {
1698
1993
  data: {
1699
1994
  materialized: allWritten,
1700
1995
  count: allWritten.length,
1996
+ ...(blocked.length > 0 ? { skipped: blocked } : {}),
1701
1997
  },
1702
1998
  };
1703
1999
  }