@animalabs/agent-framework 0.29.0 → 0.31.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 (236) hide show
  1. package/README.md +71 -8
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +50 -9
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +125 -23
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +10 -3
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/author-presentation.d.ts +50 -0
  14. package/dist/src/author-presentation.d.ts.map +1 -0
  15. package/dist/src/author-presentation.js +58 -0
  16. package/dist/src/author-presentation.js.map +1 -0
  17. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/render.js +7 -6
  19. package/dist/src/code-execution/isolate/render.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +5 -4
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/dendrite/index.d.ts +6 -0
  24. package/dist/src/dendrite/index.d.ts.map +1 -0
  25. package/dist/src/dendrite/index.js +3 -0
  26. package/dist/src/dendrite/index.js.map +1 -0
  27. package/dist/src/dendrite/presets.d.ts +60 -0
  28. package/dist/src/dendrite/presets.d.ts.map +1 -0
  29. package/dist/src/dendrite/presets.js +117 -0
  30. package/dist/src/dendrite/presets.js.map +1 -0
  31. package/dist/src/dendrite/registry.d.ts +260 -0
  32. package/dist/src/dendrite/registry.d.ts.map +1 -0
  33. package/dist/src/dendrite/registry.js +742 -0
  34. package/dist/src/dendrite/registry.js.map +1 -0
  35. package/dist/src/dendrite/types.d.ts +392 -0
  36. package/dist/src/dendrite/types.d.ts.map +1 -0
  37. package/dist/src/dendrite/types.js +24 -0
  38. package/dist/src/dendrite/types.js.map +1 -0
  39. package/dist/src/framework.d.ts +806 -118
  40. package/dist/src/framework.d.ts.map +1 -1
  41. package/dist/src/framework.js +4180 -1121
  42. package/dist/src/framework.js.map +1 -1
  43. package/dist/src/gate/event-gate.d.ts +36 -21
  44. package/dist/src/gate/event-gate.d.ts.map +1 -1
  45. package/dist/src/gate/event-gate.js +223 -149
  46. package/dist/src/gate/event-gate.js.map +1 -1
  47. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  48. package/dist/src/gate/recipe-reconcile.js +6 -5
  49. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  50. package/dist/src/host-presentation.d.ts +4 -0
  51. package/dist/src/host-presentation.d.ts.map +1 -1
  52. package/dist/src/host-presentation.js +12 -10
  53. package/dist/src/host-presentation.js.map +1 -1
  54. package/dist/src/index.d.ts +6 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/index.js +7 -1
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/inference-bounds.d.ts +37 -0
  59. package/dist/src/inference-bounds.d.ts.map +1 -0
  60. package/dist/src/inference-bounds.js +84 -0
  61. package/dist/src/inference-bounds.js.map +1 -0
  62. package/dist/src/line-reader.d.ts +7 -1
  63. package/dist/src/line-reader.d.ts.map +1 -1
  64. package/dist/src/line-reader.js +17 -6
  65. package/dist/src/line-reader.js.map +1 -1
  66. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  67. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  68. package/dist/src/mcpl/channel-registry.js +136 -58
  69. package/dist/src/mcpl/channel-registry.js.map +1 -1
  70. package/dist/src/mcpl/errors.d.ts +1 -0
  71. package/dist/src/mcpl/errors.d.ts.map +1 -1
  72. package/dist/src/mcpl/errors.js +3 -0
  73. package/dist/src/mcpl/errors.js.map +1 -1
  74. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  75. package/dist/src/mcpl/inbound-source.js +2 -2
  76. package/dist/src/mcpl/inference-router.d.ts +11 -1
  77. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/inference-router.js +65 -9
  79. package/dist/src/mcpl/inference-router.js.map +1 -1
  80. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  81. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  82. package/dist/src/mcpl/modern-connection.js +85 -74
  83. package/dist/src/mcpl/modern-connection.js.map +1 -1
  84. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  85. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  86. package/dist/src/mcpl/protocol-family.js +45 -3
  87. package/dist/src/mcpl/protocol-family.js.map +1 -1
  88. package/dist/src/mcpl/push-coalescer.d.ts +11 -7
  89. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  90. package/dist/src/mcpl/push-coalescer.js +21 -0
  91. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  92. package/dist/src/mcpl/references.d.ts +1 -2
  93. package/dist/src/mcpl/references.d.ts.map +1 -1
  94. package/dist/src/mcpl/references.js +3 -3
  95. package/dist/src/mcpl/references.js.map +1 -1
  96. package/dist/src/mcpl/server-connection.d.ts +14 -0
  97. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/server-connection.js +26 -22
  99. package/dist/src/mcpl/server-connection.js.map +1 -1
  100. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  101. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  102. package/dist/src/mcpl/tool-signature.js +16 -11
  103. package/dist/src/mcpl/tool-signature.js.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  105. package/dist/src/mcpl/toolsets-module.js +7 -0
  106. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  107. package/dist/src/mcpl/transport.d.ts +44 -21
  108. package/dist/src/mcpl/transport.d.ts.map +1 -1
  109. package/dist/src/mcpl/transport.js +283 -64
  110. package/dist/src/mcpl/transport.js.map +1 -1
  111. package/dist/src/mcpl/types.d.ts +17 -1
  112. package/dist/src/mcpl/types.d.ts.map +1 -1
  113. package/dist/src/mcpl/types.js.map +1 -1
  114. package/dist/src/module-registry.d.ts +32 -4
  115. package/dist/src/module-registry.d.ts.map +1 -1
  116. package/dist/src/module-registry.js +189 -25
  117. package/dist/src/module-registry.js.map +1 -1
  118. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.js +24 -16
  120. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  121. package/dist/src/modules/sender-name.d.ts +5 -4
  122. package/dist/src/modules/sender-name.d.ts.map +1 -1
  123. package/dist/src/modules/sender-name.js +7 -5
  124. package/dist/src/modules/sender-name.js.map +1 -1
  125. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  126. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  127. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  128. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  129. package/dist/src/modules/workspace/index.d.ts +6 -3
  130. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/index.js +61 -15
  132. package/dist/src/modules/workspace/index.js.map +1 -1
  133. package/dist/src/modules/workspace/observe.d.ts +9 -1
  134. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  135. package/dist/src/modules/workspace/observe.js +50 -10
  136. package/dist/src/modules/workspace/observe.js.map +1 -1
  137. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  138. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/reconcile.js +19 -8
  140. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  141. package/dist/src/modules/workspace/types.d.ts +8 -0
  142. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  143. package/dist/src/modules/workspace/types.js.map +1 -1
  144. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  145. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/watcher.js +7 -5
  147. package/dist/src/modules/workspace/watcher.js.map +1 -1
  148. package/dist/src/notice-tags.d.ts +2 -0
  149. package/dist/src/notice-tags.d.ts.map +1 -1
  150. package/dist/src/notice-tags.js +2 -0
  151. package/dist/src/notice-tags.js.map +1 -1
  152. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  153. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  154. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  155. package/dist/src/silent-heartbeat.d.ts +4 -2
  156. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  157. package/dist/src/silent-heartbeat.js +7 -4
  158. package/dist/src/silent-heartbeat.js.map +1 -1
  159. package/dist/src/stored-injections.d.ts +4 -0
  160. package/dist/src/stored-injections.d.ts.map +1 -1
  161. package/dist/src/stored-injections.js +6 -0
  162. package/dist/src/stored-injections.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +2 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -1
  165. package/dist/src/supplied-text.js +16 -7
  166. package/dist/src/supplied-text.js.map +1 -1
  167. package/dist/src/tool-result-guard.js +1 -1
  168. package/dist/src/tool-result-guard.js.map +1 -1
  169. package/dist/src/tune-out/coordinator.d.ts +368 -16
  170. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  171. package/dist/src/tune-out/coordinator.js +1224 -136
  172. package/dist/src/tune-out/coordinator.js.map +1 -1
  173. package/dist/src/tune-out/tools.d.ts +115 -7
  174. package/dist/src/tune-out/tools.d.ts.map +1 -1
  175. package/dist/src/tune-out/tools.js +55 -18
  176. package/dist/src/tune-out/tools.js.map +1 -1
  177. package/dist/src/types/agent.d.ts +27 -1
  178. package/dist/src/types/agent.d.ts.map +1 -1
  179. package/dist/src/types/framework.d.ts +153 -15
  180. package/dist/src/types/framework.d.ts.map +1 -1
  181. package/dist/src/types/module.d.ts +42 -3
  182. package/dist/src/types/module.d.ts.map +1 -1
  183. package/dist/src/types/trace.d.ts +140 -3
  184. package/dist/src/types/trace.d.ts.map +1 -1
  185. package/package.json +4 -4
  186. package/src/admission/admit.ts +113 -0
  187. package/src/agent.ts +138 -31
  188. package/src/api/mcp-server-core.ts +10 -3
  189. package/src/author-presentation.ts +81 -0
  190. package/src/code-execution/isolate/render.ts +7 -6
  191. package/src/code-execution/isolate/scripts.ts +5 -4
  192. package/src/dendrite/index.ts +32 -0
  193. package/src/dendrite/presets.ts +149 -0
  194. package/src/dendrite/registry.ts +808 -0
  195. package/src/dendrite/types.ts +397 -0
  196. package/src/framework.ts +4344 -1052
  197. package/src/gate/event-gate.ts +251 -150
  198. package/src/gate/recipe-reconcile.ts +7 -5
  199. package/src/host-presentation.ts +14 -10
  200. package/src/index.ts +16 -2
  201. package/src/inference-bounds.ts +111 -0
  202. package/src/line-reader.ts +22 -5
  203. package/src/mcpl/channel-registry.ts +170 -60
  204. package/src/mcpl/errors.ts +4 -0
  205. package/src/mcpl/inbound-source.ts +2 -2
  206. package/src/mcpl/inference-router.ts +67 -10
  207. package/src/mcpl/modern-connection.ts +83 -68
  208. package/src/mcpl/protocol-family.ts +37 -4
  209. package/src/mcpl/push-coalescer.ts +21 -7
  210. package/src/mcpl/references.ts +3 -4
  211. package/src/mcpl/server-connection.ts +30 -21
  212. package/src/mcpl/tool-signature.ts +18 -11
  213. package/src/mcpl/toolsets-module.ts +7 -0
  214. package/src/mcpl/transport.ts +261 -60
  215. package/src/mcpl/types.ts +18 -1
  216. package/src/module-registry.ts +178 -25
  217. package/src/modules/discord/discord-js-client.ts +33 -29
  218. package/src/modules/sender-name.ts +8 -5
  219. package/src/modules/workspace/disk-agreement.ts +39 -15
  220. package/src/modules/workspace/index.ts +65 -14
  221. package/src/modules/workspace/observe.ts +51 -10
  222. package/src/modules/workspace/reconcile.ts +22 -8
  223. package/src/modules/workspace/types.ts +5 -0
  224. package/src/modules/workspace/watcher.ts +8 -6
  225. package/src/notice-tags.ts +2 -0
  226. package/src/recovery/discord-awareness-outbox.ts +1 -11
  227. package/src/silent-heartbeat.ts +7 -4
  228. package/src/stored-injections.ts +7 -0
  229. package/src/supplied-text.ts +17 -6
  230. package/src/tool-result-guard.ts +1 -1
  231. package/src/tune-out/coordinator.ts +1374 -141
  232. package/src/tune-out/tools.ts +166 -13
  233. package/src/types/agent.ts +30 -1
  234. package/src/types/framework.ts +157 -15
  235. package/src/types/module.ts +42 -3
  236. package/src/types/trace.ts +154 -3
