@animalabs/agent-framework 0.6.8 → 0.6.10

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 (33) hide show
  1. package/dist/src/agent.d.ts +4 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +8 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +14 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +147 -9
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/modules/workspace/index.d.ts +10 -0
  10. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  11. package/dist/src/modules/workspace/index.js +48 -2
  12. package/dist/src/modules/workspace/index.js.map +1 -1
  13. package/dist/src/modules/workspace/types.d.ts +2 -0
  14. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  15. package/dist/src/modules/workspace/types.js.map +1 -1
  16. package/dist/src/modules/workspace/watcher.d.ts +17 -0
  17. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  18. package/dist/src/modules/workspace/watcher.js +143 -7
  19. package/dist/src/modules/workspace/watcher.js.map +1 -1
  20. package/dist/src/types/agent.d.ts +15 -0
  21. package/dist/src/types/agent.d.ts.map +1 -1
  22. package/dist/test/trunk-channel-routing.test.js +5 -1
  23. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  24. package/dist/test/workspace-watcher.test.js +161 -9
  25. package/dist/test/workspace-watcher.test.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +3 -3
  28. package/src/agent.ts +8 -1
  29. package/src/framework.ts +156 -9
  30. package/src/modules/workspace/index.ts +47 -2
  31. package/src/modules/workspace/types.ts +2 -0
  32. package/src/modules/workspace/watcher.ts +160 -7
  33. package/src/types/agent.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@animalabs/chronicle": "^0.2.2",
49
49
  "@animalabs/context-manager": "^0.5.13",
50
- "@animalabs/membrane": "^0.5.73",
50
+ "@animalabs/membrane": "^0.5.74",
51
51
  "chokidar": "^4.0.3",
52
52
  "discord.js": "^14.25.1",
53
53
  "ws": "^8.18.0"
@@ -62,4 +62,4 @@
62
62
  "engines": {
63
63
  "node": ">=20.0.0"
64
64
  }
65
- }
65
+ }
package/src/agent.ts CHANGED
@@ -64,6 +64,10 @@ export class Agent {
64
64
  readonly refusalHandling: AgentConfig['refusalHandling'];
65
65
  /** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */
66
66
  readonly cacheTtl: NonNullable<AgentConfig['cacheTtl']>;
67
+ /** Emit prompt-cache markers on requests (see AgentConfig.promptCaching). */
68
+ readonly promptCaching: boolean;
69
+ /** Prefill scaffold user message (see AgentConfig.prefillUserMessage). */
70
+ readonly prefillUserMessage?: string;
67
71
  /** Provider-specific request parameters forwarded unchanged by Membrane. */
68
72
  readonly providerParams?: Record<string, unknown>;
69
73
  readonly sameRoundThinkTextPolicy: AgentConfig['sameRoundThinkTextPolicy'];
@@ -101,6 +105,8 @@ export class Agent {
101
105
  this.thinking = config.thinking;
102
106
  this.refusalHandling = config.refusalHandling;
103
107
  this.cacheTtl = config.cacheTtl ?? '1h';
108
+ this.promptCaching = config.promptCaching ?? true;
109
+ this.prefillUserMessage = config.prefillUserMessage;
104
110
  this.providerParams = config.providerParams;
105
111
  this.sameRoundThinkTextPolicy = config.sameRoundThinkTextPolicy;
106
112
  this.maxStreamTokens = config.maxStreamTokens ?? 150_000;
@@ -638,8 +644,9 @@ export class Agent {
638
644
  ...(this.thinking !== undefined && { thinking: this.thinking }),
639
645
  },
640
646
  tools: availableTools.length > 0 ? availableTools : undefined,
641
- promptCaching: true,
647
+ promptCaching: this.promptCaching,
642
648
  cacheTtl: this.cacheTtl,
649
+ ...(this.prefillUserMessage && { prefillUserMessage: this.prefillUserMessage }),
643
650
  ...(this.providerParams && { providerParams: this.providerParams }),
644
651
  assistantParticipant: this.name,
645
652
  };
package/src/framework.ts CHANGED
@@ -66,6 +66,29 @@ import type { WorkspaceModule } from './modules/workspace/index.js';
66
66
  import { toolResultDataToHistoryString } from './tool-result-history.js';
67
67
  import { splitProseSegments } from './prose-segments.js';
68
68
 
69
+ /** Detect a supported image media type from magic bytes (the model API
70
+ * rejects mislabeled media types, so trust bytes over extensions).
71
+ * Returns undefined for non-image content. */
72
+ function sniffImageMediaType(data: Buffer): string | undefined {
73
+ if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
74
+ return 'image/png';
75
+ }
76
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
77
+ return 'image/jpeg';
78
+ }
79
+ if (data.length >= 3 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
80
+ return 'image/gif';
81
+ }
82
+ if (
83
+ data.length >= 12 &&
84
+ data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
85
+ data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50
86
+ ) {
87
+ return 'image/webp';
88
+ }
89
+ return undefined;
90
+ }
91
+
69
92
  /**
70
93
  * Tools whose presence suppresses auto-routing of the surrounding prose,
71
94
  * for two distinct reasons:
@@ -1134,7 +1157,9 @@ export class AgentFramework {
1134
1157
  ...channelTools,
1135
1158
  ...gateTools,
1136
1159
  this.buildAgentSettingsTool(),
1137
- ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
1160
+ ...(this.getWorkspaceModule()
1161
+ ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1162
+ : []),
1138
1163
  ];
1139
1164
  }
1140
1165
 
@@ -1915,6 +1940,29 @@ export class AgentFramework {
1915
1940
  },
1916
1941
  };
1917
1942
 
1943
+ /** Synthesized read_image tool — present when a workspace module is
1944
+ * registered. Returns the image as a native image block in the tool
1945
+ * result, so the agent SEES it in the live turn (history keeps a compact
1946
+ * placeholder via the standard tool-result serializer). */
1947
+ private static readonly READ_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
1948
+ name: 'read_image',
1949
+ description:
1950
+ 'View an image file from a workspace mount. The image is returned into ' +
1951
+ 'your context so you can actually see it (e.g. after save_recent_image, ' +
1952
+ 'or for images placed in the workspace by other means). Path is ' +
1953
+ 'mount-prefixed, e.g. "project/photos/cat.png". Supports png/jpeg/gif/webp.',
1954
+ inputSchema: {
1955
+ type: 'object',
1956
+ properties: {
1957
+ path: {
1958
+ type: 'string',
1959
+ description: 'Mount-prefixed image path, e.g. "project/photos/name.png".',
1960
+ },
1961
+ },
1962
+ required: ['path'],
1963
+ },
1964
+ };
1965
+
1918
1966
  private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
