@rjfan/dsh-presence 0.19.0 → 0.20.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.
package/index.js CHANGED
@@ -4,13 +4,12 @@
4
4
  import z from '@deepseek-ai/schemastery';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { PresencePool } from './src/pool.js';
7
+ import { JobStore } from './src/job-store.js';
7
8
  import { definePresenceTools } from './src/tools.js';
8
9
  import { presenceRoutes } from './src/api.js';
9
- import { activeWorkspace } from './src/workspaces.js';
10
10
  import { shared } from './src/shared.js';
11
11
  import { mountsRoot, parseMountPath } from './src/mounts.js';
12
- import { installToolInterceptor } from './src/intercept.js';
13
- import { lookupObservation, recordObservation } from './src/observed-store.js';
12
+ import { installObservationBridge, installToolInterceptor } from './src/intercept.js';
14
13
  import { TunnelStore } from './src/tunnel-store.js';
15
14
  import { PortDiscovery } from './src/port-discovery.js';
16
15
  import { installJobNotifier } from './src/job-notifier.js';
@@ -70,7 +69,11 @@ export function apply(ctx, config = {}) {
70
69
  ...config,
71
70
  };
72
71
 
73
- const pool = new PresencePool(settings);
72
+ // The job registry outlives the process: detached jobs are still on the
73
+ // machine after a restart, and the store is what makes them visible again
74
+ // (as `unknown` until a pid probe proves otherwise).
75
+ const jobStore = new JobStore();
76
+ const pool = new PresencePool(settings, { jobStore });
74
77
  shared.pool = pool;
75
78
  shared.mountsRoot = mountsRoot();
76
79
  // D1/D2: saved forwards and the listening-port scanner share one lifecycle
@@ -94,6 +97,7 @@ export function apply(ctx, config = {}) {
94
97
  // D1/D2 owners, constructed above with the pool.
95
98
  tunnelStore,
96
99
  portDiscovery,
100
+ jobStore,
97
101
  };