@@ -42,7 +42,15 @@ import { Refusal, onlyTheHost } from './answers.js';
42
42
  const UNTIL_REOPENED = "the workspace can't check its files against disk until its store is reopened";
43
43
  const REOPEN = onlyTheHost('reopen it with a restart');
44
44
 
45
- export class EvidenceUnavailable extends Refusal {}
45
+ export class EvidenceUnavailable extends Refusal {
46
+ constructor(readonly why: string) {
47
+ super(`${UNTIL_REOPENED}: ${why}. ${REOPEN}`);
48
+ }
49
+ }
50
+
51
+ export function rootUnreadable(err: EvidenceUnavailable): string {
52
+ return `the root disk last agreed with can't be read until the store is reopened: ${err.why}. ${REOPEN}`;
53
+ }
46
54
 
47
55
  export const callGate = new AsyncLocalStorage<{ passed: boolean }>();
48
56
 
@@ -105,8 +113,8 @@ export type Physical = Agreed | Pending | Interrupted;
105
113
 
106
114
  /**
107
115
  * Which directory a mount root is: its device and inode, and its birth time
108
- * in nanoseconds as the runtime reports it, where that is above zero, as decimal
109
- * strings.
116
+ * in nanoseconds as the runtime reports it, where that is above zero and isn't
117
+ * its ctime, as decimal strings.
110
118
  */
