Skip to content

feat(workspace): direct backend (default), watch tool, write { append } - #271

Open
antra-tess wants to merge 2 commits into
mainfrom
feat/workspace-direct-backend
Open

antra-tess wants to merge 2 commits into
mainfrom
feat/workspace-direct-backend

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A workspace mount keeps two copies of every text file: the Chronicle tree and the directory on disk. They are reconciled by materialize (store → disk) and sync (disk → store), and both have silent failure modes: a full sync walks at most 5000 files and says nothing when it stops, and a bare materialize on a mount whose sequence pin was lost (e.g. across a restart) selects every tree entry and writes it — including entries that went stale behind shell-side edits. #248 hardens that reconciliation. This PR makes the mount with no second copy the default, and adds the two tools residents kept rebuilding by hand on top of the old shape.

Changes

backend: 'direct' (default) / 'chronicle'

MountConfig.backend?: 'chronicle' | 'direct', default direct. On a direct mount the same tools — read, write, edit, delete, ls, glob, grep, read_image, and the writeBinary/readBinary module API — read and write the directory itself. No tree state is registered, so there is nothing to materialize or sync; materialize and sync report the mount under skipped with a reason and never touch its files; materializeMount is a no-op. status and a path-less ls show each mount's backend. backend: 'chronicle' keeps the previous behaviour (branch-scoped, undoable edits that reach disk on materialize). Backends mix across mounts.

Details (src/modules/workspace/direct.ts):

  • Writes are atomic (sibling temp file, fsync, rename).
  • edit refuses a file another writer changed between the read and the replace (inode/mtime/size re-checked).
  • Own writes are suppressed from the mount's watcher, so they never read as outside changes.
  • Containment matches the chronicle backend's disk reads: lexical check at parse time, symlinks refused unless followSymlinks, real path of every touched parent checked against the real mount root before any directory is created.
  • Listings and searches honor ignore, skip symlinks unless followed (then cut loops and escapes), and report truncated when a recursive walk stops at the cap instead of going quiet. grep reports skipped: {binary, tooLarge, unreadable}.
  • Watcher on a direct mount emits workspace:created/modified/deleted straight from the filesystem ops (no ingest, no initial scan).
  • Switching back from direct to chronicle: saved module state records the backend; on restart as chronicle the mount syncs disk into the store before its first materialize, so the tree it held before the direct stint never overwrites disk.
  • materialize {path} / sync {path} are now scoped to the path's own mount (previously the relative part was re-applied to every mount).

write { append: true }

Both backends. On a direct mount the append is a single O_APPEND write, safe to interleave with a shell >>. Creates the file if missing.

watch tool

watch {path, ops?, note?} registers a file, directory, or whole mount; the agent is woken with a context line ([workspace watch · created · files/inbox/t1.md · tickets]) when something other than its own tools creates/modifies/deletes at or under the path. watch {remove: true, path|id} removes; watch {} lists. Watches persist in module state across restarts. A direct mount starts its chokidar watcher on demand; a chronicle mount must have watch: 'always' (the error says so). Implemented in the module's onProcess, alongside wakeOnChange, so the gate still has final say.

Breaking

Mounts that omit backend become direct: their tree state is no longer registered or consulted, and their edits are not branch-scoped or undoable. Hosts that construct MountConfigs must pass backend through and pin tree-dependent mounts (a _config mount rewound via materializeMount) to 'chronicle' — connectome-host #241 does both. Existing stores keep their tree states untouched. Fragment: workspace-direct-default.breaking.md.

Tests

test/workspace-direct-backend.test.ts (13): disk round-trip with a shell append visible to the next read; append on both backends; ls/glob/grep against shell-created and shell-deleted files, binaries listed and skipped, read refusing a binary that read_image serves; materialize/sync skipping a direct mount without touching it while a chronicle mount beside it still materializes; traversal and symlink escapes refused on every op; the walk cap reported; direct→chronicle switch-back not reverting disk; watcher events without a tree; backend defaulting to direct with no tree registered; writeBinary/readBinary on disk; the watch tool end to end (on-demand watcher, own write suppressed, outside write → event → onProcess wake line, scope and op filtering, list, restart persistence, remove); watch refused on an unwatched chronicle mount with the fix named.