98
102
  ctx.inject(['agents'], (scope) => {
99
103
  late.agents = scope.agents;
@@ -121,88 +125,22 @@ export function apply(ctx, config = {}) {
121
125
  // The harness read-before-edit policy is event-driven: it records a target as
122
126
  // "seen" only when a tool dispatches `fs/observed`. presence_read/write/edit
123
127
  // are not the built-in fs tools, so they publish the observation themselves —
124
- // otherwise an agent that read a file with presence_read still hits
125
- // "file has not been read" when it tries the built-in edit.
128
+ // otherwise an agent that read a file with presence_read still hits "file has
129
+ // not been read" when it tries the built-in edit.
126
130
  //
127
- // The guard's own store is a WeakMap keyed by the live Session, so a resumed or
128
- // compacted agent loses it. We also persist (session, target) -> version and,
129
- // just before a built-in edit/write, re-publish the observation when the file
130
- // is still at that exact version. A changed file is never auto-approved.
131
- const sessionIdOf = (exec) => exec?.agent?.session?.id ?? exec?.agent?.session?.header?.id ?? null;
132
-
133
- const resolveTarget = async (exec, path) => {
134
- const fs = late.fs;
135
- if (!fs || typeof path !== 'string' || path === '') return null;
136
- const cwd = await cwdOf(exec);
137
- const target = await fs.resolve(path, cwd ? { cwd } : undefined);
138
- return target ?? null;
139
- };
140
-
141
- const observe = async (exec, path, options = {}) => {
142
- try {
143
- const target = await resolveTarget(exec, path);
144
- if (!target) return;
145
- const stat = await late.fs.stat(target);
146
- const observation = stat ? { kind: 'present', version: stat.version } : { kind: 'absent' };
147
- ctx.emit('fs/observed', target, observation, { agent: exec.agent });
148
- if (stat && options.remember !== false) {
149
- recordObservation(sessionIdOf(exec), String(target.targetKey ?? ''), stat.version);
150
- }
151
- } catch {
152
- /* the observation is an optimization, never a failure */
153
- }
154
- };
155
-
131
+ // The bridge (src/intercept.js) canonicalizes every path spelling to
132
+ // `presence://host/path`, persists (session, canonical target) -> version and,
133
+ // just before a built-in edit/write, re-publishes the observation when the
134
+ // file is still at that exact version. That repairs the guard after
135
+ // compaction, when its own WeakMap is gone; a changed file is never
136
+ // auto-approved.
156
137
  ctx.inject(['fs'], (scope) => {
157
138
  late.fs = scope.fs;
158
139
  scope.effect(() => () => {
159
140
  late.fs = undefined;
160
141
  });
161
142
  });
162
-
163
- // Built-in read/read_image: remember what the session has seen, so the record
164
- // survives compaction even though the guard's WeakMap does not.
165
- ctx.on('tools/post-execute', async (exec, result, next) => {
166
- try {
167
- if (!result?.isError && (exec?.name === 'read' || exec?.name === 'read_image')) {
168
- const path = exec.arguments?.file_path;
169
- const target = await resolveTarget(exec, path);
170
- if (target) {
171
- const stat = await late.fs?.stat(target);
172
- if (stat) recordObservation(sessionIdOf(exec), String(target.targetKey ?? ''), stat.version);
173
- }
174
- }
175
- } catch {
176
- /* best effort */
177
- }
178
- return next();
179
- });
180
-
181
- // Built-in edit/write on a remote target: if this session read the file at the
182
- // same version we recorded, re-publish the observation so the guard passes.
183
- ctx.on('tools/pre-execute', async (exec, next) => {
184
- try {
185
- if (exec?.name === 'edit' || exec?.name === 'write') {
186
- const path = exec.arguments?.file_path;
187
- if (typeof path === 'string' && late.fs) {
188
- const sessionId = sessionIdOf(exec);
189
- const target = await resolveTarget(exec, path);
190
- if (target && sessionId) {
191
- const recorded = lookupObservation(sessionId, String(target.targetKey ?? ''));
192
- if (recorded) {
193
- const stat = await late.fs.stat(target);
194
- if (stat && stat.version === recorded) {
195
- ctx.emit('fs/observed', target, { kind: 'present', version: stat.version }, { agent: exec.agent });
196
- }
197
- }
198
- }
199
- }
200
- }
201
- } catch {
202
- /* best effort: a failed repair leaves the original guard behavior */
203
- }
204
- return next();
205
- });
143
+ const { observe } = installObservationBridge(ctx, { cwdOf, late });
206
144
 
207
145
  for (const definition of definePresenceTools(pool, settings, { cwdOf, observe })) {
208
146
  ctx.tools.register(definition);
@@ -285,7 +223,20 @@ export function apply(ctx, config = {}) {
285
223
  });
286
224
  });
287
225
 
288
- // Tell the model what is reachable without spending tokens on every alias.
226
+ // ---------------------------------------------------------------- prompts
227
+ //
228
+ // The static context is deployment-wide and token-cheap. 0.20 makes the
229
+ // session-scope sentence explicit: a LOCAL session and a session inside a
230
+ // remote workspace need opposite rules, and the 0.19 wording ("if your
231
+ // working directory is under the mounts root...") let a local session assume
232
+ // it was remote.
233
+ const STATIC_GUIDANCE =
234
+ '- Which tool: inside the workspace mount use the built-in read/write/edit/grep/glob (grep/glob are routed to the remote machine); absolute paths outside it (/data, GPFS, /scratch) need presence_read/write/edit/stat/search/glob, and listing or running commands there needs presence_exec (e.g. presence_exec {command:"ls -la /data"}); present and Office only see local files, so presence_export first.\n' +
235
+ '- Long jobs: presence_exec {background:true} then presence_jobs {action:"status"|"wait"}, with presence_tail to follow the log. Interactive shells: presence_terminal (tmux sessions survive reconnects). Do not start nohup/tmux new-session/& inside a foreground presence_exec for long work — a login-node session can be cleaned up; background:true already detaches with setsid, and on scheduler-managed clusters use the cluster launcher (rlaunch -d, salloc/sbatch).\n' +
236
+ '- Output: \\r progress bars are normalized by default (progress:"all"/"strip" available); output over the cap is spilled to a remote file whose path is returned — presence_tail it.\n' +
237
+ '- Destructive commands: dry-run first; confirm:true runs the whole command, confirm:"partial" runs only the safe segments; prefer presence_trash.\n' +
238
+ '- Hosts: transient failures are retried, then a host may show degraded with the last error; presence_hosts marks the current workspace machine.';
239
+
289
240
  ctx.inject(['systemPrompt'], (scope) => {
290
241
  scope.effect(() =>
291
242
  scope.systemPrompt.context({
@@ -298,8 +249,13 @@ export function apply(ctx, config = {}) {
298
249
  const live = connected.length > 0 ? `connected now: ${connected.join(', ')}` : 'none connected yet';
299
250
  return (
300
251
  `Presence ${VERSION ?? ''}${VERSION ? ':' : ''} ${hosts.length} remote machines from ~/.ssh/config (${live}). ` +
301
- 'If your working directory is under the presence mounts root, this session runs against that remote machine: ' +
302
- 'read/write/edit, the file tree, and grep/glob all reach it (grep/glob are routed to the remote machine automatically). ' +
252
+ 'Session scope — decide this first; the two cases need opposite behaviour. ' +
253
+ '(a) If your working directory is under the presence mounts root (`~/.dsh/presence/mounts/<host>/<path>`), ' +
254
+ 'this session RUNS IN that remote workspace: built-in read/write/edit, the file tree, and grep/glob all reach it ' +
255
+ '(grep/glob are routed to the remote machine automatically). ' +
256
+ '(b) If your working directory is NOT under that root, this session is LOCAL: built-in tools touch only local files, ' +
257
+ 'so every presence_* call must pass `host` explicitly (or have PRESENCE_HOST configured once), and remote output is ' +
258
+ 'never your workspace. ' +
303
259
  'TO CHANGE A REMOTE FILE use the built-in `edit`/`write` tools or presence_edit/presence_write — never `sed -i`, ' +
304
260
  '`perl -pi`, or shell redirects, which bypass the atomic write, the ambiguity check and the version guard. ' +
305
261
  'DESTRUCTIVE shell commands (rm -r/-f, git clean -fdx, find -delete, shred, dd of=) are dry-run first and need confirm:true; ' +
@@ -310,19 +266,112 @@ export function apply(ctx, config = {}) {
310
266
  'read_image works directly on remote images (mount path or presence:// URL) as long as the model accepts image input. ' +
311
267
  'Built-in read/write/edit and presence_read/write/edit both reach the remote workspace and share the read-before-edit guard; ' +
312
268
  'that guard is repaired automatically after compaction, so a file you already read stays editable. Prefer the built-in tools inside the workspace and presence_* outside it. ' +
313
- 'For long work start it with presence_exec {background:true}, then use presence_jobs {action:"wait"} instead of sleep+poll, ' +
269
+ 'For long work start it with presence_exec {background:true}, then use presence_jobs {action:"wait"} or presence_wait instead of sleep+poll, ' +
314
270
  'and presence_jobs {action:"list"|"stop"} to manage this session\'s jobs. ' +
315
271
  'The mount path is virtual: Explorer, Office, Python and native open cannot see remote files — call presence_export first, then present the returned local path. ' +
316
272
  'Paths may be given relative to the workspace root, as a presence:// URL, or as the local mount path.' +
317
- '\n\nGuidance:\n' +
318
- '- Which tool: inside the workspace mount use the built-in read/write/edit/grep/glob (grep/glob are routed to the remote machine); absolute paths outside it (/data, GPFS, /scratch) need presence_read/write/edit/ls/stat/search/glob; present and Office only see local files, so presence_export first.\n' +
319
- '- Long jobs: presence_exec {background:true} then presence_jobs {action:"status"|"wait"}, with presence_tail to follow the log. Interactive shells: presence_terminal (tmux sessions survive reconnects). Do not start nohup/tmux new-session/& inside a foreground presence_exec for long work — a login-node session can be cleaned up; background:true already detaches with setsid, and on scheduler-managed clusters use the cluster launcher (rlaunch -d, salloc/sbatch).\n' +
320
- '- Output: \\r progress bars are normalized by default (progress:"all"/"strip" available); output over the cap is spilled to a remote file whose path is returned — presence_tail it.\n' +
321
- '- Destructive commands: dry-run first; confirm:true runs the whole command, confirm:"partial" runs only the safe segments; prefer presence_trash.\n' +
322
- '- Hosts: transient failures are retried, then a host may show degraded with the last error; presence_hosts marks the current workspace machine.'
273
+ `\n\nGuidance:\n${STATIC_GUIDANCE}` +
274
+ '\n\nSession scope rules:\n' +
275
+ '- Local session: the globally last-opened remote workspace is not bound to this session; pass host explicitly on every presence_* call, and treat presence_exec output as remote data — not as files in your working directory.\n' +
276
+ '- Remote-workspace session: prefer the built-in read/write/edit/grep/glob inside the mounted workspace; use presence_read/write/edit/stat/search/glob for absolute paths elsewhere (presence_exec for listing/commands), and pass host explicitly for any machine other than the mount host.\n' +
277
+ '- presence_hosts marks the machine this session is mounted on (if any) and the explicit default host; check it before assuming a machine.'
323
278
  );
324
279
  },
325
280
  }),
326
281
  );
327
282
  });
283
+
284
+ // Per-agent session scope. No static text can know where *this* agent runs, so
285
+ // resolve the agent's working directory once when it is created and register a
286
+ // scoped system-prompt section (the pattern `dsh-file-reference-local` uses:
287
+ // `agent.ctx.inject([...])` + `scope.systemPrompt.section(...)`), disposed with
288
+ // the agent. The section text is a function so a `cd` into/out of a mounted
289
+ // workspace updates it; the resolved cwd is only the fallback.
290
+ const sessionScopeFibers = new Map();
291
+ const sessionScopeText = (cwd) => {
292
+ if (!cwd) return '';
293
+ const mounted = parseMountPath(cwd);
294
+ if (mounted) {
295
+ return (
296
+ `This session runs in the remote workspace ${mounted.host}:${mounted.path}; ` +
297
+ 'built-in read/write/edit, the file tree and grep/glob reach it. ' +
298
+ `presence_* calls may omit host for ${mounted.host} only — pass host explicitly for any other machine — ` +
299
+ 'and remote output is not your local workspace.'
300
+ );
301
+ }
302
+ return (
303
+ `This session is LOCAL (cwd=${JSON.stringify(cwd)}). Presence tools are optional: ` +
304
+ 'pass host explicitly on every presence_* call (or configure PRESENCE_HOST once), and never treat remote output ' +
305
+ 'as the workspace or as a local path.'
306
+ );
307
+ };
308
+ const installSessionScope = (agent) => {
309
+ if (!agent?.ctx || sessionScopeFibers.has(agent)) return sessionScopeFibers.get(agent) ?? null;
310
+ let fiber = null;
311
+ try {
312
+ fiber = agent.ctx.inject(['systemPrompt', 'workingDirectory'], async (scope) => {
313
+ let cwd = null;
314
+ try {
315
+ cwd = (await scope.workingDirectory.ensure(agent)) ?? null;
316
+ } catch {
317
+ cwd = null;
318
+ }
319
+ scope.systemPrompt.section({
320
+ name: 'presence:session-scope',
321
+ order: 450,
322
+ text: () => {
323
+ let current = cwd;
324
+ try {
325
+ current = scope.workingDirectory?.get?.(agent.session) ?? cwd;
326
+ } catch {
327
+ current = cwd;
328
+ }
329
+ return sessionScopeText(current);
330
+ },
331
+ });
332
+ });
333
+ sessionScopeFibers.set(agent, fiber);
334
+ } catch (error) {
335
+ console.error(`presence: could not install the per-agent session scope: ${error?.message ?? String(error)}`);
336
+ return null;
337
+ }
338
+ return fiber;
339
+ };
340
+ const disposeSessionScope = (agent) => {
341
+ const fiber = sessionScopeFibers.get(agent);
342
+ if (!fiber) return;
343
+ sessionScopeFibers.delete(agent);
344
+ try {
345
+ Promise.resolve(fiber.dispose()).catch(() => {});
346
+ } catch {
347
+ /* already gone */
348
+ }
349
+ };
350
+ ctx.on('agent/created', (payload) => {
351
+ installSessionScope(payload?.agent ?? payload);
352
+ });
353
+ ctx.on('agent/disposed', (payload) => {
354
+ disposeSessionScope(payload?.agent ?? payload);
355
+ });
356
+ // Agents that already exist when Presence activates need the section too; a
357
+ // missing `agents` service just means there is nothing to install yet.
358
+ ctx.inject(['agents'], (scope) => {
359
+ try {
360
+ for (const agent of scope.agents.list()) installSessionScope(agent);
361
+ } catch {
362
+ /* best effort: the static context still covers the session scope */
363
+ }
364
+ });
365
+ ctx.effect(() => () => {
366
+ const disposals = [];
367
+ for (const [agent, fiber] of [...sessionScopeFibers]) {
368
+ sessionScopeFibers.delete(agent);
369
+ try {
370
+ disposals.push(Promise.resolve(fiber.dispose()));
371
+ } catch {
372
+ /* already gone */
373
+ }
374
+ }
375
+ return Promise.allSettled(disposals).then(() => {});
376
+ });
328
377
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rjfan/dsh-presence",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "description": "Work on any machine in your ssh config as if it were local: persistent connections, native file editing, search, exec and terminals, with nothing installed on the remote host.",
6
6
  "icon": "icon.svg",
package/src/api.js CHANGED
@@ -296,7 +296,9 @@ export function presenceRoutes(pool, late = {}) {
296
296
  }
297
297
  // C2: the client detects running -> exited transitions from this list.
298
298
  // Trimmed to the toast fields: this poll runs every few seconds and the
299
- // full pool row carries the command line (session-log sensitive).
299
+ // full pool row carries the command line (session-log sensitive). 0.20
300
+ // adds the process identity and the two log paths, so a client can show
301
+ // which process a persisted job belongs to and tail the right log.
300
302
  let jobs = [];
301
303
  try {
302
304
  jobs = (await pool.listJobs({ sessionId: undefined })).map((job) => ({
@@ -309,6 +311,9 @@ export function presenceRoutes(pool, late = {}) {
309
311
  startedAt: job.startedAt ?? null,
310
312
  logPath: job.logPath ?? null,
311
313
  pid: job.pid ?? null,
314
+ pgid: job.pgid ?? null,
315
+ commandLogPath: job.commandLogPath ?? null,
316
+ persisted: job.persisted === true,
312
317
  }));
313
318
  } catch {
314
319
  jobs = [];
package/src/intercept.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { parseMountPath, parsePresenceUrl, resolveRemoteInput } from './mounts.js';
10
10
  import { remoteGlobDetailed, remoteGrep, splitGlobPattern } from './remote-search.js';
11
+ import { canonicalObservation, lookupObservation, recordObservation } from './observed-store.js';
11
12
 
12
13
  const LIMIT = 200;
13
14
 
@@ -131,3 +132,152 @@ export function installToolInterceptor(ctx, { pool, cwdOf }) {
131
132
  }
132
133
  });
133
134
  }
135
+
136
+ /**
137
+ * Bridge between the Presence file tools and the harness read-before-edit
138
+ * policy (plugin `fs-observation-policy`).
139
+ *
140
+ * The policy records an authoritative observation only when a tool emits
141
+ * `fs/observed` with a resolved target and the returned version. Its state is a
142
+ * WeakMap keyed by the live Session object inside that plugin — it is not a
143
+ * service, so it cannot be read or written directly from here. What IS possible
144
+ * (and what this bridge does) is to publish the same event the built-in tools
145
+ * publish:
146
+ * - after `presence_read`/`presence_write`/`presence_edit` succeed (through the
147
+ * `observe` callback the tools call), so a later built-in `edit` on the same
148
+ * remote file passes;
149
+ * - after a built-in `read`/`read_image`, so the persisted record survives
150
+ * compaction (the guard's own WeakMap does not);
151
+ * - just before a built-in `edit`/`write`, re-publishing the recorded version
152
+ * when the file is still at exactly that version. A changed file is never
153
+ * auto-approved.
154
+ *
155
+ * `exec cat` (or any command that happens to read a file) is deliberately NOT
156
+ * turned into an observation: a shell command carries neither a target identity
157
+ * nor a version, and fabricating one would defeat the guard.
158
+ *
159
+ * @param ctx - plugin context (event owner).
160
+ * @param options.cwdOf - async (exec) => session cwd or null.
161
+ * @param options.late - late-bound services (`fs`).
162
+ * @returns `{ observe, resolveObservation }`; `observe` is the callback
163
+ * `definePresenceTools` expects, `resolveObservation` is useful for probes.
164
+ */
165
+ export function installObservationBridge(ctx, { cwdOf, late }) {
166
+ const sessionIdOf = (exec) => exec?.agent?.session?.id ?? exec?.agent?.session?.header?.id ?? null;
167
+
168
+ const explicitHostOf = (exec) => {
169
+ const host = exec?.arguments?.host;
170
+ return typeof host === 'string' && host.trim() ? host.trim() : null;
171
+ };
172
+
173
+ /**
174
+ * Resolve a tool path spelling to `{ target, canonical }`. A remote spelling
175
+ * becomes the canonical `presence://host/path` target directly (so a local
176
+ * session that passed `host` explicitly still records the right identity);
177
+ * a genuinely local path falls back to the provider's own target, keeping the
178
+ * pre-0.20 local repair behavior.
179
+ */
180
+ const resolveObservation = async (exec, path) => {
181
+ if (typeof path !== 'string' || path === '') return null;
182
+ let cwd = null;
183
+ try {
184
+ cwd = (await cwdOf(exec)) ?? null;
185
+ } catch {
186
+ cwd = null;
187
+ }
188
+ const explicitHost = explicitHostOf(exec);
189
+ const canonical = canonicalObservation(path, { cwd, host: explicitHost });
190
+ if (canonical) {
191
+ // The presence provider's identity IS the presence URL; the policy only
192
+ // reads `targetKey`, and every other `fs/observed` listener can stat this
193
+ // target through the same provider.
194
+ return { target: { targetKey: canonical.key, displayPath: canonical.key }, canonical };
195
+ }
196
+ // A relative spelling cannot be mapped when the call named a different host
197
+ // than the mounted workspace: resolving it through the provider would record
198
+ // the mounted machine's file, which is NOT the file the tool touched. Never
199
+ // fabricate a target that belongs to another machine.
200
+ if (explicitHost && cwd && parseMountPath(cwd)) return null;
201
+ const fs = late?.fs;
202
+ if (!fs || typeof fs.resolve !== 'function') return null;
203
+ try {
204
+ const target = await fs.resolve(path, cwd ? { cwd } : undefined);
205
+ if (!target) return null;
206
+ return { target, canonical: canonicalObservation(target, { cwd }) };
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+
212
+ /** Record that this session saw the file at its current version. */
213
+ const observe = async (exec, path, options = {}) => {
214
+ try {
215
+ const resolved = await resolveObservation(exec, path);
216
+ if (!resolved) return;
217
+ const fs = late?.fs;
218
+ const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
219
+ const observation = stat ? { kind: 'present', version: stat.version } : { kind: 'absent' };
220
+ // The built-in read/write tools already publish their own event; a second
221
+ // one would only churn `changes` listeners. Presence tools have no other
222
+ // publisher, so they always publish here.
223
+ if (options.publish !== false) ctx.emit('fs/observed', resolved.target, observation, { agent: exec?.agent });
224
+ if (stat && options.remember !== false) {
225
+ recordObservation(
226
+ sessionIdOf(exec),
227
+ resolved.canonical ?? String(resolved.target.targetKey ?? ''),
228
+ stat.version,
229
+ { cwd: null },
230
+ );
231
+ }
232
+ } catch {
233
+ /* the observation is an optimization, never a failure */
234
+ }
235
+ };
236
+
237
+ /** Re-publish a recorded observation if the file is still at that version. */
238
+ const repair = async (exec, path) => {
239
+ const sessionId = sessionIdOf(exec);
240
+ if (!sessionId) return null;
241
+ const resolved = await resolveObservation(exec, path);
242
+ if (!resolved) return null;
243
+ const targetKey = resolved.canonical?.key ?? String(resolved.target.targetKey ?? '');
244
+ const recorded = lookupObservation(sessionId, resolved.canonical ?? targetKey);
245
+ if (!recorded) return null;
246
+ const fs = late?.fs;
247
+ const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
248
+ if (!stat || stat.version !== recorded) return null;
249
+ ctx.emit('fs/observed', resolved.target, { kind: 'present', version: stat.version }, { agent: exec.agent });
250
+ return stat.version;
251
+ };
252
+
253
+ // Built-in read/read_image: remember what the session has seen, so the record
254
+ // survives compaction even though the guard's WeakMap does not.
255
+ ctx.on('tools/post-execute', async (exec, result, next) => {
256
+ try {
257
+ if (!result?.isError && (exec?.name === 'read' || exec?.name === 'read_image')) {
258
+ await observe(exec, exec.arguments?.file_path, { publish: false });
259
+ }
260
+ } catch {
261
+ /* best effort */
262
+ }
263
+ return next();
264
+ });
265
+
266
+ // Built-in edit/write on a remote target: if this session read the file at
267
+ // the same version we recorded, re-publish the observation so the guard
268
+ // passes. A changed file is left alone: the guard's refusal is the point.
269
+ ctx.on('tools/pre-execute', async (exec, next) => {
270
+ try {
271
+ if (exec?.name === 'edit' || exec?.name === 'write') {
272
+ const path = exec.arguments?.file_path;
273
+ if (typeof path === 'string' && path !== '') await repair(exec, path);
274
+ }
275
+ } catch {
276
+ /* best effort: a failed repair leaves the original guard behavior */
277
+ }
278
+ return next();
279
+ });
280
+
281
+ return { observe, resolveObservation };
282
+ }
283
+