111
119
  export interface RootIdentity {
112
120
  dev: string;
@@ -121,16 +129,32 @@ function sameRootPath(a: string, b: string): boolean {
121
129
  }
122
130
 
123
131
  /**
124
- * Whether two identities name the same directory: the same device and inode,
125
- * or the same inode and birth time on another device, since macOS can give a
126
- * volume another device number after a reboot. A birth time in whole seconds
127
- * doesn't count across devices: filesystems made in the same second can give
128
- * their roots one inode (2 on ext4) and one birth time.
132
+ * Whether the directory seen `now` is the one `recorded`: the same inode, and
133
+ * not born after the recorded one where `now`'s filesystem keeps a birth time
134
+ * fixed (`bornFixed`), since ext4 gives a directory made where one was removed
135
+ * its inode again, while overlayfs gives a directory copied up from a lower
136
+ * layer a later birth time under the same inode; and the same device, or the
137
+ * same birth time on another device, since macOS can give a volume another
138
+ * device number after a reboot. A birth time in whole seconds doesn't count
139
+ * across devices: filesystems made in the same second can give their roots
140
+ * one inode (2 on ext4) and one birth time.
129
141
  */
130
- export function sameRootIdentity(a: RootIdentity, b: RootIdentity): boolean {
131
- if (a.ino !== b.ino) return false;
132
- if (a.dev === b.dev) return true;
133
- return a.born !== undefined && a.born === b.born && !a.born.endsWith('000000000');
142
+ export function sameRootIdentity(recorded: RootIdentity, now: RootIdentity, bornFixed: boolean): boolean {
143
+ if (recorded.ino !== now.ino || (bornFixed && bornLater(recorded, now))) return false;
144
+ if (recorded.dev === now.dev) return true;
145
+ return fractional(recorded.born) && recorded.born === now.born;
146
+ }
147
+
148
+ export function onlyDeviceDiffers(recorded: RootIdentity, now: RootIdentity, bornFixed: boolean): boolean {
149
+ return !sameRootIdentity(recorded, now, bornFixed) && recorded.ino === now.ino && recorded.dev !== now.dev && !(bornFixed && bornLater(recorded, now));
150
+ }
151
+
152
+ function bornLater(recorded: RootIdentity, now: RootIdentity): boolean {
153
+ return recorded.born !== undefined && now.born !== undefined && BigInt(now.born) > BigInt(recorded.born);
154
+ }
155
+
156
+ function fractional(born: string | undefined): boolean {
157
+ return born !== undefined && !born.endsWith('000000000');
134
158
  }
135
159
 
136
160
  type JournalEntry =
@@ -242,10 +266,10 @@ export class DiskAgreement {
242
266
  */
243
267
  ensureReconciled(): void {
244
268
  if (this.journal.needsReopen) {
245
- throw new EvidenceUnavailable(`${UNTIL_REOPENED}: an append to its record of what disk last agreed with failed inside the store, so that record may lack what the append held. ${REOPEN}`);
269
+ throw new EvidenceUnavailable('an append to its record of what disk last agreed with failed inside the store, so that record may lack what the append held');
246
270
  }
247
271
  if (!this.whole) {
248
- throw new EvidenceUnavailable(`${UNTIL_REOPENED}: reading back its record of what disk last agreed with failed. ${REOPEN}`);
272
+ throw new EvidenceUnavailable('reading back its record of what disk last agreed with failed');
249
273
  }
250
274
  if (this.journal.needsReconcile) this.rebuild();
251
275
  const gate = callGate.getStore();
@@ -345,7 +369,7 @@ export class DiskAgreement {
345
369
  recordRootIdentity(mount: string, identity: RootIdentity): void {
346
370
  const recorded = this.rootIdentity(mount);
347
371
  if (recorded !== undefined) {
348
- if (!sameRootIdentity(recorded, identity)) return;
372
+ if (!sameRootIdentity(recorded, identity, false)) return;
349
373
  const renumbered = recorded.dev !== identity.dev;
350
374
  const bornLearned = recorded.born === undefined && identity.born !== undefined;
351
375
  if (!renumbered && !bornLearned) return;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { NOTICE_TAG } from '../../notice-tags.js';
9
+ import { nameToTypeBack } from '../../supplied-text.js';
9
10
  import { constants as fsConstants } from 'node:fs';
10
11
  import type { BigIntStats, Stats } from 'node:fs';
11
12
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
@@ -39,9 +40,9 @@ import type {
39
40
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
40
41
  import { MountWatcher, type FsChange } from './watcher.js';
41
42
  import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
42
- import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
43
+ import { DiskAgreement, EvidenceUnavailable, callGate, rootUnreadable, sameRootIdentity, type RootIdentity } from './disk-agreement.js';
43
44
  import { type BranchIntent, BranchIntents } from './branch-intent.js';
44
- import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
45
+ import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_ON_ANOTHER_DEVICE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
45
46
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
46
47
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
47
48
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
@@ -62,6 +63,7 @@ import {
62
63
  WALK_CAP,
63
64
  entryMode,
64
65
  explained,
66
+ passRoot,
65
67
  pushPaths,
66
68
  reconcilePass,
67
69
  settleAdoptionBeforeMutation,
@@ -122,6 +124,21 @@ const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so th
122
124
  /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
123
125
  const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
124
126
 
127
+ const NOTHING_RECORDED_YET = 'nothing is recorded of this root yet, so any directory at its path is taken as it';
128
+ const NOTHING_RECORDED = `${NOTHING_RECORDED_YET}; the first sync that takes in a file, or materialize that writes one, records it`;
129
+ const NOTHING_RECORDED_READ_ONLY = `${NOTHING_RECORDED_YET}; the first sync that takes in a file records it`;
130
+
131
+ const BORN_UNRECORDED = 'no birth time is recorded of this root yet, so a directory made again in its place with the same inode can\'t be told from it; the next sync or ls of this mount records it';
132
+
133
+ interface RootAsShown { dev: string; ino: string; born?: string }
134
+
135
+ function asShown(identity: RootIdentity | null): RootAsShown | null {
136
+ if (identity?.born === undefined) return identity;
137
+ const ns = BigInt(identity.born);
138
+ const second = new Date(Number(ns / 1_000_000_000n) * 1000).toISOString().replace(/\.000Z$/, '');
139
+ return { ...identity, born: `${second}.${String(ns % 1_000_000_000n).padStart(9, '0')}Z` };
140
+ }
141
+
125
142
  export type {
126
143
  WorkspaceConfig,
127
144
  MountConfig,
@@ -1385,6 +1402,7 @@ export class WorkspaceModule implements Module {
1385
1402
  /** One disk→store pass; the caller holds the mount's turn. Emits its events. */
1386
1403
  private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
1387
1404
  const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
1405
+ if (result.rootListed !== undefined) mount.lastPassRoot = { at: new Date().toISOString(), listed: result.rootListed };
1388
1406
  if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
1389
1407
  // What a pass says is another mount's directory names that mount and
1390
1408
  // the path to use there, as the file operations' refusal does: a copy's
@@ -2300,13 +2318,13 @@ export class WorkspaceModule implements Module {
2300
2318
 
2301
2319
  let text: string;
2302
2320
  if (paths.length === 1) {
2303
- text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
2321
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${nameToTypeBack(paths[0]!)}]`;
2304
2322
  } else {
2305
- const list = paths.map(p => `- ${p}`).join('\n');
2323
+ const list = paths.map(p => `- ${nameToTypeBack(p)}`).join('\n');
2306
2324
  text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2307
2325
  }
2308
2326
  if (conflicts && conflicts.length > 0) {
2309
- text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2327
+ text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.map(nameToTypeBack).join(', ')}; reading each shows how to settle it]`;
2310
2328
  }
2311
2329
 
2312
2330
  return {
@@ -2375,7 +2393,7 @@ export class WorkspaceModule implements Module {
2375
2393
  /**
2376
2394
  * Write binary content (e.g. an image pulled from the agent's context) to a
2377
2395
  * mount, through the same Chronicle-tree + auto-materialize path as the
2378
- * `write` tool. Public API for the framework's synthesized `save_image`
2396
+ * `write` tool. Public API for the framework's synthesized `save_recent_image`
2379
2397
  * tool and other peer callers that hold bytes rather than text.
2380
2398
  *
2381
2399
  * `branch`, when given, ties the write to that branch: the store's current
@@ -3222,8 +3240,9 @@ export class WorkspaceModule implements Module {
3222
3240
  * observation checks it (checkRoot), so the read and the evidence agree on
3223
3241
  * which directory the mount is. Once the workspace has recorded the root's
3224
3242
  * identity, a root that resolves to any other directory is refused, in
3225
- * ROOT_REPLACED's words; with none recorded yet, it need only be a
3226
- * directory there. A refusal where no directory is at the root's path
3243
+ * ROOT_REPLACED's words, or ROOT_ON_ANOTHER_DEVICE's where only its device
3244
+ * differs and birth times can't tell; with none recorded yet, it need only
3245
+ * be a directory there. A refusal where no directory is at the root's path
3227
3246
  * carries the errno that gives (rootAbsence: ENOENT when nothing is there
3228
3247
  * and ENOTDIR when something else is, which a caller can read as no file,
3229
3248
  * or the errno the root's stat failed with otherwise, such as EACCES). A
@@ -4077,9 +4096,7 @@ export class WorkspaceModule implements Module {
4077
4096
  private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
4078
4097
  const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
4079
4098
  if (!absent) return { unreadable: containedRefusal(err, path) };
4080
- const root = resolve(mount.config.path);
4081
- const rootReal = await realpath(root).catch(() => root);
4082
- const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
4099
+ const { view, rootReal } = await this.readView(mount);
4083
4100
  const proof = await confirmAbsent(view, rootReal, relativePath);
4084
4101
  if (!('reason' in proof)) return 'none';
4085
4102
  return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
@@ -5838,6 +5855,8 @@ export class WorkspaceModule implements Module {
5838
5855
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
5839
5856
  return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5840
5857
  }
5858
+ const asked = input.mount === undefined ? [] : [...this.mounts].filter(([name, mount]) => !mount.direct && name === input.mount);
5859
+ const roots = new Map(await Promise.all(asked.map(async ([name, mount]) => [name, await this.rootState(mount)] as const)));
5841
5860
 
5842
5861
  for (const [name, mount] of this.mounts) {
5843
5862
  if (input.mount !== undefined && name !== input.mount) continue;
@@ -5857,6 +5876,8 @@ export class WorkspaceModule implements Module {
5857
5876
  const pending = new Set([...conflictPaths, ...deletionPaths, ...owed]);
5858
5877
 
5859
5878
  const currentBranch = store.currentBranch();
5879
+ const root = input.mount !== undefined ? roots.get(name)!
5880
+ : mount.lastPassRoot?.listed ? { unavailable: mount.lastPassRoot.listed, lastPassAt: mount.lastPassRoot.at } : undefined;
5860
5881
  status[name] = {
5861
5882
  path: mount.config.path,
5862
5883
  mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
@@ -5874,14 +5895,44 @@ export class WorkspaceModule implements Module {
5874
5895
  initialSyncDone: mount.initialSyncDone,
5875
5896
  currentBranch: currentBranch.name,
5876
5897
  lastMaterializedBranch: mount.lastMaterializedBranchId,
5877
- canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
5898
+ canMaterialize: this.mountMaterializeBlockReason(store, mount) === null && root?.unavailable === undefined,
5878
5899
  ...(mount.lastAgentActionScan ? { lastAgentActionScan: mount.lastAgentActionScan } : {}),
5900
+ ...(root !== undefined ? { root } : {}),
5879
5901
  };
5880
5902
  }
5881
5903
 
5882
5904
  return { success: true, data: status };
5883
5905
  }
5884
5906
 
5907
+ private async readView(mount: MountState): Promise<{ view: MountView; rootReal: string }> {
5908
+ const root = resolve(mount.config.path);
5909
+ const rootReal = await realpath(root).catch(() => root);
5910
+ return { view: this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name)), rootReal };
5911
+ }
5912
+
5913
+ private async rootState(mount: MountState): Promise<{ recorded?: RootAsShown | null; now: RootAsShown | null; unavailable?: string; note?: string }> {
5914
+ const read = await this.readView(mount).catch((err: unknown) => {
5915
+ if (err instanceof EvidenceUnavailable) return err;
5916
+ throw err;
5917
+ });
5918
+ if (read instanceof EvidenceUnavailable) {
5919
+ const root = resolve(mount.config.path);
5920
+ const there = await locateRoot(this.viewOf(mount, undefined), await realpath(root).catch(() => root));
5921
+ return { now: asShown('identity' in there ? there.identity : null), unavailable: rootUnreadable(read) };
5922
+ }
5923
+ const { view, rootReal } = read;
5924
+ const recorded = view.rootIdentity ?? null;
5925
+ const found = await passRoot({ view, rootReal });
5926
+ if ('unavailable' in found) {
5927
+ const there = await locateRoot({ ...view, rootIdentity: undefined }, rootReal);
5928
+ return { recorded: asShown(recorded), now: asShown('identity' in there ? there.identity : null), unavailable: found.listed };
5929
+ }
5930
+ const note = this.agreementOrThrow().awaitsReopen ? undefined
5931
+ : recorded === null ? mount.config.mode === 'read-only' ? NOTHING_RECORDED_READ_ONLY : NOTHING_RECORDED
5932
+ : recorded.born === undefined && found.identity.born !== undefined && found.bornFixed ? BORN_UNRECORDED : undefined;
5933
+ return { recorded: asShown(recorded), now: asShown(found.identity), ...(note !== undefined ? { note } : {}) };
5934
+ }
5935
+
5885
5936
  /**
5886
5937
  * What the store holds of a mount that disk lacks, on the selected branch,
5887
5938
  * among the mount's own paths. Status counts the first three as pending:
@@ -6401,7 +6452,7 @@ export class WorkspaceModule implements Module {
6401
6452
  if ('reason' in found) return false; // still missing: the pass says so
6402
6453
  const agreement = this.agreementOrThrow();
6403
6454
  const recorded = agreement.rootIdentity(mount.config.name);
6404
- if (recorded && sameRootIdentity(recorded, found.identity)) return false;
6455
+ if (recorded && sameRootIdentity(recorded, found.identity, found.bornFixed)) return false;
6405
6456
  if (recorded) agreement.acceptRoot(mount.config.name, found.identity);
6406
6457
  else agreement.recordRootIdentity(mount.config.name, found.identity);
6407
6458
  agreement.barrier();
@@ -6868,7 +6919,7 @@ function deletedOnDisk(path: string): string {
6868
6919
 
6869
6920
  function uncheckedFile(shown: string, note: string | undefined, cause: Cause): string {
6870
6921
  if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6871
- const on = note === ROOT_REPLACED || cause.ownStep === true ? null
6922
+ const on = note === ROOT_REPLACED || note === ROOT_ON_ANOTHER_DEVICE || cause.ownStep === true ? null
6872
6923
  : (note === undefined ? null : wayOn(note, cause, 'read')) ?? 'calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk';
6873
6924
  return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on[0]!.toUpperCase()}${sentence(on.slice(1))}` : ''}`;
6874
6925
  }
@@ -46,7 +46,7 @@ import type { BigIntStats } from 'node:fs';
46
46
  import { lstat, mkdir, open, readdir, readlink, realpath, stat, statfs } from 'node:fs/promises';
47
47
  import type { FileHandle } from 'node:fs/promises';
48
48
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
49
- import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
49
+ import { onlyDeviceDiffers, sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
50
50
  import { OUTSIDE_STEP, UNFOLLOWED_STEP, errnoClause, errnoCode, systemWords } from './answers.js';
51
51
 
52
52
  export interface MountView {
@@ -164,8 +164,14 @@ export const LEAVES_MOUNT = 'a symlink that leaves the mount';
164
164
  export const NOT_A_DIRECTORY = 'a parent path is not a directory';
165
165
  export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
166
166
  export const ROOT_REPLACED =
167
- 'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
168
- 'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now';
167
+ 'the mount root is not the directory disk last agreed with (an unmounted drive, a directory moved aside, or a replaced one); ' +
168
+ 'whoever runs this host can say which: a drive they can reconnect, a directory moved aside they can put back, ' +
169
+ 'and a replaced directory a sync of the mount with acceptRoot takes as it is now';
170
+ export const ROOT_ON_ANOTHER_DEVICE =
171
+ 'the mount root has the inode disk last agreed with, but another device number: a reboot can renumber a volume, ' +
172
+ 'and another volume mounted at the same place can have a root with the same inode; whoever runs this host can ' +
173
+ 'say which, and if it is the same directory, a sync of the mount with acceptRoot takes it as it is now; if not, ' +
174
+ 'they can mount the right volume';
169
175
  export const CHANGED = 'it changed while it was observed';
170
176
  export const CHANGED_WALK = 'the directory changed while the walk ran';
171
177
  export const FILE_CHANGED = 'the file changed while it was read';
@@ -1026,6 +1032,8 @@ export class CheckedDir {
1026
1032
  private readonly dev: bigint,
1027
1033
  private readonly ino: bigint,
1028
1034
  private readonly born: bigint,
1035
+ /** For the root: whether its filesystem keeps a birth time fixed (filesystemKeepsBirthTime). */
1036
+ readonly bornFixed = false,
1029
1037
  ) {}
1030
1038
 
1031
1039
  /** Which directory it is. */
@@ -1094,7 +1102,12 @@ export async function rootAbsence(root: string): Promise<string | undefined> {
1094
1102
  async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
1095
1103
  const info = await lstat(real, { bigint: true }).catch(() => null);
1096
1104
  if (!info?.isDirectory()) return null;
1097
- return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs);
1105
+ return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, bornOf(info), rel === '' && await filesystemKeepsBirthTime(real));
1106
+ }
1107
+
1108
+ function bornOf(info: BigIntStats): bigint {
1109
+ // Where statx is unavailable, libuv gives the ctime as the birth time (uv__to_stat).
1110
+ return info.birthtimeNs === info.ctimeNs ? 0n : info.birthtimeNs;
1098
1111
  }
1099
1112
 
1100
1113
  /**
@@ -1141,8 +1154,8 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
1141
1154
  // A root that isn't the directory disk last agreed under proves nothing
1142
1155
  // about the files that agreed: an empty mountpoint where a drive was
1143
1156
  // would otherwise read as every file deleted.
1144
- if (rel === '' && view.rootIdentity && !sameRootIdentity(checked.identity(), view.rootIdentity)) {
1145
- return { kind: 'unobserved', reason: ROOT_REPLACED };
1157
+ if (rel === '' && view.rootIdentity && !sameRootIdentity(view.rootIdentity, checked.identity(), checked.bornFixed)) {
1158
+ return { kind: 'unobserved', reason: onlyDeviceDiffers(view.rootIdentity, checked.identity(), checked.bornFixed) ? ROOT_ON_ANOTHER_DEVICE : ROOT_REPLACED };
1146
1159
  }
1147
1160
  return { kind: 'checked', dir: checked };
1148
1161
  }
@@ -1153,9 +1166,9 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
1153
1166
  * The mount root as it is now: its identity if it is available (a directory,
1154
1167
  * the one the pass was given, and the one disk last agreed under), else why not.
1155
1168
  */
1156
- export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string; errno?: string }> {
1169
+ export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity; bornFixed: boolean } | { reason: string; errno?: string }> {
1157
1170
  const found = await checkRoot(view, rootReal);
1158
- return 'dir' in found ? { identity: found.dir.identity() } : found;
1171
+ return 'dir' in found ? { identity: found.dir.identity(), bornFixed: found.dir.bornFixed } : found;
1159
1172
  }
1160
1173
 
1161
1174
  /** The mount root as locateRoot finds it, checked, so a caller can ask later whether it's still there. */
@@ -1265,7 +1278,7 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
1265
1278
  }
1266
1279
  if (info.isDirectory()) {
1267
1280
  if (last) return { reason: CHANGED }; // present after all
1268
- cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, info.birthtimeNs);
1281
+ cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, bornOf(info));
1269
1282
  walked.push(cur);
1270
1283
  continue;
1271
1284
  }
@@ -1360,6 +1373,34 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
1360
1373
  }
1361
1374
  }
1362
1375
 
1376
+ /**
1377
+ * Linux filesystems that give a directory its birth time when it is made and
1378
+ * have no call that changes it (statfs f_type). Overlayfs isn't one: copying a
1379
+ * directory up from a lower layer gives it the copy's birth time under the
1380
+ * same device and inode.
1381
+ */
1382
+ const BIRTH_FIXED_FS_TYPES = new Set<number>([
1383
+ 0xef53, // ext2/ext3/ext4
1384
+ 0x58465342, // xfs
1385
+ 0x9123683e, // btrfs
1386
+ 0x01021994, // tmpfs
1387
+ 0xf2f52010, // f2fs
1388
+ 0x2fc12fc1, // zfs
1389
+ ]);
1390
+
1391
+ export function fsTypeKeepsBirthTime(type: number | bigint): boolean {
1392
+ return BIRTH_FIXED_FS_TYPES.has(Number(type) >>> 0);
1393
+ }
1394
+
1395
+ export async function filesystemKeepsBirthTime(dir: string): Promise<boolean> {
1396
+ if (process.platform !== 'linux') return false;
1397
+ try {
1398
+ return fsTypeKeepsBirthTime((await statfs(dir)).type);
1399
+ } catch {
1400
+ return false;
1401
+ }
1402
+ }
1403
+
1363
1404
  // ===========================================================================
1364
1405
  // Paths and files
1365
1406
  // ===========================================================================
@@ -2106,7 +2147,7 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
2106
2147
  return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
2107
2148
  }
2108
2149
  if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
2109
- return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs) };
2150
+ return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, bornOf(info)) };
2110
2151
  }