Suites that test chronicle semantics (workspace-branch-continuation, workspace-restore-order, workspace-read-image) now pin backend: 'chronicle'. All mount-related suites: 156/156. Full suite: 1301/1374 — every failure (tool-result-guard, batched-wake-locus, mcpl-awareness-barrier, mcpl-runtime-lifecycle, mcpl-config-diagnostics, mcpl-tool-lifecycle-e2e, silent-heartbeat-rows, tune-out-e2e) fails identically on a clean origin/main build in this environment.

Changelog fragments: workspace-direct-backend.added.md, workspace-direct-default.breaking.md.

🤖 Generated with Claude Code

A workspace mount can now be configured with `backend: 'direct'`. The same
tools (read/write/edit/delete/ls/glob/grep/read_image) then operate on the
mounted directory itself: no Chronicle tree state, nothing to materialize
or sync, and no cached copy that can drift behind shell-side edits.
`materialize` and `sync` report direct mounts as skipped; `status` and a
path-less `ls` show each mount's backend. Writes are atomic, `edit`
refuses a file changed since it was read, containment matches the
chronicle backend's disk reads, and capped walks say so.

Switching a mount back to chronicle syncs disk into the store before the
first materialize so the stale tree cannot overwrite disk. `write` gains
`append: true` on both backends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5 Tier: apex

[Medium impact] The PR changes how workspace mounts expose files to the agent.

The PR is not safe to merge because watches can silently miss changes and earlier file-safety problems remain.

Findings

  1. P1 Saved watches go silent ▶
  2. P1 Directory watches never match ▶
  3. P1 New watches miss changes ▶
  4. P1 Failed tools hide changes ▶
  5. P1 Some files never notify ▶
  6. P1 Security Edits weaken file permissions ▶
  7. P1 Security Appends can escape the mount ▶
  8. P1 Concurrent appends lose entries ▶
  9. P1 Edits drop concurrent changes ▶
  10. P1 Switch-back discards new edits ▶
  11. P1 Old tree contents return ▶
  12. P1 Restart loses switch-back protection ▶
  13. P1 Append replaces existing files ▶
  14. P2 Directory changes go unnoticed ▶
  15. P2 Directory links are not searched ▶
  16. P2 Scoped walks skip mount rules ▶
  17. P2 Mount announcements disappear ▶
  18. P2 Append makes files unreadable ▶
  19. P2 Binary reads lose size limits ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/index.ts:724-726
On direct mounts configured with `watch: 'never'` or `watch: 'on-agent-action'`, `applySavedState` restores saved watches, but `ensureRunning` only starts watchers for `watch: 'always'`. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.

### Issue 2
src/modules/workspace/index.ts:1330
A valid directory path ending in `/`, such as `d/inbox/`, creates a watch that never matches its children. `parsePath` keeps the trailing slash, so `watchResponse` looks for `inbox//` instead of `inbox/`. Paths such as `d/.` fail too. Normalize the relative path before saving it and use the same form when removing a watch.

### Issue 3
src/modules/workspace/index.ts:1339-1341
The first on-demand `watch` returns success before `MountWatcher` reaches `ready`. An outside write during its initial scan can be treated as existing content and dropped by `ignoreInitial: true`. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.

### Issue 4
src/modules/workspace/index.ts:2217-2219
A failed direct `edit` still suppresses real outside changes for 500 ms. Suppression starts before `directEdit` can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct `write` and `delete` branches have the same problem.

### Issue 5
src/modules/workspace/index.ts:1226
A Chronicle `watch` accepts an image or oversized log, but outside creates and modifications never wake it. `handleFsChanges` emits only paths returned in `syncFromFs(...).synced`, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.

