@animalabs/agent-framework 0.22.4 → 0.23.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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -45,7 +45,16 @@ export interface MountConfig {
45
45
  * `^\..+\.sw[px]$`, `~$` and `\.subl.*\.tmp$`.
46
46
  */
47
47
  ignore?: string[];
48
- /** Whether to follow symlinks (default: false) */
48
+ /**
49
+ * Whether to follow symlinks (default: false). Without it, no symlink on a
50
+ * path is followed: not one at the path itself, and not one among the
51
+ * directories on its way beneath the mount's root, wherever it leads. A path
52
+ * with a symlink among its directories is refused naming the link, and for
53
+ * a link inside the mount, its target. A mount root that is
54
+ * itself a symlink is still the root. With it, a symlink on a path is
55
+ * followed while what it reaches stays inside the mount. Either way, a
56
+ * listing or a sync's walk descends into no symlinked directory.
57
+ */
49
58
  followSymlinks?: boolean;
50
59
  /** Maximum file size in bytes (default: 5MB) */
51
60
  maxFileSize?: number;
@@ -6,8 +6,8 @@
6
6
  */
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
- import { mkdirSync, statSync } from 'node:fs';
10
- import { basename, sep } from 'node:path';
9
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, statSync, type Stats } from 'node:fs';
10
+ import { basename, resolve, sep } from 'node:path';
11
11
  import { passedOverAt } from './observe.js';
12
12
  import { type MountConfig } from './types.js';
13
13
 
@@ -43,6 +43,8 @@ interface RootIdentity {
43
43
  birthtimeMs: number;
44
44
  }
45
45
 
46
+ const identityOf = (stat: Stats): RootIdentity => ({ dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs });
47
+
46
48
  /**
47
49
  * Manages filesystem watching for a single mount.
48
50
  * Handles debouncing, ignore patterns, and write suppression.
@@ -58,11 +60,20 @@ export class MountWatcher {
58
60
  private suppressedPaths = new Set<string>();
59
61
  private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
60
62
  private rootIdentity: RootIdentity | null = null;
63
+ // The root as last attached, held open on Linux (see holdRoot).
64
+ private rootFd: number | null = null;
61
65
  private detached = false;
62
66
  private attaching = false;
63
67
  private stopped = true;
64
68
  private readonly debounceMs: number;
65
69
  private readonly config: MountConfig;
70
+ /**
71
+ * The root as the watcher makes, holds, watches and checks it, and as each
72
+ * event's path is read against: the configured path resolved (a link in it
73
+ * kept), since chokidar hands back the paths it reports normalized, and
74
+ * those of a root configured as `./m` matched none of them.
75
+ */
76
+ private readonly root: string;
66
77
  private readonly onChangeCallback: (changes: FsChange[]) => void;
67
78
  private readonly lifecycle: WatcherLifecycle;
68
79
 
@@ -74,6 +85,7 @@ export class MountWatcher {
74
85
  private readonly others: readonly string[] = [],
75
86
  ) {
76
87
  this.config = config;
88
+ this.root = resolve(config.path);
77
89
  this.debounceMs = config.watchDebounceMs ?? 300;
78
90
  this.onChangeCallback = onChange;
79
91
  this.lifecycle = lifecycle;
@@ -100,7 +112,7 @@ export class MountWatcher {
100
112
  // directories a user asked us only to read would be surprising.
101
113
  if (this.config.mode === 'read-write') {
102
114
  try {
103
- mkdirSync(this.config.path, { recursive: true });
115
+ mkdirSync(this.root, { recursive: true });
104
116
  } catch (err) {
105
117
  this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
106
118
  }
@@ -108,7 +120,7 @@ export class MountWatcher {
108
120
 
109
121
  const ignore = this.config.ignore ?? [];
110
122
 
111
- this.watcher = watch(this.config.path, {
123
+ this.watcher = watch(this.root, {
112
124
  // What a walk passes over by design, by the path relative to the mount:
113
125
  // another mount's directory, the mount's ignore list and editors' swap
114
126
  // and backup files. chokidar 4
@@ -165,13 +177,19 @@ export class MountWatcher {
165
177
  this.watcher.on('error', (err) => {
166
178
  this.reportError(err);
167
179
  });
168
- this.watcher.on('raw', (event: string, rawPath: string, _details: unknown) => {
169
- if (event !== 'rename') return;
170
- // A rename on the watched root's path/basename is the one signal that
171
- // survives inode reuse on Linux, where unlinkDir/error never fire.
172
- if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) {
173
- void this.checkRootLiveness({ force: true });
174
- }
180
+ this.watcher.on('raw', (event: string, rawPath: string | null) => {
181
+ // libuv reports the root's own move as a rename under the root's name,
182
+ // and its removal too once nothing holds it open. An entry beneath the
183
+ // root with that name comes the same way, as does, on macOS, an
184
+ // FSEvents event for the root that carries no modification flags, such
185
+ // as its making just before the watch. A held root tells these apart
186
+ // (checkRootLiveness), so the rename runs the check at once. A root
187
+ // that isn't held can be made again with the removed one's identity,
188
+ // so there the rename forces a re-attach. A rename without a name, as
189
+ // libuv gives for a file whose path it can't read, is passed over.
190
+ if (event !== 'rename' || typeof rawPath !== 'string') return;
191
+ if (basename(rawPath) !== basename(this.root)) return;
192
+ void this.checkRootLiveness({ force: this.rootFd === null });
175
193
  });
176
194
 
177
195
  this.captureRootIdentity();
@@ -196,6 +214,8 @@ export class MountWatcher {
196
214
  this.suppressionTimers.clear();
197
215
  this.suppressedPaths.clear();
198
216
  this.pendingChanges.clear();
217
+ // Before the close, so a close that fails still lets go of the root.
218
+ this.releaseRoot();
199
219
 
200
220
  if (this.watcher) {
201
221
  await this.watcher.close();
@@ -291,6 +311,15 @@ export class MountWatcher {
291
311
  this.rootPollTimer.unref();
292
312
  }
293
313
 
314
+ /**
315
+ * Whether the root is still the directory the watcher attached to, checked
316
+ * every `watchRootPollMs` and at a rename under the root's name. A root
317
+ * replaced at its path, or whose held directory has been removed, is
318
+ * attached to again, and with `force` so is one that looks unchanged. A
319
+ * root that's gone is let go of: a read-write mount makes it again and
320
+ * attaches to it, and a read-only one waits, detached, until a check finds
321
+ * it back.
322
+ */
294
323
  private async checkRootLiveness(opts: { force?: boolean } = {}): Promise<void> {
295
324
  if (this.stopped || this.attaching) return;
296
325
 
@@ -298,8 +327,10 @@ export class MountWatcher {
298
327
  try {
299
328
  current = this.readRootIdentity();
300
329
  } catch (err) {
301
- if (this.isErrno(err, 'ENOENT')) {
330
+ // A held root that has been removed is gone, whatever the path's error.
331
+ if (this.isErrno(err, 'ENOENT') || this.heldRootRemoved()) {
302
332
  this.detached = true;
333
+ this.releaseRoot();
303
334
  if (this.config.mode === 'read-write') {
304
335
  await this.reattach();
305
336
  }
@@ -319,7 +350,7 @@ export class MountWatcher {
319
350
  }
320
351
 
321
352
  if (!opts.force && !this.detached && this.rootIdentity
322
- && this.sameIdentity(current, this.rootIdentity)) {
353
+ && this.sameIdentity(current, this.rootIdentity) && !this.heldRootRemoved()) {
323
354
  return;
324
355
  }
325
356
 
@@ -333,8 +364,15 @@ export class MountWatcher {
333
364
  try {
334
365
  const oldWatcher = this.watcher;
335
366
  this.watcher = null;
367
+ this.releaseRoot();
336
368
  if (oldWatcher) {
337
- await oldWatcher.close();
369
+ try {
370
+ await oldWatcher.close();
371
+ } catch (err) {
372
+ // chokidar removes its listeners before it closes, so the old
373
+ // instance reports nothing more, and the root is attached to again.
374
+ this.reportError(err);
375
+ }
338
376
  }
339
377
  if (this.stopped) return;
340
378
 
@@ -351,9 +389,11 @@ export class MountWatcher {
351
389
 
352
390
  private captureRootIdentity(): void {
353
391
  try {
354
- this.rootIdentity = this.readRootIdentity();
392
+ this.rootIdentity = this.holdRoot();
355
393
  this.detached = false;
356
394
  } catch (err) {
395
+ // What holdRoot opened before it failed isn't held.
396
+ this.releaseRoot();
357
397
  if (this.isErrno(err, 'ENOENT')) {
358
398
  this.rootIdentity = null;
359
399
  this.detached = true;
@@ -365,14 +405,79 @@ export class MountWatcher {
365
405
  }
366
406
  }
367
407
 
408
+ /**
409
+ * The root's identity, with the root held open on Linux until the watcher
410
+ * lets go of it: at a re-attach, when the root is found gone, or at the
411
+ * stop. The held directory then says itself whether it has been removed
412
+ * (heldRootRemoved). A removed directory's inode number can otherwise go
413
+ * to the next directory made, as ext4 gives it, and one held open isn't
414
+ * given out. Elsewhere, or where the root can't be opened as a directory,
415
+ * as without read permission, at the process's file limit or when the
416
+ * path isn't a directory, it isn't held, and its identity is read from its
417
+ * path. So too where the root's link count reads 0 as it's opened: if
418
+ * that's the file system's count for a directory in place, a later check
419
+ * would take the root for removed and re-attach at every poll.
420
+ */
421
+ private holdRoot(): RootIdentity {
422
+ if (process.platform !== 'linux') return this.readRootIdentity();
423
+ let fd: number;
424
+ try {
425
+ // A directory only, so a FIFO at the path fails at once rather than
426
+ // blocking until a writer opens it.
427
+ fd = openSync(this.root, constants.O_RDONLY | constants.O_DIRECTORY);
428
+ } catch {
429
+ return this.readRootIdentity();
430
+ }
431
+ this.rootFd = fd;
432
+ const stats = fstatSync(fd);
433
+ if (stats.nlink === 0) {
434
+ this.releaseRoot();
435
+ return this.readRootIdentity();
436
+ }
437
+ return identityOf(stats);
438
+ }
439
+
440
+ /**
441
+ * Whether the directory the watcher holds has been removed, which its link
442
+ * count says whatever has since been made at its path. On a file system
443
+ * whose inode numbers a server gives out, the hold can't keep a root made
444
+ * again from taking the removed one's number, and a removal by another
445
+ * client shows once this one revalidates the directory's cached
446
+ * attributes. A held directory that can't be read, as a stale one on a
447
+ * network file system, counts as removed.
448
+ */
449
+ private heldRootRemoved(): boolean {
450
+ if (this.rootFd === null) return false;
451
+ try {
452
+ return fstatSync(this.rootFd).nlink === 0;
453
+ } catch {
454
+ return true;
455
+ }
456
+ }
457
+
458
+ private releaseRoot(): void {
459
+ if (this.rootFd === null) return;
460
+ const fd = this.rootFd;
461
+ this.rootFd = null;
462
+ try {
463
+ closeSync(fd);
464
+ } catch (err) {
465
+ // Linux frees the descriptor whatever close returns, so the error is
466
+ // reported and what called this goes on.
467
+ this.reportError(err);
468
+ }
469
+ }
470
+
368
471
  private readRootIdentity(): RootIdentity {
369
- const stat = statSync(this.config.path);
370
- return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs };
472
+ return identityOf(statSync(this.root));
371
473
  }
372
474
 
373
475
  private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
374
- // birthtimeMs is only a secondary tripwire: sub-millisecond replacements
375
- // can reuse both the inode and timestamp, so raw rename remains essential.
476
+ // birthtimeMs tells a root made again from a removed one whose inode
477
+ // number it took, unless both were made within one tick of the clock or
478
+ // the file system keeps no birth time. A held root's removal shows in its
479
+ // link count instead, and where the root isn't held the rename's force
480
+ // covers those.
376
481
  return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
377
482
  }
378
483
 
@@ -389,9 +494,7 @@ export class MountWatcher {
389
494
  // Chokidar hands back platform-separated absolute paths; compare with
390
495
  // the platform separator and normalize the result to the '/'-separated
391
496
  // logical form the rest of the workspace uses. No-op on POSIX.
392
- const base = this.config.path.endsWith(sep)
393
- ? this.config.path
394
- : this.config.path + sep;
497
+ const base = this.root.endsWith(sep) ? this.root : this.root + sep;
395
498
  if (absolutePath.startsWith(base)) {
396
499
  return absolutePath.slice(base.length).split(sep).join('/');
397
500
  }
@@ -399,9 +502,7 @@ export class MountWatcher {
399
502
  }
400
503
 
401
504
  private isRootPath(absolutePath: string): boolean {
402
- const root = this.config.path.endsWith(sep)
403
- ? this.config.path.slice(0, -1)
404
- : this.config.path;
505
+ const root = this.root.endsWith(sep) ? this.root.slice(0, -1) : this.root;
405
506
  const candidate = absolutePath.endsWith(sep)
406
507
  ? absolutePath.slice(0, -1)
407
508
  : absolutePath;
@@ -1,40 +1,70 @@
1
1
  /**
2
- * Stored-row handling for silent heartbeat ticks.
2
+ * Request-only rows: user turns a request carries before a stored response
3
+ * that the store never holds, rendered again by every later compile.
3
4
  *
4
- * A silent tick stores no prompt row (#216): its reply is written straight
5
- * after whatever the window held before it — often the previous assistant
6
- * message, since a suppressed tick leaves no delivery receipt behind. The
7
- * wire formatter merges consecutive same-role messages, so without a turn
8
- * between them the tick would read as an unprompted continuation of the
9
- * earlier response and one assistant message would carry signed thinking
10
- * from two separate responses (a provider 400 when it is the latest
11
- * assistant message; see `Agent.addAssistantResponse`).
5
+ * Two kinds exist, and both are anchored to the response they prompted:
12
6
  *
13
- * Every row a tick writes is therefore stamped with `metadata.silentHeartbeat`
14
- * and, at request-build time only, a terse user turn is rendered immediately
15
- * before the first of the tick's rows that survives compilation. The
16
- * separator is never stored and its position is a function of the stamped
17
- * rows alone, so every compile renders it in the same place (the tick's own
18
- * request ends on the same turn), which keeps the prompt-cache prefix
19
- * stable — unlike a per-compile injection that re-anchors to the newest user
20
- * message.
7
+ * - A silent heartbeat tick stores no prompt row (#216): its reply is written
8
+ * straight after whatever the window held before it — often the previous
9
+ * assistant message, since a suppressed tick leaves no delivery receipt
10
+ * behind. Every row a tick writes is stamped with `metadata.silentHeartbeat`,
11
+ * and a separator carrying the tick's instruction is rendered before the
12
+ * first of the tick's rows that survives compilation.
13
+ * - A compile that ends on the agent's own message gets a trailing
14
+ * `[Continue]` turn (some models reject a trailing assistant message). The
15
+ * reply it prompts is stored straight after that message, so the reply's
16
+ * rows are stamped `metadata.promptedBy` (`RequestOnlyPrompt`), and the turn
17
+ * is rendered before the first of them that survives. A reply follows the
18
+ * prompt when what it was sent ends on that `[Continue]`, or on the tool
19
+ * results of a reply that followed it (`toolLoopPrompt`): a stream start
20
+ * and each round of a stream alike. A reply sent anything else after the
21
+ * results (a message that arrived meanwhile) follows that, and carries no
22
+ * prompt.
23
+ *
24
+ * Without the row, the wire formatter merges consecutive same-role messages:
25
+ * the reply would read as an unprompted continuation of the earlier response,
26
+ * and one assistant message would carry signed thinking from two separate
27
+ * responses (a provider 400 when it is the latest assistant message; see
28
+ * `Agent.addAssistantResponse`). And the request that minted the reply held
29
+ * the row: a provider that binds signed thinking to the prefix it was minted
30
+ * under (context-manager #155) refuses that thinking once a later request
31
+ * drops it. Each row's text is read from the stamp (what the request that
32
+ * minted the reply rendered, so a later change to the constant changes no
33
+ * stored reply's prefix) and its position is a function of the stamped rows
34
+ * alone, so every compile renders it in the same place (the request that
35
+ * minted the reply ends on that same turn), which keeps the prompt-cache
36
+ * prefix and the binding stable — unlike a per-compile injection that
37
+ * re-anchors to the newest user message, or a system prompt that changes for
38
+ * one turn.
21
39
  *
22
40
  * Known limit: compiled messages carry no store provenance, so a stored row
23
41
  * is recognised only by structural identity the provider made unique
24
- * (tool_use ids, thinking signatures). A tick reply with neither — unsigned
25
- * and tool-free — gets no separator and still merges as before. Without
26
- * signed thinking that merge carries no provider-rejection risk; it is only
27
- * a misreading. Reply text is deliberately never used as identity: identical
28
- * replies ("all quiet") would collide, and an ordinary reply with the same
29
- * text could take a tick's separator and rewrite cached history. Exact
30
- * matching for every row would need the compile result to expose each
31
- * message's source rows.
42
+ * (tool_use ids, thinking signatures). A reply with neither — unsigned and
43
+ * tool-free — gets no row and still merges as before. Without signed thinking
44
+ * that merge carries no provider-rejection risk; it is only a misreading.
45
+ * Reply text is deliberately never used as identity: identical replies ("all
46
+ * quiet") would collide, and an ordinary reply with the same text could take
47
+ * another reply's row and rewrite cached history. Exact matching for every
48
+ * row would need the compile result to expose each message's source rows.
32
49
  */
33
50
  import type { ContentBlock, NormalizedMessage } from '@animalabs/membrane';
34
51
 
35
- /** Request-only separator text. Constant on purpose: it carries no time or
36
- * id, so it is byte-identical on every compile. */
37
- export const SILENT_HEARTBEAT_SEPARATOR = '[heartbeat tick]';
52
+ /** Request-only separator a silent tick's request ends on. It carries the
53
+ * tick's instruction, so the tick's own request reads it as its prompt, and
54
+ * every later compile renders it again before the tick's first surviving row.
55
+ * Constant on purpose: it carries no time or id. Each tick's rows record the
56
+ * text their request rendered (`SilentHeartbeatStamp.separator`), so changing
57
+ * this constant changes only ticks that open afterwards. */
58
+ export const SILENT_HEARTBEAT_SEPARATOR =
59
+ '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
60
+ 'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.';
61
+
62
+ /** The separator ticks rendered before their rows recorded its text: rows
63
+ * stamped without `separator` still render it, byte for byte. */
64
+ const LEGACY_SILENT_HEARTBEAT_SEPARATOR = '[heartbeat tick]';
65
+
66
+ /** Request-only turn a compile that ends on the agent's own message gets. */
67
+ export const CONTINUE_ROW = '[Continue]';
38
68
 
39
69
  /** Identity of one authenticated silent heartbeat tick. */
40
70
  export interface SilentHeartbeatTick {
@@ -50,20 +80,63 @@ export interface SilentHeartbeatTick {
50
80
  * (tool_result rows are stored as `user`). */
51
81
  export interface SilentHeartbeatStamp extends SilentHeartbeatTick {
52
82
  agentName: string;
83
+ /** The separator text the tick's request rendered, which later compiles
84
+ * render again. Absent on rows stamped before it was recorded; they render
85
+ * the bare marker those requests carried. */
86
+ separator?: string;
87
+ }
88
+
89
+ /** One tick's key in an index or a rendered window: the server and its event
90
+ * id together, since two servers' events can share an id. */
91
+ export function tickKey(tick: SilentHeartbeatTick): string {
92
+ return JSON.stringify([tick.serverId, tick.eventId]);
53
93
  }
54
94
 
55
95
  /** The tick stamp on a stored row's metadata, if any. */
56
96
  export function silentHeartbeatOf(metadata: unknown): SilentHeartbeatStamp | undefined {
57
97
  const stamp = (metadata as { silentHeartbeat?: unknown } | undefined)?.silentHeartbeat;
58
98
  if (!stamp || typeof stamp !== 'object') return undefined;
59
- const { eventId, serverId, agentName } = stamp as Record<string, unknown>;
99
+ const { eventId, serverId, agentName, separator } = stamp as Record<string, unknown>;
60
100
  return typeof eventId === 'string' && typeof serverId === 'string' && typeof agentName === 'string'
61
- ? { eventId, serverId, agentName }
101
+ ? { eventId, serverId, agentName, ...(typeof separator === 'string' ? { separator } : {}) }
62
102
  : undefined;
63
103
  }
64
104
 
105
+ /** A request-only turn a request ended on before the agent's reply, as
106
+ * `metadata.promptedBy` on the rows of each reply that follows it: `turn`
107
+ * names the one prompt, and `text` is what the request rendered. */
108
+ export interface RequestOnlyPrompt {
109
+ turn: string;
110
+ text: string;
111
+ }
112
+
113
+ /** The stamps on a row a turn stores after a request-only turn
114
+ * (`silentHeartbeatOf`, `promptedByOf` read them back). A type alias, so a
115
+ * value fits `MessageMetadata`'s index signature without a cast, and a
116
+ * misspelled key fails to compile. */
117
+ export type RequestOnlyStamps = {
118
+ silentHeartbeat?: SilentHeartbeatStamp;
119
+ promptedBy?: RequestOnlyPrompt;
120
+ };
121
+
122
+ /** The request-only prompt a stored row replies to, if any. */
123
+ export function promptedByOf(metadata: unknown): RequestOnlyPrompt | undefined {
124
+ const prompt = (metadata as { promptedBy?: unknown } | undefined)?.promptedBy;
125
+ if (!prompt || typeof prompt !== 'object') return undefined;
126
+ const { turn, text } = prompt as Record<string, unknown>;
127
+ return typeof turn === 'string' && typeof text === 'string' ? { turn, text } : undefined;
128
+ }
129
+
65
130
  export function silentHeartbeatSeparatorTurn(): NormalizedMessage {
66
- return { participant: 'user', content: [{ type: 'text', text: SILENT_HEARTBEAT_SEPARATOR }] };
131
+ return requestOnlyTurn(SILENT_HEARTBEAT_SEPARATOR);
132
+ }
133
+
134
+ export function continueTurn(): NormalizedMessage {
135
+ return requestOnlyTurn(CONTINUE_ROW);
136
+ }
137
+
138
+ function requestOnlyTurn(text: string): NormalizedMessage {
139
+ return { participant: 'user', content: [{ type: 'text', text }] };
67
140
  }
68
141
 
69
142
  /**
@@ -76,7 +149,7 @@ export function silentHeartbeatSeparatorTurn(): NormalizedMessage {
76
149
  function identityKeys(content: ContentBlock[]): string[] {
77
150
  const keys: string[] = [];
78
151
  for (const block of content) {
79
- if (block.type === 'tool_use' && typeof block.id === 'string' && block.id) keys.push(`tool_use:${block.id}`);
152
+ if (block.type === 'tool_use' && typeof block.id === 'string' && block.id) keys.push(toolUseKey(block.id));
80
153
  else if (block.type === 'thinking' && typeof block.signature === 'string' && block.signature) {
81
154
  keys.push(`thinking:${block.signature}`);
82
155
  } else if (block.type === 'redacted_thinking' && typeof block.data === 'string' && block.data) {
@@ -86,56 +159,108 @@ function identityKeys(content: ContentBlock[]): string[] {
86
159
  return keys;
87
160
  }
88
161
 
162
+ function toolUseKey(id: string): string {
163
+ return `tool_use:${id}`;
164
+ }
165
+
89
166
  export interface StoredRowLike {
90
167
  participant: string;
91
168
  content: ContentBlock[];
92
169
  metadata?: unknown;
93
170
  }
94
171
 
95
- export interface SilentTickIndex {
96
- /** Identity key of every assistant row this agent stored during a tick → tick event id. */
97
- rows: Map<string, string>;
98
- /** Ticks this agent has already stored rows for (its own, not another resident's). */
99
- started: Set<string>;
172
+ export interface RequestOnlyRowIndex {
173
+ /** Identity key of every assistant row this agent stored during a silent
174
+ * tick → the tick (`tickKey`), and the separator text to render before
175
+ * its first surviving row. */
176
+ ticks: Map<string, { tick: string; text: string }>;
177
+ /** Identity key of every assistant row this agent stored in reply to a
178
+ * request-only prompt → that prompt. */
179
+ prompts: Map<string, RequestOnlyPrompt>;
100
180
  }
101
181
 
102
- /** Index the rows this agent stored during silent ticks. */
103
- export function indexSilentTickRows(stored: readonly StoredRowLike[], agentName: string): SilentTickIndex {
104
- const rows = new Map<string, string>();
105
- const started = new Set<string>();
182
+ /** Index the agent's own rows that follow request-only turns: the rows of its
183
+ * silent ticks, and the rows of each reply to a `[Continue]`. The two are
184
+ * independent, so a row can be both. Only stamped rows are keyed, so an
185
+ * agent's history walk costs a metadata read per row. */
186
+ export function indexRequestOnlyRows(stored: readonly StoredRowLike[], agentName: string): RequestOnlyRowIndex {
187
+ const ticks = new Map<string, { tick: string; text: string }>();
188
+ const prompts = new Map<string, RequestOnlyPrompt>();
106
189
  for (const row of stored) {
107
- const stamp = silentHeartbeatOf(row.metadata);
108
- if (!stamp || stamp.agentName !== agentName) continue;
109
- started.add(stamp.eventId);
110
190
  if (row.participant !== agentName) continue;
111
- for (const key of identityKeys(row.content)) rows.set(key, stamp.eventId);
191
+ const stamp = silentHeartbeatOf(row.metadata);
192
+ const ownTick = stamp !== undefined && stamp.agentName === agentName;
193
+ const prompt = promptedByOf(row.metadata);
194
+ if (!ownTick && !prompt) continue;
195
+ const keys = identityKeys(row.content);
196
+ if (ownTick) {
197
+ const tick = { tick: tickKey(stamp), text: stamp.separator ?? LEGACY_SILENT_HEARTBEAT_SEPARATOR };
198
+ for (const key of keys) ticks.set(key, tick);
199
+ }
200
+ if (prompt) for (const key of keys) prompts.set(key, prompt);
112
201
  }
113
- return { rows, started };
202
+ return { ticks, prompts };
114
203
  }
115
204
 
116
205
  /**
117
- * Insert the separator turn immediately before the first surviving row of
118
- * each tick in a compiled window — the tick's first row when it is rendered,
119
- * otherwise whichever later row a strategy kept. Rows folded into summaries
206
+ * The prompt a reply continues when what it is sent ends on `last`: if
207
+ * `last` holds tool results, the prompt of the reply that called those tools
208
+ * (this reply continues that reply's tool loop); otherwise none. Read from
209
+ * the stored stamps, so it doesn't depend on anything held in memory. A
210
+ * request that ends on its own `[Continue]` mints a new prompt instead.
211
+ */
212
+ export function toolLoopPrompt(
213
+ last: NormalizedMessage | undefined,
214
+ index: RequestOnlyRowIndex,
215
+ ): RequestOnlyPrompt | undefined {
216
+ if (!last) return undefined;
217
+ for (const block of last.content) {
218
+ if (block.type !== 'tool_result') continue;
219
+ const prompt = index.prompts.get(toolUseKey(block.toolUseId));
220
+ if (prompt) return prompt;
221
+ }
222
+ return undefined;
223
+ }
224
+
225
+ export interface RenderedRequestOnlyRows {
226
+ messages: NormalizedMessage[];
227
+ /** The ticks (`tickKey`) whose separator this window renders. A tick none
228
+ * of whose rows survive compilation renders none. */
229
+ separatedTicks: Set<string>;
230
+ }
231
+
232
+ /**
233
+ * Insert the request-only turns into a compiled window, each immediately
234
+ * before the first of its rows that survives compilation (the first row when
235
+ * it is rendered, otherwise whichever later row a strategy kept): a tick's
236
+ * separator, and the prompt each reply to `[Continue]` followed, after any
237
+ * separator, as the reply's request had them. Rows folded into summaries
120
238
  * simply do not match; ordinary messages pass through untouched.
121
239
  */
122
- export function separateSilentHeartbeatTicks(
240
+ export function renderRequestOnlyRows(
123
241
  messages: NormalizedMessage[],
124
- rows: ReadonlyMap<string, string>,
242
+ index: RequestOnlyRowIndex,
125
243
  agentName: string,
126
- ): NormalizedMessage[] {
127
- if (rows.size === 0) return messages;
128
- const separated = new Set<string>();
244
+ ): RenderedRequestOnlyRows {
245
+ const separatedTicks = new Set<string>();
246
+ const promptedTurns = new Set<string>();
247
+ if (index.ticks.size === 0 && index.prompts.size === 0) return { messages, separatedTicks };
129
248
  const out: NormalizedMessage[] = [];
130
249
  for (const message of messages) {
131
250
  if (message.participant === agentName) {
132
- const eventId = identityKeys(message.content).map((key) => rows.get(key)).find((id) => id !== undefined);
133
- if (eventId !== undefined && !separated.has(eventId)) {
134
- separated.add(eventId);
135
- out.push(silentHeartbeatSeparatorTurn());
251
+ const keys = identityKeys(message.content);
252
+ const tick = keys.map((key) => index.ticks.get(key)).find((t) => t !== undefined);
253
+ if (tick !== undefined && !separatedTicks.has(tick.tick)) {
254
+ separatedTicks.add(tick.tick);
255
+ out.push(requestOnlyTurn(tick.text));
256
+ }
257
+ const prompt = keys.map((key) => index.prompts.get(key)).find((p) => p !== undefined);
258
+ if (prompt !== undefined && !promptedTurns.has(prompt.turn)) {
259
+ promptedTurns.add(prompt.turn);
260
+ out.push(requestOnlyTurn(prompt.text));
136
261
  }
137
262
  }
138
263
  out.push(message);
139
264
  }
140
- return out;
265
+ return { messages: out, separatedTicks };
141
266
  }