2111
2152
 
2112
2153
  /**
@@ -169,6 +169,8 @@ export interface PassResult {
169
169
  branchId?: string;
170
170
  /** Why it decided nothing, where it didn't. */
171
171
  undecided?: string;
172
+ /** The words it listed for a root it couldn't use, or null where it used it, from its last look at the root. */
173
+ rootListed?: string | null;
172
174
  }
173
175
 
174
176
  export interface MountRuntime {
@@ -942,6 +944,7 @@ interface Gathered {
942
944
  branchId: string;
943
945
  /** The root the facts were gathered under, when it was available. */
944
946
  rootIdentity: RootIdentity | null;
947
+ rootListed: string | null;
945
948
  walk: Walk | null;
946
949
  facts: Map<string, DiskFact>;
947
950
  dirs: string[];
@@ -951,6 +954,13 @@ interface Gathered {
951
954
  /** How often a pass gathers again when the branch changes under it. */
952
955
  const GATHER_ATTEMPTS = 3;
953
956
 
957
+ export async function passRoot(mount: Pick<MountRuntime, 'view' | 'rootReal'>): Promise<{ identity: RootIdentity; bornFixed: boolean } | { unavailable: string; cause: Cause; listed: string }> {
958
+ const root = await locateRoot(mount.view, mount.rootReal);
959
+ if ('identity' in root) return root;
960
+ const cause: Cause = root.errno !== undefined ? { errno: root.errno } : {};
961
+ return { unavailable: root.reason, cause, listed: explained(root.reason, cause, 'list') };
962
+ }
963
+
954
964
  async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRuntime, scope: Scope, opts: PassOptions): Promise<Gathered> {
955
965
  // A missing mount root is an unavailable mount (an unmounted drive, a root
956
966
  // being replaced), not proof that every file in it was deleted, and so is a
@@ -958,9 +968,10 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
958
968
  // directory a watcher makes where a drive's mountpoint was. The root is
959
969
  // the operator's choice and may itself be a symlink to a directory, so it
960
970
  // is followed, as `rootReal` is: `followSymlinks` governs links within it.
961
- const root = await locateRoot(mount.view, mount.rootReal);
962
- const rootUnavailable = 'reason' in root ? root.reason : null;
963
- const rootCause = 'reason' in root && root.errno !== undefined ? { errno: root.errno } : {};
971
+ const root = await passRoot(mount);
972
+ const rootUnavailable = 'unavailable' in root ? root.unavailable : null;
973
+ const rootCause = 'unavailable' in root ? root.cause : {};
974
+ const rootListed = 'unavailable' in root ? root.listed : null;
964
975
 
965
976
  // The branch is labelled where the tracked candidates are read, with no
966
977
  // await between: the decision is checked against this label.
@@ -968,15 +979,16 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
968
979
  const gathered: Gathered = {
969
980
  branchId,
970
981
  rootIdentity: 'identity' in root ? root.identity : null,
982
+ rootListed,
971
983
  walk: null,
972
984
  facts: new Map(),
973
985
  dirs: [],
974
986
  incomplete: [],
975
987
  };
976
- if (rootUnavailable !== null) {
988
+ if (rootListed !== null) {
977
989
  // Said whether or not anything is tracked: an unavailable mount must not
978
990
  // read as a verified empty directory.
979
- gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: explained(rootUnavailable, rootCause, 'list') });
991
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootListed });
980
992
  }
981
993
 
982
994
  // Which paths to decide: what disk shows, and what the store, P and intent
@@ -1082,14 +1094,15 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
1082
1094
  // now, nothing observed counts and nothing is decided from it.
1083
1095
  if (gathered.rootIdentity !== null) {
1084
1096
  const after = await locateRoot(mount.view, mount.rootReal);
1085
- if (!('identity' in after) || !sameRootIdentity(after.identity, gathered.rootIdentity)) {
1097
+ if (!('identity' in after) || !sameRootIdentity(gathered.rootIdentity, after.identity, after.bornFixed)) {
1086
1098
  const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
1087
1099
  const errno = 'errno' in after ? after.errno : undefined;
1088
1100
  for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason, ...(errno !== undefined ? { errno } : {}) });
1089
1101
  gathered.rootIdentity = null;
1102
+ gathered.rootListed = explained(reason, errno !== undefined ? { errno } : {}, 'list');
1090
1103
  gathered.walk = null;
1091
1104
  gathered.dirs = [];
1092
- gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: explained(reason, errno !== undefined ? { errno } : {}, 'list') });
1105
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: gathered.rootListed });
1093
1106
  }
1094
1107
  }
1095
1108
  return gathered;
@@ -1120,6 +1133,7 @@ export async function reconcilePass(
1120
1133
  let gathered: Gathered | null = null;
1121
1134
  for (let attempt = 0; attempt < GATHER_ATTEMPTS; attempt++) {
1122
1135
  const next = await gather(store, agreement, mount, scope, opts);
1136
+ result.rootListed = next.rootListed;
1123
1137
  if (store.currentBranch().id === next.branchId) {
1124
1138
  gathered = next;
1125
1139
  break;
@@ -1469,7 +1483,7 @@ export async function pushPaths(
1469
1483
  // replaced meanwhile they describe the replacement, so, as a pass does,
1470
1484
  // nothing is decided or recorded from them.
1471
1485
  const after = await locateRoot(mount.view, mount.rootReal);
1472
- if (!('identity' in after) || !sameRootIdentity(after.identity, root.identity)) {
1486
+ if (!('identity' in after) || !sameRootIdentity(root.identity, after.identity, after.bornFixed)) {
1473
1487
  const reason = 'reason' in after ? explained(after.reason, after.errno !== undefined ? { errno: after.errno } : {}, 'write') : ROOT_REPLACED;
1474
1488
  for (const path of paths) result.skipped.push({ path, reason });
1475
1489
  return result;
@@ -435,6 +435,11 @@ export interface MountState {
435
435
  initialSyncDone: boolean;
436
436
  /** Branch ID that was active when this mount last materialized */
437
437
  lastMaterializedBranchId: string | null;
438
+ /**
439
+ * What the mount's last pass found of its root, as of `at` (an ISO time):
440
+ * the words it listed for a root it couldn't use, or null where it used it.
441
+ */
442
+ lastPassRoot?: { at: string; listed: string | null };
438
443
  /**
439
444
  * The last on-agent-action scan, as of `at`: `complete` when it has
440
445
  * finished at all, `withinDeadline` when it finished before the deadline
@@ -38,13 +38,13 @@ export interface WatcherLifecycle {
38
38
  onReattach?: () => void;
39
39
  }
40
40
 
41
- interface RootIdentity {
41
+ export interface RootIdentity {
42
42
  dev: number;
43
43
  ino: number;
44
44
  birthtimeMs: number;
45
45
  }
46
46
 
47
- const identityOf = (stat: Stats): RootIdentity => ({ dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs });
47
+ export const identityOf = (stat: Stats): RootIdentity => ({ dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs === stat.ctimeMs ? 0 : stat.birthtimeMs });
48
48
 
49
49
  /**
50
50
  * Manages filesystem watching for a single mount.
@@ -481,10 +481,12 @@ export class MountWatcher {
481
481
  private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
482
482
  // birthtimeMs tells a root made again from a removed one whose inode
483
483
  // number it took, unless both were made within one tick of the clock or
484
- // the file system keeps no birth time. A held root's removal shows in its
485
- // link count instead, and where the root isn't held the rename's force
486
- // covers those.
487
- return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
484
+ // either side's birth time can't be read (0: none kept, or one equal to
485
+ // the ctime, as where statx is unavailable or nothing has changed in the
486
+ // root since it was made). A held root's removal shows in its link count
487
+ // instead, and where the root isn't held the rename's force covers those.
488
+ return a.dev === b.dev && a.ino === b.ino
489
+ && (a.birthtimeMs === 0 || b.birthtimeMs === 0 || a.birthtimeMs === b.birthtimeMs);
488
490
  }
489
491
 
490
492
  private isErrno(err: unknown, code: string): boolean {
@@ -33,6 +33,8 @@ export const NOTICE_TAG = {
33
33
  tuneOutWake: 'Tune-out wake',
34
34
  tuneOutCadence: 'Tune-out cadence',
35
35
  tuneOutCancelled: 'Tune-out cancelled',
36
+ focus: 'Focus',
37
+ agentLifecycle: 'Agent lifecycle',
36
38
  standingDispositions: 'Standing dispositions',
37
39
  channelInvitation: 'Channel invitation',
38
40
  system: 'system',
@@ -1285,7 +1285,7 @@ function leftPrepared(batch?: DiscordAwarenessBatch): string {
1285
1285
  * Read and check the ledger file: a missing file is an empty ledger. A file
1286
1286
  * this build can't read for its meaning is refused, never guessed at: one
1287
1287
  * it can't read or parse, one that isn't the shape its version promises,
1288
- * and any version but 2 and 3, version 1 with its own reason.
1288
+ * and any version but 2 and 3.
1289
1289
  */
1290
1290
  function readLedger(path: string):
1291
1291
  | { version: 3; state: LedgerState }
@@ -1318,16 +1318,6 @@ function readLedger(path: string):
1318
1318
  if (!isLegacyLedger(parsed)) throw new Error(`Invalid Discord awareness ledger ${path}`);
1319
1319
  return { version: 2, document: parsed as { version: 2; batches: unknown[] } };
1320
1320
  }
1321
- if (version === 1) {
1322
- throw new Error(
1323
- `Discord awareness ledger ${path} is version 1, which only agent-framework commit b16731b2 wrote ` +
1324
- '(2026-07-15, before any release). Every release rewrites it as version 2 at its next write, and this ' +
1325
- 'build reads versions 2 and 3. Nothing is read from it until the file is moved away. Moving it gives up ' +
1326
- 'only work it still had to send, which this build would retire unsent anyway, since that commit took each ' +
1327
- 'mark out of the file once Discord confirmed it. A mark added just before that process ended, before its ' +
1328
- 'confirmation was written, stays on Discord, and retract can\'t find it',
1329
- );
1330
- }
1331
1321
  throw new Error(
1332
1322
  `Discord awareness ledger ${path} is ${version === undefined ? 'of no version' : `version ${JSON.stringify(version)}`}, ` +
1333
1323
  'which this build can\'t read: it reads versions 2 and 3',
@@ -190,14 +190,17 @@ export interface RequestOnlyRowIndex {
190
190
  /** Index the agent's own rows that follow request-only turns: the rows of its
191
191
  * silent ticks, and the rows of each reply to a `[Continue]`. The two are
192
192
  * independent, so a row can be both. Only stamped rows are keyed, so an
193
- * agent's history walk costs a metadata read per row. */
194
- export function indexRequestOnlyRows(stored: readonly StoredRowLike[], agentName: string): RequestOnlyRowIndex {
193
+ * agent's history walk costs a metadata read per row. `own` is the agent's
194
+ * name, or every participant that is its own voice, as a derived agent's
195
+ * inherited rows are. */
196
+ export function indexRequestOnlyRows(stored: readonly StoredRowLike[], own: string | ReadonlySet<string>): RequestOnlyRowIndex {
197
+ const ownNames = typeof own === 'string' ? new Set([own]) : own;
195
198
  const ticks = new Map<string, { tick: string; text: string }>();
196
199
  const prompts = new Map<string, RequestOnlyPrompt>();
197
200
  for (const row of stored) {
198
- if (row.participant !== agentName) continue;
201
+ if (!ownNames.has(row.participant)) continue;
199
202
  const stamp = silentHeartbeatOf(row.metadata);
200
- const ownTick = stamp !== undefined && stamp.agentName === agentName;
203
+ const ownTick = stamp !== undefined && stamp.agentName === row.participant;
201
204
  const prompt = promptedByOf(row.metadata);
202
205
  if (!ownTick && !prompt) continue;
203
206
  const keys = identityKeys(row.content);
@@ -84,6 +84,13 @@ export function isAnotherAgentsInjection(metadata: unknown, agentName: string):
84
84
  return mark !== undefined && mark.agent !== agentName;
85
85
  }
86
86
 
87
+ /** Whether a row is `agentName`'s own stored injection: its context, which
88
+ * its view keeps however the rest is read, as a shared fork's does, whose
89
+ * view is otherwise its source's (AgentFramework's deriveSharedContext). */
90
+ export function isOwnInjection(metadata: unknown, agentName: string): boolean {
91
+ return storedInjectionMarkOf(metadata)?.agent === agentName;
92
+ }
93
+
87
94
  /**
88
95
  * Whether an injection is stored, rather than placed by `compile()` each
89
96
  * time: an `afterUser` one, unless it opts out