1919
1967
  name: 'agent_settings',
1920
1968
  description:
@@ -3774,18 +3822,30 @@ export class AgentFramework {
3774
3822
  ? event.origin.messageId
3775
3823
  : event.eventId;
3776
3824
  const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
3825
+ // Resolve human-readable names from the event origin wherever the
3826
+ // surface provided them — a mind reading this should see WHO addressed
3827
+ // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
3828
+ // tool-argument instructions, where they are the literal values to pass.
3829
+ const origin = (event.origin ?? {}) as Record<string, unknown>;
3830
+ const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
3831
+ const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
3832
+ const place = typeof origin.guildName === 'string' && origin.guildName
3833
+ ? `${channelLabel} in "${origin.guildName}"`
3834
+ : channelLabel;
3777
3835
  content.push({
3778
3836
  type: 'text',
3779
3837
  text:
3780
- `\n[Channel invitation] You were addressed in closed channel ` +
3781
- `"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
3782
- `You received this one message without subscribing. ` +
3838
+ `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
3839
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
3840
+ `Your options:\n` +
3841
+ `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
3842
+ `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
3783
3843
  (maxBackscroll > 0
3784
- ? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
3785
- : `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
3786
- `You may reply once without subscribing. To remain closed, call channel_decline ` +
3787
- `with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
3788
- `optionally set acknowledge to a surface value such as 👀.`,
3844
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
3845
+ : `.\n`) +
3846
+ `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
3847
+ `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
3848
+ `Doing nothing is also fine.`,
3789
3849
  });
3790
3850
  metadata.channelInvitation = true;
3791
3851
  metadata.channelOpen = false;
@@ -5111,6 +5171,11 @@ export class AgentFramework {
5111
5171
  return;
5112
5172
  }
5113
5173
 
5174
+ if (enrichedCall.name === 'read_image') {
5175
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
5176
+ return;
5177
+ }
5178
+
5114
5179
  // Route gate_status tool
5115
5180
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
5116
5181
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -6934,6 +6999,19 @@ export class AgentFramework {
6934
6999
  .then((result) => {
6935
7000
  const durationMs = Date.now() - startTime;
6936
7001
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
7002
+ // A successful channel_open plants the agent's feet in the opened
7003
+ // channel: pin it as this turn's reply locus so plain-text speech
7004
+ // written right after opening routes THERE. Found live 2026-07-21
7005
+ // (Aria bring-up): open → plain-text reply → "no locus (no
7006
+ // home/active channel, defaultPublishChannel null)" on a turn with
7007
+ // no channel trigger (post-restart continuation). Next turn start
7008
+ // re-resolves the locus as usual (startAgentStream set/delete).
7009
+ if (call.name === 'channel_open' && result?.success) {
7010
+ const opened =
7011
+ (result.data as { channelId?: string } | undefined)?.channelId
7012
+ ?? (call.input as { channelId?: string } | undefined)?.channelId;
7013
+ if (opened) this.activeTriggerChannels.set(agentName, opened);
7014
+ }
6937
7015
  this.pushEvent({
6938
7016
  type: 'tool-result',
6939
7017
  callId: call.id,
@@ -7096,6 +7174,75 @@ export class AgentFramework {
7096
7174
  });
7097
7175
  }
7098
7176
 
7177
+ /**
7178
+ * Handle the synthesized `read_image` tool: read image bytes from a
7179
+ * workspace mount and return them as a native MCP-shaped content array
7180
+ * (`[{type:'text'…},{type:'image', data, mimeType}]`). The live-turn
7181
+ * converter (tryNativeToolResultContent → membrane tool_result image
7182
+ * blocks) delivers the actual image to the model; history storage keeps
7183
+ * the standard compact `[image: …]` placeholder.
7184
+ */
7185
+ private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
7186
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
7187
+ const finish = (result: ToolResult): void => {
7188
+ this.emitTrace({
7189
+ type: result.isError ? 'tool:failed' : 'tool:completed',
7190
+ module: 'workspace',
7191
+ tool: call.name,
7192
+ callId: call.id,
7193
+ durationMs: 0,
7194
+ ...(result.isError ? { error: result.error } : {}),
7195
+ });
7196
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
7197
+ };
7198
+ void (async (): Promise<void> => {
7199
+ try {
7200
+ const workspace = this.getWorkspaceModule();
7201
+ if (!workspace || typeof workspace.readBinary !== 'function') {
7202
+ throw new Error('read_image requires a workspace module');
7203
+ }
7204
+ const input = (call.input ?? {}) as { path?: unknown };
7205
+ if (typeof input.path !== 'string' || input.path.length === 0) {
7206
+ throw new Error('read_image: `path` (mount-prefixed) is required');
7207
+ }
7208
+ const read = await workspace.readBinary(input.path);
7209
+ if ('error' in read) throw new Error(read.error);
7210
+ const mediaType = sniffImageMediaType(read.data);
7211
+ if (!mediaType) {
7212
+ throw new Error(
7213
+ `read_image: "${input.path}" does not look like a supported image ` +
7214
+ '(png/jpeg/gif/webp — checked by magic bytes, not extension)',
7215
+ );
7216
+ }
7217
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7218
+ // limit here — resizing is out of scope without an image library.
7219
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7220
+ if (read.data.byteLength > MAX_IMAGE_BYTES) {
7221
+ throw new Error(
7222
+ `read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
7223
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`,
7224
+ );
7225
+ }
7226
+ finish({
7227
+ success: true,
7228
+ data: [
7229
+ {
7230
+ type: 'text',
7231
+ text: `${input.path} (${mediaType}, ${Math.ceil(read.data.byteLength / 1024)} KB):`,
7232
+ },
7233
+ { type: 'image', data: read.data.toString('base64'), mimeType: mediaType },
7234
+ ],
7235
+ });
7236
+ } catch (error) {
7237
+ finish({
7238
+ success: false,
7239
+ error: error instanceof Error ? error.message : String(error),
7240
+ isError: true,
7241
+ });
7242
+ }
7243
+ })();
7244
+ }
7245
+
7099
7246
  /**
7100
7247
  * Handle the synthesized `save_recent_image` tool: locate the requested
7101
7248
  * image blocks in the calling agent's context (blobs re-inlined, counted
@@ -651,6 +651,15 @@ export class WorkspaceModule implements Module {
651
651
  error: err.message,
652
652
  } as ProcessEvent);
653
653
  },
654
+ onReattach: () => {
655
+ mount.watcherError = null;
656
+ void this.initialScan(name);
657
+ this.ctx?.pushEvent({
658
+ type: 'workspace:watcher-reattached',
659
+ mount: name,
660
+ path: mount.config.path,
661
+ } as ProcessEvent);
662
+ },
654
663
  },
655
664
  );
656
665
  watcher.start();
@@ -1018,6 +1027,30 @@ export class WorkspaceModule implements Module {
1018
1027
  };
1019
1028
  }
1020
1029
 
1030
+ /**
1031
+ * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1032
+ * first like the `read` tool). Public API for the framework's synthesized
1033
+ * `read_image` tool and other peer callers that need binary content.
1034
+ */
1035
+ async readBinary(
1036
+ mountPrefixedPath: string,
1037
+ ): Promise<{ data: Buffer } | { error: string }> {
1038
+ let mount: MountState;
1039
+ let relativePath: string;
1040
+ try {
1041
+ ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1042
+ } catch (error) {
1043
+ return { error: error instanceof Error ? error.message : String(error) };
1044
+ }
1045
+ const store = this.getStore();
1046
+ await this.ensureSynced(mount, relativePath);
1047
+ const entry = store.treeGet(mount.treeStateId, relativePath);
1048
+ if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
1049
+ const blob = store.getBlob(entry.blobHash);
1050
+ if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
1051
+ return { data: blob };
1052
+ }
1053
+
1021
1054
  /**
1022
1055
  * Parse a mount-prefixed path into (mountName, relativePath).
1023
1056
  */
@@ -1662,8 +1695,20 @@ export class WorkspaceModule implements Module {
1662
1695
  mount.initialSyncDone = true;
1663
1696
  }
1664
1697
 
1665
- const prefix = relativePath ? relativePath + '/' : undefined;
1666
- const entries = store.treeList(mount.treeStateId, prefix);
1698
+ // If `path` points at a single FILE, grep just that file. Otherwise treat
1699
+ // `path` as a directory prefix (the original behaviour). Previously a file
1700
+ // path produced an empty prefix like "notes.md/" and matched nothing, so
1701
+ // grepping a specific file silently returned zero results.
1702
+ let entries: Array<{ path: string; blobHash: string }>;
1703
+ const fileNode = relativePath
1704
+ ? (store.treeGet(mount.treeStateId, relativePath) as { blobHash?: string } | null)
1705
+ : null;
1706
+ if (fileNode && fileNode.blobHash) {
1707
+ entries = [{ path: relativePath, blobHash: fileNode.blobHash }];
1708
+ } else {
1709
+ const prefix = relativePath ? relativePath + '/' : undefined;
1710
+ entries = store.treeList(mount.treeStateId, prefix);
1711
+ }
1667
1712
 
1668
1713
  for (const entry of entries) {
1669
1714
  if (fileGlob && !fileGlob.test(entry.path)) continue;
@@ -25,6 +25,8 @@ export interface MountConfig {
25
25
  watch?: 'always' | 'on-agent-action' | 'never';
26
26
  /** Debounce window in ms for watch: 'always' mode (default: 300) */
27
27
  watchDebounceMs?: number;
28
+ /** Poll interval in ms for checking watched root identity changes (default: 2000) */
29
+ watchRootPollMs?: number;
28
30
  /**
29
31
  * Simple ignore patterns (NOT full gitignore syntax). Supported forms:
30
32
  * - Exact name match: ".git", "node_modules"
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
- import { mkdirSync } from 'node:fs';
9
+ import { mkdirSync, statSync } from 'node:fs';
10
+ import { basename } from 'node:path';
10
11
  import { type MountConfig } from './types.js';
11
12
 
12
13
  export type FsOp = 'created' | 'modified' | 'deleted';
@@ -30,6 +31,14 @@ export interface WatcherLifecycle {
30
31
  onReady?: () => void;
31
32
  /** Fires on any chokidar-reported error (ENOENT, EACCES, platform faults). */
32
33
  onError?: (err: Error) => void;
34
+ /** Fires after the watcher re-attaches to a replaced root inode. */
35
+ onReattach?: () => void;
36
+ }
37
+
38
+ interface RootIdentity {
39
+ dev: number;
40
+ ino: number;
41
+ birthtimeMs: number;
33
42
  }
34
43
 
35
44
  /**
@@ -42,8 +51,13 @@ export class MountWatcher {
42
51
  // create -> modify collapses to 'created' (single batch); delete always wins.
43
52
  private pendingChanges = new Map<string, FsOp>();
44
53
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
54
+ private rootPollTimer: ReturnType<typeof setInterval> | null = null;
45
55
  private suppressedPaths = new Set<string>();
46
56
  private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
57
+ private rootIdentity: RootIdentity | null = null;
58
+ private detached = false;
59
+ private attaching = false;
60
+ private stopped = true;
47
61
  private readonly debounceMs: number;
48
62
  private readonly config: MountConfig;
49
63
  private readonly onChangeCallback: (changes: FsChange[]) => void;
@@ -64,8 +78,14 @@ export class MountWatcher {
64
78
  * Start watching the filesystem.
65
79
  */
66
80
  start(): void {
67
- if (this.watcher) return;
81
+ if (this.watcher || this.rootPollTimer) return;
82
+
83
+ this.stopped = false;
84
+ this.attach();
85
+ this.startRootPolling();
86
+ }
68
87
 
88
+ private attach(): void {
69
89
  // Read-write mounts: ensure the path exists before chokidar attaches.
70
90
  // chokidar 4's fs.watch-based backend silently fails to fire events when
71
91
  // the watched path OR any of its parent directories don't exist at
@@ -110,21 +130,39 @@ export class MountWatcher {
110
130
  this.watcher.on('add', handleEvent('created'));
111
131
  this.watcher.on('change', handleEvent('modified'));
112
132
  this.watcher.on('unlink', handleEvent('deleted'));
133
+ this.watcher.on('unlinkDir', (dirPath) => {
134
+ if (this.isRootPath(dirPath)) {
135
+ this.detached = true;
136
+ }
137
+ });
113
138
 
114
139
  if (this.lifecycle.onReady) {
115
140
  this.watcher.once('ready', this.lifecycle.onReady);
116
141
  }
117
- if (this.lifecycle.onError) {
118
- this.watcher.on('error', (err) => {
119
- this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
120
- });
121
- }
142
+ this.watcher.on('error', (err) => {
143
+ this.reportError(err);
144
+ });
145
+ this.watcher.on('raw', (event: string, rawPath: string, _details: unknown) => {
146
+ if (event !== 'rename') return;
147
+ // A rename on the watched root's path/basename is the one signal that
148
+ // survives inode reuse on Linux, where unlinkDir/error never fire.
149
+ if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) {
150
+ void this.checkRootLiveness({ force: true });
151
+ }
152
+ });
153
+
154
+ this.captureRootIdentity();
122
155
  }
123
156
 
124
157
  /**
125
158
  * Stop watching.
126
159
  */
127
160
  async stop(): Promise<void> {
161
+ this.stopped = true;
162
+ if (this.rootPollTimer) {
163
+ clearInterval(this.rootPollTimer);
164
+ this.rootPollTimer = null;
165
+ }
128
166
  if (this.debounceTimer) {
129
167
  clearTimeout(this.debounceTimer);
130
168
  this.debounceTimer = null;
@@ -140,6 +178,9 @@ export class MountWatcher {
140
178
  await this.watcher.close();
141
179
  this.watcher = null;
142
180
  }
181
+ this.rootIdentity = null;
182
+ this.detached = false;
183
+ this.attaching = false;
143
184
  }
144
185
 
145
186
  /**
@@ -219,6 +260,108 @@ export class MountWatcher {
219
260
  }, this.debounceMs);
220
261
  }
221
262
 
263
+ private startRootPolling(): void {
264
+ const pollMs = this.config.watchRootPollMs ?? 2000;
265
+ this.rootPollTimer = setInterval(() => {
266
+ void this.checkRootLiveness();
267
+ }, pollMs);
268
+ this.rootPollTimer.unref();
269
+ }
270
+
271
+ private async checkRootLiveness(opts: { force?: boolean } = {}): Promise<void> {
272
+ if (this.stopped || this.attaching) return;
273
+
274
+ let current: RootIdentity;
275
+ try {
276
+ current = this.readRootIdentity();
277
+ } catch (err) {
278
+ if (this.isErrno(err, 'ENOENT')) {
279
+ this.detached = true;
280
+ if (this.config.mode === 'read-write') {
281
+ await this.reattach();
282
+ }
283
+ return;
284
+ }
285
+ this.reportError(err);
286
+ if (opts.force) {
287
+ this.detached = true;
288
+ await this.reattach();
289
+ }
290
+ return;
291
+ }
292
+
293
+ if (!this.rootIdentity && !this.detached) {
294
+ await this.reattach();
295
+ return;
296
+ }
297
+
298
+ if (!opts.force && !this.detached && this.rootIdentity
299
+ && this.sameIdentity(current, this.rootIdentity)) {
300
+ return;
301
+ }
302
+
303
+ await this.reattach();
304
+ }
305
+
306
+ private async reattach(): Promise<void> {
307
+ if (this.attaching || this.stopped) return;
308
+
309
+ this.attaching = true;
310
+ try {
311
+ const oldWatcher = this.watcher;
312
+ this.watcher = null;
313
+ if (oldWatcher) {
314
+ await oldWatcher.close();
315
+ }
316
+ if (this.stopped) return;
317
+
318
+ this.attach();
319
+ if (!this.detached && this.rootIdentity) {
320
+ this.lifecycle.onReattach?.();
321
+ }
322
+ } catch (err) {
323
+ this.reportError(err);
324
+ } finally {
325
+ this.attaching = false;
326
+ }
327
+ }
328
+
329
+ private captureRootIdentity(): void {
330
+ try {
331
+ this.rootIdentity = this.readRootIdentity();
332
+ this.detached = false;
333
+ } catch (err) {
334
+ if (this.isErrno(err, 'ENOENT')) {
335
+ this.rootIdentity = null;
336
+ this.detached = true;
337
+ return;
338
+ }
339
+ this.rootIdentity = null;
340
+ this.detached = false;
341
+ this.reportError(err);
342
+ }
343
+ }
344
+
345
+ private readRootIdentity(): RootIdentity {
346
+ const stat = statSync(this.config.path);
347
+ return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs };
348
+ }
349
+
350
+ private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
351
+ // birthtimeMs is only a secondary tripwire: sub-millisecond replacements
352
+ // can reuse both the inode and timestamp, so raw rename remains essential.
353
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
354
+ }
355
+
356
+ private isErrno(err: unknown, code: string): boolean {
357
+ return typeof err === 'object' && err !== null && 'code' in err
358
+ && (err as NodeJS.ErrnoException).code === code;
359
+ }
360
+
361
+ private reportError(err: unknown): void {
362
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
363
+ }
364
+
222
365
  private toRelative(absolutePath: string): string | null {
223
366
  const base = this.config.path.endsWith('/')
224
367
  ? this.config.path
@@ -228,4 +371,14 @@ export class MountWatcher {
228
371
  }
229
372
  return null;
230
373
  }
374
+
375
+ private isRootPath(absolutePath: string): boolean {
376
+ const root = this.config.path.endsWith('/')
377
+ ? this.config.path.slice(0, -1)
378
+ : this.config.path;
379
+ const candidate = absolutePath.endsWith('/')
380
+ ? absolutePath.slice(0, -1)
381
+ : absolutePath;
382
+ return candidate === root;
383
+ }
231
384
  }
@@ -68,6 +68,23 @@ export interface AgentConfig {
68
68
  */
69
69
  cacheTtl?: '5m' | '1h';
70
70
 
71
+ /**
72
+ * Whether to emit prompt-cache markers (cache_control) on requests.
73
+ * Default true (Anthropic API). Set false for transports that reject
74
+ * cache_control — e.g. Bedrock legacy Claude models, which fail with
75
+ * "your request did not allow prompt caching".
76
+ */
77
+ promptCaching?: boolean;
78
+
79
+ /**
80
+ * Prefill-formatter support: a synthetic user message appended after the
81
+ * conversation (e.g. chapterx's `<cmd>cat untitled.txt</cmd>` CLI-sim
82
+ * scaffold). Forwarded verbatim to membrane's prefill formatters; ignored
83
+ * by native formatting. Part of reproducing a prefill-era bot's exact
84
+ * prompting structure when migrating it into a resident.
85
+ */
86
+ prefillUserMessage?: string;
87
+
71
88
  /** Provider-specific request parameters (for example Responses reasoning
72
89
  * and server-side compaction settings). */
73
90
  providerParams?: Record<string, unknown>;