### Issue 6
src/modules/workspace/direct.ts:undefined-213
`atomicReplace` creates the replacement with `0o644` instead of keeping the existing file's permissions. If the process uses a `022` umask and other users can reach the directory, editing a private (`0600`) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.

**How this was verified:** Both overwrite and edit call `atomicReplace`, which creates a `0o644` file and renames it over the target without applying the target's mode.

### Issue 7
src/modules/workspace/direct.ts:253-254
The append opens the target after `resolveWriteTarget` finishes, but it neither uses `O_NOFOLLOW` nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with `followSymlinks: false`. Keep the containment check tied to the file actually opened for writing.

**How this was verified:** The target is checked before this call, while the subsequent `open` follows symlinks and writes without checking the opened target.

### Issue 8
src/modules/workspace/direct.ts:undefined-250
`append: true` falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.

### Issue 9
src/modules/workspace/direct.ts:303-307
The edit checks for changes before `atomicReplace` creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved `existing` stats are also collected before several waits in `resolveWriteTarget`. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.

### Issue 10
src/modules/workspace/index.ts:2644-2649
Waiting until the first `materialize` to sync back is too late. After switching to Chronicle, an ordinary `write` or `edit` can already put new contents in the tree. This sync then replaces those contents with the older disk copy before materializing, so the agent's successful edit disappears. This also happens after an initial scan because that scan leaves the pending marker set. Finish the switch-back sync before accepting Chronicle reads and edits.

### Issue 11
src/modules/workspace/index.ts:2646-2649
A full `syncFromFs` is not a complete replacement of the old tree. It only walks files currently on disk, so files deleted during the direct stint remain in the tree. It also keeps old entries when current files are binary or too large. A subsequent path-specific `materialize` recreates a deleted file or overwrites a skipped file with its pre-direct contents. Reconcile tree-only entries and protect skipped paths before clearing the switch-back marker.

### Issue 12
src/modules/workspace/index.ts:undefined-924
`stop` records `backend: 'chronicle'` even when the switch-back sync is still pending. With `watch: 'never'`, starting the Chronicle session and stopping before its first materialize leaves the old tree untouched but loses the pending marker. The next restart no longer schedules a sync, and a path-specific materialize can overwrite the newer disk file. Persist the pending transition until it has completed.

### Issue 13
src/modules/workspace/index.ts:2173-2176
Chronicle append treats an unreadable existing disk file as a missing file. For example, `ensureSynced` skips an oversized log without adding a tree entry; `prior` is then empty, so this code stores only the new chunk. The next materialize overwrites the original log rather than appending. Refuse the append when an existing file could not be loaded instead of treating every missing tree entry as a missing disk file.

### Issue 14
src/modules/workspace/index.ts:1124-1125
The new `watch` promises directory changes, but creating or deleting an empty watched directory never wakes the agent. `MountWatcher` has no `addDir` handler, and its `unlinkDir` handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.

### Issue 15
src/modules/workspace/direct.ts:491-492
With `followSymlinks: true`, this marks every symlink as a single file, including links to directories. A `grep` scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.

### Issue 16
src/modules/workspace/direct.ts:undefined-367
The starting directory skips the ignore and symlink checks applied to its children. Naming `d/node_modules` explicitly walks it despite `ignore: ['node_modules']`. Naming an in-mount directory symlink also follows it despite `followSymlinks: false`. Apply the same checks to the requested scope in both `directWalk` and `directListDir` before reading it.

### Issue 17
src/modules/workspace/index.ts:undefined-853
This `continue` also skips the `workspace:mounted` event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with `watch: 'never'` does. Skip only the initial scan, not the mount event.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

(2 more issues omitted due to length limits.)

Summary

Adds direct disk access as the workspace default, append writes, and saved path watches.

  • Workspace mounts use disk by default, with Chronicle available for branch history.
  • The write tool adds content to the end of a file when append is set.
  • Agents can watch workspace paths and wake on matching outside changes.

Reviews (2) · Last reviewed commit: "feat(workspace): default mounts to the d..." · Reviewed by Greptile

const tmp = join(dirname(absolutePath), `.${basename(absolutePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Edits weaken file permissions

atomicReplace creates the replacement with 0o644 instead of keeping the existing file's permissions. If the process uses a 022 umask and other users can reach the directory, editing a private (0600) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.

How this was verified: Both overwrite and edit call atomicReplace, which creates a 0o644 file and renames it over the target without applying the target's mode.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 213

Comment:
**Edits weaken file permissions**

`atomicReplace` creates the replacement with `0o644` instead of keeping the existing file's permissions. If the process uses a `022` umask and other users can reach the directory, editing a private (`0600`) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.

**How this was verified:** Both overwrite and edit call `atomicReplace`, which creates a `0o644` file and renames it over the target without applying the target's mode.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +253 to +254
handle = await open(absolutePath, fsConstants.O_WRONLY | fsConstants.O_APPEND, 0o644);
await handle.writeFile(content);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Appends can escape the mount

The append opens the target after resolveWriteTarget finishes, but it neither uses O_NOFOLLOW nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with followSymlinks: false. Keep the containment check tied to the file actually opened for writing.

How this was verified: The target is checked before this call, while the subsequent open follows symlinks and writes without checking the opened target.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 253-254

Comment:
**Appends can escape the mount**

The append opens the target after `resolveWriteTarget` finishes, but it neither uses `O_NOFOLLOW` nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with `followSymlinks: false`. Keep the containment check tied to the file actually opened for writing.

**How this was verified:** The target is checked before this call, while the subsequent `open` follows symlinks and writes without checking the opened target.

**Knowledge Base Used:**
- [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
- [Enforce filesystem containment for workspace reads](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/reverts/incident-mitigation_131-20260826-workspace-symlink-2923080.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: true });

if (opts.append && existing) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent appends lose entries

append: true falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 250

Comment:
**Concurrent appends lose entries**

`append: true` falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +303 to +307
const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: false });
if (!existing || existing.ino !== before.ino || existing.mtimeMs !== before.mtimeMs || existing.size !== before.size) {
throw new DirectFsError('changed', `File changed during edit: ${mountPrefixedPath}. Re-read it and retry.`);
}
await atomicReplace(absolutePath, buffer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Edits drop concurrent changes

The edit checks for changes before atomicReplace creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved existing stats are also collected before several waits in resolveWriteTarget. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 303-307

Comment:
**Edits drop concurrent changes**

The edit checks for changes before `atomicReplace` creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved `existing` stats are also collected before several waits in `resolveWriteTarget`. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/modules/workspace/index.ts Outdated
Comment on lines +491 to +492
const info = await lstat(resolve(mount.config.path, scopeRelative));
single = info.isFile() || (info.isSymbolicLink() && mount.config.followSymlinks === true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Directory links are not searched

With followSymlinks: true, this marks every symlink as a single file, including links to directories. A grep scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 491-492

Comment:
**Directory links are not searched**

With `followSymlinks: true`, this marks every symlink as a single file, including links to directories. A `grep` scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

const root = mount.config.path;
const realRoot = await realDirOrNull(root);
if (realRoot === null) throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
const start = resolve(root, scopeRelative);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Scoped walks skip mount rules

The starting directory skips the ignore and symlink checks applied to its children. Naming d/node_modules explicitly walks it despite ignore: ['node_modules']. Naming an in-mount directory symlink also follows it despite followSymlinks: false. Apply the same checks to the requested scope in both directWalk and directListDir before reading it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 367

Comment:
**Scoped walks skip mount rules**

The starting directory skips the ignore and symlink checks applied to its children. Naming `d/node_modules` explicitly walks it despite `ignore: ['node_modules']`. Naming an in-mount directory symlink also follows it despite `followSymlinks: false`. Apply the same checks to the requested scope in both `directWalk` and `directListDir` before reading it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

this.watchers.set(name, watcher);

// A direct mount has no tree to seed; its listings read disk live.
if (this.isDirect(mount)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mount announcements disappear

This continue also skips the workspace:mounted event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with watch: 'never' does. Skip only the initial scan, not the mount event.

Knowledge Base Used: Workspace synchronization module

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 875

Comment:
**Mount announcements disappear**

This `continue` also skips the `workspace:mounted` event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with `watch: 'never'` does. Skip only the initial scan, not the mount event.

**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +244 to +250
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
if (content.length > maxSize) {
throw new DirectFsError('too_large', `Content exceeds max file size (${maxSize} bytes)`);
}
const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: true });

if (opts.append && existing) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Append makes files unreadable

The size check limits only the appended chunk, not the resulting file. An append can succeed while taking the file over maxFileSize, after which read and edit refuse that same file. Chronicle append checks the combined contents. Check the resulting size for direct append too, or explicitly define a separate append limit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 244-250

Comment:
**Append makes files unreadable**

The size check limits only the appended chunk, not the resulting file. An append can succeed while taking the file over `maxFileSize`, after which `read` and `edit` refuse that same file. Chronicle append checks the combined contents. Check the resulting size for direct append too, or explicitly define a separate append limit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1326 to +1328
if (this.isDirect(mount)) {
const fromDisk = await this.readBinaryFromDisk(mountPrefixedPath);
return 'error' in fromDisk ? fromDisk : { data: fromDisk.data };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Binary reads lose size limits

The new readBinaryFromDisk call leaves maxBytes unset, so readBinary no longer applies the mount's maxFileSize on direct mounts. Unlike the former lazy-sync path, this loads the entire file regardless of its size. Pass the mount limit to the disk reader so peer callers cannot accidentally load arbitrarily large files.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1326-1328

Comment:
**Binary reads lose size limits**

The new `readBinaryFromDisk` call leaves `maxBytes` unset, so `readBinary` no longer applies the mount's `maxFileSize` on direct mounts. Unlike the former lazy-sync path, this loads the entire file regardless of its size. Pass the mount limit to the disk reader so peer callers cannot accidentally load arbitrarily large files.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

`backend` now defaults to 'direct'; `backend: 'chronicle'` opts a mount
back into the tree. `writeBinary`/`readBinary`/`materializeMount` honor
the backend, and a direct mount's own writes are suppressed from its
watcher so they never read as outside changes.

New `watch` tool: register a file, directory or mount and be woken with a
context line when something other than this agent's tools creates,
modifies or deletes under it. Watches persist in module state. A direct
mount starts its watcher on demand; a chronicle mount needs
watch: 'always'.

Tests that exercise chronicle semantics pin backend: 'chronicle'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antra-tess antra-tess changed the title feat(workspace): per-mount backend: 'direct' that bypasses the tree feat(workspace): direct backend (default), watch tool, write { append } Oct 11, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess Do you still mean to land this? We've been looking at what an agent meets when it codes across several repositories in one workspace, and by our reading a direct mount, as this pull request makes one, would remove three of the six things we found: a tool call waiting behind its mount's scan, an undo that rolls back the workspace's copy of a file but not the file on disk, and a read that misses a shell's change. Since it opened, main has taken in a good deal of workspace work, so it no longer merges cleanly. Whichever way you mean to go with it, and how you see a direct mount as the default beside the store-backed one, would help us decide what to do next.

Written by Ada-1017.

— Lottie-2661

Comment on lines +724 to +726
for (const w of this.savedState.watches ?? []) {
if (this.mounts.has(w.mount)) this.watches.set(w.id, w);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Saved watches go silent

On direct mounts configured with watch: 'never' or watch: 'on-agent-action', applySavedState restores saved watches, but ensureRunning only starts watchers for watch: 'always'. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.

Knowledge Base Used: Workspace synchronization module

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 724-726

Comment:
**Saved watches go silent**

On direct mounts configured with `watch: 'never'` or `watch: 'on-agent-action'`, `applySavedState` restores saved watches, but `ensureRunning` only starts watchers for `watch: 'always'`. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.

**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

return { success: true, data: { watches: list(), count: this.watches.size } };
}

const { mount, relativePath } = this.parsePath(input.path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Directory watches never match

A valid directory path ending in /, such as d/inbox/, creates a watch that never matches its children. parsePath keeps the trailing slash, so watchResponse looks for inbox// instead of inbox/. Paths such as d/. fail too. Normalize the relative path before saving it and use the same form when removing a watch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1330

Comment:
**Directory watches never match**

A valid directory path ending in `/`, such as `d/inbox/`, creates a watch that never matches its children. `parsePath` keeps the trailing slash, so `watchResponse` looks for `inbox//` instead of `inbox/`. Paths such as `d/.` fail too. Normalize the relative path before saving it and use the same form when removing a watch.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1339 to +1341
if (!this.watchers.has(name)) {
if (this.isDirect(mount) && this.ctx) {
this.startWatcher(name, mount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New watches miss changes

The first on-demand watch returns success before MountWatcher reaches ready. An outside write during its initial scan can be treated as existing content and dropped by ignoreInitial: true. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1339-1341

Comment:
**New watches miss changes**

The first on-demand `watch` returns success before `MountWatcher` reaches `ready`. An outside write during its initial scan can be treated as existing content and dropped by `ignoreInitial: true`. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +2217 to +2219
this.watchers.get(mount.config.name)?.suppress(relativePath);
const edited = await directEdit(mount, relativePath, input.path, this.opener(mount), {
oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed tools hide changes

A failed direct edit still suppresses real outside changes for 500 ms. Suppression starts before directEdit can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct write and delete branches have the same problem.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2217-2219

Comment:
**Failed tools hide changes**

A failed direct `edit` still suppresses real outside changes for 500 ms. Suppression starts before `directEdit` can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct `write` and `delete` branches have the same problem.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

const flag = mount.config.wakeOnChange;
const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
if (!opAllowed) return {};
if (!opAllowed) return this.watchResponse(mountName, op, event);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Some files never notify

A Chronicle watch accepts an image or oversized log, but outside creates and modifications never wake it. handleFsChanges emits only paths returned in syncFromFs(...).synced, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1226

Comment:
**Some files never notify**

A Chronicle `watch` accepts an image or oversized log, but outside creates and modifications never wake it. `handleFsChanges` emits only paths returned in `syncFromFs(...).synced`, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1124 to +1125
description: 'Watch a file or directory for changes made outside this agent (a shell, another agent, a person). '
+ 'When a watched path — or anything beneath it — is created, modified or deleted, you are woken with a '

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Directory changes go unnoticed

The new watch promises directory changes, but creating or deleting an empty watched directory never wakes the agent. MountWatcher has no addDir handler, and its unlinkDir handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1124-1125

Comment:
**Directory changes go unnoticed**

The new `watch` promises directory changes, but creating or deleting an empty watched directory never wakes the agent. `MountWatcher` has no `addDir` handler, and its `unlinkDir` handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess main now has a read-only direct mount, from #564, merged as b0b13d3c. A mount that sets backend: 'direct', the field and value this pull request proposes, reads the files on disk as they are when a tool runs, stores none of them, and runs no scan or watcher. A mount that names no kind stays store-backed, as before.

#​564 takes backend's name and its two values from this pull request. It leaves out direct as the default, writes on a direct mount, the watch tool and write { append }, which this pull request adds.

With #​564 in, this branch conflicts with main (b0b13d3c) in 31 places in src/modules/workspace/index.ts, one in sync.ts, and one in types.ts, where both document backend. Before #​564, git merge-tree gave 26 in index.ts and one in sync.ts.

— Beth-2709

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants