@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0 → 0.1.2

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 (43) hide show
  1. package/CHANGELOG.en.md +89 -0
  2. package/CHANGELOG.md +89 -0
  3. package/README.en.md +96 -20
  4. package/README.md +97 -19
  5. package/cordis.patch.yml +22 -1
  6. package/lib/client.js +234 -71
  7. package/lib/client.js.map +1 -1
  8. package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
  9. package/lib/command.js +2 -2
  10. package/lib/compat-CRdv0vPw.js +272 -0
  11. package/lib/compat.js +2 -0
  12. package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
  13. package/lib/fs.js +1 -1
  14. package/lib/index.js +8 -6
  15. package/lib/instructions-Cn1Jj5Xi.js +703 -0
  16. package/lib/instructions.js +2 -0
  17. package/lib/registry-DySMbjgW.js +793 -0
  18. package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
  19. package/lib/registry.js +2 -466
  20. package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
  21. package/lib/sandbox.js +1 -1
  22. package/lib/scope.js +1 -1
  23. package/lib/types/client/locales.d.ts +6 -0
  24. package/lib/types/client/panel-client.d.ts +0 -2
  25. package/lib/types/command.d.ts +17 -0
  26. package/lib/types/compat/agent-instructions.d.ts +138 -0
  27. package/lib/types/compat/client-icons.d.ts +40 -0
  28. package/lib/types/compat/client-session.d.ts +32 -0
  29. package/lib/types/compat/dsh-version.d.ts +119 -0
  30. package/lib/types/compat/llm-message.d.ts +72 -0
  31. package/lib/types/compat/sandbox-confine.d.ts +58 -0
  32. package/lib/types/compat/tool-result.d.ts +29 -0
  33. package/lib/types/compat.d.ts +85 -0
  34. package/lib/types/contract.d.ts +6 -8
  35. package/lib/types/index.d.ts +19 -5
  36. package/lib/types/instructions.d.ts +125 -0
  37. package/lib/types/registry-lease-win32.d.ts +29 -0
  38. package/lib/types/registry-lease.d.ts +62 -0
  39. package/lib/types/registry.d.ts +130 -19
  40. package/lib/types/roots.d.ts +1 -1
  41. package/lib/types/sandbox.d.ts +22 -2
  42. package/package.json +60 -13
  43. package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
@@ -0,0 +1,703 @@
1
+ import { l as isCanonicallyUnder } from "./roots-DasWEBJk.js";
2
+ import { createRequire } from "node:module";
3
+ import "@deepseek-ai/cordis";
4
+ import { createHash } from "node:crypto";
5
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
6
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
7
+ //#region src/compat/agent-instructions.ts
8
+ /**
9
+ * Compatibility adapter for upstream's instruction discovery and rendering.
10
+ *
11
+ * `@deepseek-ai/dsh-agent-instructions` renamed its renderer between two
12
+ * releases this plugin supports, without changing its signature:
13
+ *
14
+ * ```text
15
+ * 0.1.5-rc.2 renderWorkspaceContext(files, options) RenderedWorkspaceContext
16
+ * 0.1.6-alpha.1 renderAgentInstructions(files, options) RenderedAgentInstructions
17
+ * ```
18
+ *
19
+ * `discoverBaselineInstructionFiles` kept both its name and its signature.
20
+ *
21
+ * All coupling to that package is concentrated here so the business layer
22
+ * (`src/instructions.ts`) only ever calls {@link renderInstructions} and
23
+ * {@link InstructionsApi.discover}. A future rename is one edit in this file,
24
+ * not a version check sprinkled through the provider.
25
+ *
26
+ * The export is picked by NAME rather than by comparing the release string:
27
+ * a structural probe also copes with upstream keeping both names for a release,
28
+ * or reshaping within one, which a version comparison does not.
29
+ *
30
+ * The package is loaded LAZILY, and its genuine ABSENCE is reported as absence
31
+ * rather than thrown. It is an optional peer: the default DSH bundle mounts it,
32
+ * but a minimal composition legitimately has neither it nor an agent, and in
33
+ * that case this plugin's instruction row must simply contribute nothing. A
34
+ * package that is present but cannot be evaluated is a different answer, and
35
+ * one this adapter reports instead of caching as absence.
36
+ *
37
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/compat/agent-instructions
38
+ */
39
+ /** Raised when the package is present but carries neither renderer name. */
40
+ var DshInstructionApiError = class extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "DshInstructionApiError";
44
+ }
45
+ };
46
+ /** Resolution is attempted once per process; `null` records a confirmed absence. */
47
+ let resolution;
48
+ /**
49
+ * Pick the renderer this release exports.
50
+ * @param module - the loaded upstream module.
51
+ * @returns the renderer.
52
+ * @throws {DshInstructionApiError} when neither name is present.
53
+ */
54
+ function selectRenderer(module) {
55
+ const render = module.renderAgentInstructions ?? module.renderWorkspaceContext;
56
+ if (render === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions exports neither renderAgentInstructions (0.1.6 and later) nor renderWorkspaceContext (0.1.5); the additional roots' instruction files cannot be rendered. Adapt src/compat/agent-instructions.ts to the installed release.");
57
+ return render;
58
+ }
59
+ /**
60
+ * Adapt one loaded upstream module to {@link InstructionsApi}.
61
+ * @param module - the loaded upstream module.
62
+ * @returns the adapted surface.
63
+ * @throws {DshInstructionApiError} when a function this plugin needs is absent.
64
+ */
65
+ function adaptInstructionsModule(module) {
66
+ const discover = module.discoverBaselineInstructionFiles;
67
+ if (discover === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions does not export discoverBaselineInstructionFiles; adapt src/compat/agent-instructions.ts to the installed release.");
68
+ return {
69
+ discover: (options) => discover(options),
70
+ render: selectRenderer(module)
71
+ };
72
+ }
73
+ /**
74
+ * Whether one package is installed at all, judged WITHOUT evaluating it.
75
+ *
76
+ * This is what separates "the optional peer is absent" from "the optional peer
77
+ * is installed but its evaluation fails" (a missing transitive dependency, a
78
+ * broken build). Only a genuine resolvability failure counts as absence: a
79
+ * resolution that fails for another reason (an `exports` map with no `require`
80
+ * condition, say) leaves the verdict to the `import()` below, which is the
81
+ * authority on whether the module can actually be loaded.
82
+ * @param specifier - the bare package specifier to probe.
83
+ * @returns whether the package resolves from this module.
84
+ */
85
+ function isPackageInstalled(specifier) {
86
+ try {
87
+ createRequire(import.meta.url).resolve(specifier);
88
+ return true;
89
+ } catch (error) {
90
+ const code = error?.code;
91
+ return code !== "MODULE_NOT_FOUND" && code !== "ERR_MODULE_NOT_FOUND";
92
+ }
93
+ }
94
+ /**
95
+ * Load and adapt the upstream instruction surface, once.
96
+ *
97
+ * @returns the adapted surface, or `undefined` when the package is not
98
+ * installed. A package that IS installed but exports neither renderer throws
99
+ * instead: that is a compatibility break to fix, not an optional seam. So
100
+ * does one that is installed but cannot be evaluated at all — a missing
101
+ * transitive dependency, a broken build — because reporting that as absence
102
+ * would drop every additional root's instructions for the rest of the
103
+ * process. See {@link isPackageInstalled} for how the two are told apart.
104
+ */
105
+ async function instructionsApi() {
106
+ resolution ??= (async () => {
107
+ if (!isPackageInstalled("@deepseek-ai/dsh-agent-instructions")) return null;
108
+ return adaptInstructionsModule(await import("@deepseek-ai/dsh-agent-instructions"));
109
+ })();
110
+ return await resolution ?? void 0;
111
+ }
112
+ /**
113
+ * Render loaded instruction files within a byte budget, through whichever
114
+ * renderer the installed release exports.
115
+ *
116
+ * This is the only rendering entry point the business layer uses.
117
+ * @param files - loaded files, ordered broadest to most specific.
118
+ * @param options - the byte budget.
119
+ * @returns the rendered text and its budget diagnostics.
120
+ * @throws {DshInstructionApiError} when no usable renderer is installed.
121
+ */
122
+ async function renderInstructions(files, options) {
123
+ const api = await instructionsApi();
124
+ if (api === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions is not installed, so instruction files cannot be rendered. Check for its presence with instructionsApi() before calling renderInstructions().");
125
+ return api.render(files, options);
126
+ }
127
+ //#endregion
128
+ //#region src/compat/llm-message.ts
129
+ /**
130
+ * This plugin's own producer kind.
131
+ *
132
+ * Session format 4 rejects the shared `kind: 'plugin'` (`format v4 message
133
+ * requires a producer-owned source kind`). The kind is ours, not
134
+ * `agent-instructions`: upstream treats that kind's `changes` array as its
135
+ * own reconciliation authority.
136
+ */
137
+ const PRODUCER_SOURCE_KIND = "multi-root-workspace";
138
+ /**
139
+ * The first session format whose encoder refuses `kind: 'plugin'`.
140
+ * Earlier formats declare that kind on `MessageSourceMap` and require it.
141
+ */
142
+ const PRODUCER_OWNED_FORMAT = 4;
143
+ /**
144
+ * Which source kind the installed session format will persist.
145
+ * @param formatVersion - `SESSION_FORMAT_VERSION`, when the session package is installed.
146
+ * @returns `plugin` through format 3, and this plugin's own kind from format 4 on.
147
+ */
148
+ function instructionSourceKind(formatVersion) {
149
+ return formatVersion !== void 0 && formatVersion >= PRODUCER_OWNED_FORMAT ? PRODUCER_SOURCE_KIND : "plugin";
150
+ }
151
+ /**
152
+ * The installed session format, when that package is present.
153
+ *
154
+ * A composition that never persists a session can omit the package; format 3
155
+ * is then the spelling that still declares `kind: 'plugin'`. Any other failure
156
+ * is a broken install and must surface.
157
+ * @returns the format version, or `undefined` when the package is not installed.
158
+ */
159
+ async function installedFormatVersion() {
160
+ try {
161
+ const session = await import("@deepseek-ai/dsh-session");
162
+ return typeof session.SESSION_FORMAT_VERSION === "number" ? session.SESSION_FORMAT_VERSION : void 0;
163
+ } catch (error) {
164
+ const code = error.code;
165
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return void 0;
166
+ throw error;
167
+ }
168
+ }
169
+ /**
170
+ * Build one user-role instruction message.
171
+ *
172
+ * Format 3 and earlier: `{ kind: 'plugin', plugin, form: 'instructions' }`.
173
+ * Format 4 and later: `{ kind: 'multi-root-workspace', plugin, form: 'instructions' }`.
174
+ * @param input - the body and the plugin attribution.
175
+ * @returns the frozen message upstream's helper produced.
176
+ * @throws {Error} when the optional peer is absent: a step that has instruction
177
+ * text to deliver cannot be served without it, and staying silent would drop
178
+ * user-visible context instead of reporting a misconfigured composition.
179
+ */
180
+ async function createInstructionMessage(input) {
181
+ let module;
182
+ try {
183
+ module = await import("@deepseek-ai/dsh-llm");
184
+ } catch (error) {
185
+ throw new Error(`multi-root workspace: @deepseek-ai/dsh-llm is not installed, so additional roots' instruction files cannot be delivered as messages (${error instanceof Error ? error.message : String(error)}).`);
186
+ }
187
+ const create = module.createUserMessage;
188
+ return create({
189
+ content: [{
190
+ type: "text",
191
+ text: input.text
192
+ }],
193
+ source: instructionSource(input.plugin, await installedFormatVersion())
194
+ });
195
+ }
196
+ /** The source object the installed format will persist. */
197
+ function instructionSource(plugin, formatVersion) {
198
+ const kind = instructionSourceKind(formatVersion);
199
+ if (kind === "plugin") return {
200
+ kind,
201
+ plugin,
202
+ form: "instructions"
203
+ };
204
+ return {
205
+ kind,
206
+ plugin,
207
+ form: "instructions"
208
+ };
209
+ }
210
+ //#endregion
211
+ //#region src/compat/tool-result.ts
212
+ /**
213
+ * Whether a tool-result message reports failure, on every supported release.
214
+ * @param message - the `tool/result` message from the session log.
215
+ * @returns `true` when either spelling says the call failed.
216
+ */
217
+ function toolResultFailed(message) {
218
+ if (message.isError === true) return true;
219
+ const block = message.content?.[0];
220
+ if (typeof block !== "object" || block === null || !("isError" in block)) return false;
221
+ return block.isError === true;
222
+ }
223
+ //#endregion
224
+ //#region src/instructions.ts
225
+ /**
226
+ * `multi-root-instructions`: the additional roots' own instruction files, made
227
+ * visible to the model.
228
+ *
229
+ * Upstream `agent-instructions` discovers instruction files by walking UP from
230
+ * the session cwd (plus the one user-global file). In a multi-root workspace
231
+ * that walk never reaches an additional root, so `repo-b/AGENTS.md` is
232
+ * invisible to a model that is nevertheless allowed to write `repo-b`. This row
233
+ * closes exactly that gap, and nothing else: the primary root's chain — nested
234
+ * files included — and the user-global file stay upstream's business.
235
+ *
236
+ * Four deliberate choices:
237
+ *
238
+ * 1. **Not a system-prompt contribution.** Instruction text is
239
+ * producer-supplied context, not system authority, so it enters as a
240
+ * user-role message tagged `form: 'instructions'`. Through session format 3
241
+ * the source kind is `plugin`; format 4 retired that shared kind, so the
242
+ * adapter persists this plugin's own (`multi-root-workspace`). It is also
243
+ * the only channel that is logged, and model-visible content must be logged.
244
+ * 2. **Injected from `agent/pre-step`, not a session lifecycle event.**
245
+ * `pre-step` is an awaited waterfall, so discovery, reading and rendering all
246
+ * complete BEFORE the step it feeds — deterministically, on the first step.
247
+ * A synchronous prompt callback could not await them, and an emit-mode
248
+ * lifecycle listener would race the first step. It is also the one hook whose
249
+ * shape is identical across every supported release, which keeps the
250
+ * instruction feature out of the compatibility matrix (ADR-0009).
251
+ * 3. **Discovery is pinned to the root.** `projectRoot` is the additional root,
252
+ * so the walk can never leave it, and every candidate is then required to be
253
+ * canonically INSIDE that root. That is what keeps `$DSH_HOME/AGENTS.md`, the
254
+ * primary root's `AGENTS.md`, and any ancestor's file from being injected a
255
+ * second time by this plugin.
256
+ * 4. **Nested files are driven by the session's own tool activity.** A
257
+ * subdirectory's `AGENTS.md` only matters once the session has worked in that
258
+ * subdirectory, so the directories worth examining are derived from the
259
+ * session's persisted events: `tool/call` and `tool/result` are paired, and a
260
+ * successful `read`/`write`/`edit` makes the path from the root down to the
261
+ * touched file's directory relevant (ADR-0010). A file that disappears is
262
+ * retracted explicitly, because the text it contributed earlier is still in
263
+ * the conversation and silence does not withdraw it.
264
+ *
265
+ * The byte budget is shared: `maxBytes` bounds the WHOLE additional-root
266
+ * snapshot, not each root, because ten roots must not quietly cost ten budgets
267
+ * of context. Roots consume it in scope order. Retractions are exempt, because a
268
+ * truncated withdrawal is worse than a slightly over-budget message.
269
+ *
270
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/instructions
271
+ */
272
+ /** The `plugin` attribution every message this row produces carries. */
273
+ const PLUGIN_SOURCE = "@dsh-electron/dsh-plugin-multi-root-workspace";
274
+ /**
275
+ * Total UTF-8 byte budget for the additional-root snapshot, matching the
276
+ * default the upstream `agent-instructions` row is composed with so that one
277
+ * extra workspace root costs at most as much as the primary one does.
278
+ */
279
+ const DEFAULT_MAX_BYTES = 65536;
280
+ /** Largest instruction file this row will read; larger files are ignored. */
281
+ const DEFAULT_MAX_SOURCE_BYTES = 32768;
282
+ /** Tools whose SUCCESS makes the directories on the path to the file relevant. */
283
+ const FILE_TOUCH_TOOL_NAMES = {
284
+ read: true,
285
+ write: true,
286
+ edit: true
287
+ };
288
+ /** Cap on unanswered `tool/call` records remembered per session. */
289
+ const MAX_PENDING_CALLS = 128;
290
+ /** Cap on touched paths remembered per session; beyond it, touches are dropped. */
291
+ const MAX_TOUCHED_PATHS = 64;
292
+ /** Cap on delivered scopes remembered per session; beyond it, nested scopes stop. */
293
+ const MAX_DELIVERED_SCOPES = 512;
294
+ /** Digest one file's content. Content-addressed, so identical content never re-sends. */
295
+ function digestOf(text) {
296
+ return createHash("sha256").update(text).digest("hex");
297
+ }
298
+ /**
299
+ * The scope identity of one candidate instruction file.
300
+ *
301
+ * NUL-joined, so two additional roots carrying the same relative
302
+ * `AGENTS.md` can never collide on one key.
303
+ * @param root - the canonical additional root.
304
+ * @param relativeDirectory - the file's directory, relative to the root, `.` for the root itself.
305
+ * @param fileName - the instruction file's name.
306
+ * @returns the scope key.
307
+ */
308
+ function scopeKey(root, relativeDirectory, fileName) {
309
+ return `${root}\0${relativeDirectory}\0${fileName}`;
310
+ }
311
+ /** A directory's location within its root, spelled `.` for the root itself. */
312
+ function relativeDirectoryOf(root, directory) {
313
+ const suffix = relative(root, directory);
314
+ return suffix.length === 0 ? "." : suffix;
315
+ }
316
+ /**
317
+ * The path one RAW tool call touched.
318
+ *
319
+ * `arguments` is the model-produced JSON string, so the path is only known after
320
+ * parsing it; a call that names no `file_path`, or is not one of the tools whose
321
+ * success makes a directory relevant, touched nothing this row cares about.
322
+ * @param name - the tool name the model called.
323
+ * @param argumentsJson - the tool call's raw arguments, exactly as produced.
324
+ * @returns the trimmed absolute-or-relative path, or `undefined`.
325
+ */
326
+ function touchedPathOfToolCall(name, argumentsJson) {
327
+ if (FILE_TOUCH_TOOL_NAMES[name] !== true) return void 0;
328
+ let parsed;
329
+ try {
330
+ parsed = JSON.parse(argumentsJson);
331
+ } catch {
332
+ return;
333
+ }
334
+ if (typeof parsed !== "object" || parsed === null) return void 0;
335
+ if (!("file_path" in parsed)) return void 0;
336
+ const path = parsed.file_path;
337
+ if (typeof path !== "string") return void 0;
338
+ const trimmed = path.trim();
339
+ return trimmed.length > 0 ? trimmed : void 0;
340
+ }
341
+ /**
342
+ * The directory a touched path's instructions would live in.
343
+ *
344
+ * Tool arguments are the model's raw input, so a relative `file_path` is
345
+ * resolved against the session cwd — the base the tool layer itself uses —
346
+ * rather than against this process's cwd. Mac's `realpath` spelling is applied
347
+ * here so the result compares against the canonical roots.
348
+ * @param path - a touched path, absolute or relative to the session cwd.
349
+ * @param session - the session the touch belongs to.
350
+ * @returns the canonical parent directory, or `undefined` when it cannot be named.
351
+ */
352
+ function parentDirectoryOf(path, session) {
353
+ const cwd = session.header.cwd;
354
+ const absolute = isAbsolute(path) ? path : cwd === void 0 ? void 0 : resolve(cwd, path);
355
+ if (absolute === void 0) return void 0;
356
+ try {
357
+ return canonicalPath(dirname(absolute));
358
+ } catch {
359
+ return;
360
+ }
361
+ }
362
+ /** Cap warnings are emitted once per process; the cap itself is not user-facing. */
363
+ let scopeCapWarned = false;
364
+ /**
365
+ * Compose the model-facing message body for one batch of changes.
366
+ *
367
+ * All three halves matter. A newly visible root — or a newly touched
368
+ * subdirectory — needs its rules; a root that left needs an explicit
369
+ * revocation; and a file that disappeared needs an explicit withdrawal, because
370
+ * the instructions it contributed are still sitting in the conversation history
371
+ * and would otherwise keep applying forever. Silence is not a retraction.
372
+ * @param rendered - roots whose instruction text is new or changed.
373
+ * @param revoked - canonical roots that left the workspace scope.
374
+ * @param removed - instruction files that are no longer present.
375
+ * @returns the message text, or `undefined` when there is nothing to say.
376
+ */
377
+ function composeInstructionMessage(rendered, revoked, removed) {
378
+ if (rendered.length === 0 && revoked.length === 0 && removed.length === 0) return void 0;
379
+ const sections = ["Additional workspace root instructions. The directories below are additional roots of this session's workspace; the instruction files found at their top level, and in the subdirectories this session has worked in, follow, and they apply to work in those directories exactly as this workspace's own instructions apply to the session root."];
380
+ for (const entry of rendered) sections.push(`Instructions for additional workspace root ${entry.root}:\n\n${entry.text}`);
381
+ for (const root of revoked) sections.push(`The directory ${root} is no longer an additional root of this session's workspace. Any instructions previously supplied for it in this conversation no longer apply, and it is no longer writable here.`);
382
+ for (const scope of [...removed].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) sections.push(`The instruction file ${scope.path} is no longer present in the additional workspace root ${scope.root}, so the instructions it supplied earlier no longer apply.`);
383
+ return sections.join("\n\n");
384
+ }
385
+ /** This row is gated on the compatibility contract like every other provider. */
386
+ const inject = [
387
+ "multiRootCompat",
388
+ "multiRootScope",
389
+ "sandboxPolicy"
390
+ ];
391
+ /**
392
+ * Mount the additional-root instruction provider.
393
+ *
394
+ * `fs` is a SOFT dependency, like the scope service's prompt contribution: a
395
+ * composition with no filesystem seam (headless probes, bare test contexts)
396
+ * contributes nothing instead of failing to load. The same is true of the
397
+ * upstream instruction package, which is an optional peer.
398
+ *
399
+ * Touch recording is registered OUTSIDE the `fs` injection, so a composition
400
+ * without a filesystem seam still learns which directories the session worked
401
+ * in; delivery is what needs the seam.
402
+ * @param ctx - the row's context.
403
+ * @param config - the row's configuration.
404
+ */
405
+ function apply(ctx, config = {}) {
406
+ const maxBytes = config.maxBytes ?? 65536;
407
+ if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return;
408
+ const states = /* @__PURE__ */ new WeakMap();
409
+ const callsBySession = /* @__PURE__ */ new WeakMap();
410
+ const stateOf = (session) => {
411
+ const existing = states.get(session);
412
+ if (existing !== void 0) return existing;
413
+ const created = {
414
+ scopes: /* @__PURE__ */ new Map(),
415
+ touched: /* @__PURE__ */ new Set()
416
+ };
417
+ states.set(session, created);
418
+ return created;
419
+ };
420
+ ctx.on("session/event", (session, event) => {
421
+ const pendingCalls = callsBySession.get(session) ?? /* @__PURE__ */ new Map();
422
+ callsBySession.set(session, pendingCalls);
423
+ if (event.type === "tool/call") {
424
+ if (pendingCalls.size >= MAX_PENDING_CALLS) {
425
+ const oldest = pendingCalls.keys().next();
426
+ if (!oldest.done) pendingCalls.delete(oldest.value);
427
+ }
428
+ pendingCalls.set(event.data.callId, {
429
+ name: event.data.name,
430
+ arguments: event.data.arguments
431
+ });
432
+ return;
433
+ }
434
+ if (event.type !== "tool/result") return;
435
+ const callId = event.data.message.source.callId;
436
+ const call = pendingCalls.get(callId);
437
+ pendingCalls.delete(callId);
438
+ if (call === void 0) return;
439
+ if (event.data.error !== void 0 || toolResultFailed(event.data.message)) return;
440
+ const path = touchedPathOfToolCall(call.name, call.arguments);
441
+ if (path === void 0) return;
442
+ const state = stateOf(session);
443
+ if (state.touched.size >= MAX_TOUCHED_PATHS) return;
444
+ state.touched.add(path);
445
+ });
446
+ ctx.inject(["fs"], (scope) => {
447
+ scope.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
448
+ const decision = await next();
449
+ if (decision.kind !== "enter") return decision;
450
+ const seeded = await pending(scope, states, agent, config, maxBytes, signal);
451
+ if (seeded === void 0) return decision;
452
+ signal.throwIfAborted();
453
+ const lastClaimed = decision.messages.findLastIndex((message) => messages.includes(message));
454
+ return {
455
+ ...decision,
456
+ messages: decision.messages.toSpliced(lastClaimed + 1, 0, seeded)
457
+ };
458
+ });
459
+ });
460
+ }
461
+ /**
462
+ * Compute the message this step should carry, if any.
463
+ *
464
+ * Returns `undefined` — the steady state — whenever every delivered file is
465
+ * still current, so a long session does not re-send the same text on every step.
466
+ * @param ctx - the injected scope, which owns `fs` and the services.
467
+ * @param states - per-session delivery state.
468
+ * @param agent - the agent whose step this is.
469
+ * @param config - the row's configuration.
470
+ * @param maxBytes - the total budget for all additional roots.
471
+ * @param signal - the turn's cancellation signal.
472
+ * @returns the message to inject, or `undefined`.
473
+ */
474
+ async function pending(ctx, states, agent, config, maxBytes, signal) {
475
+ const session = agent.session;
476
+ const state = states.get(session) ?? {
477
+ scopes: /* @__PURE__ */ new Map(),
478
+ touched: /* @__PURE__ */ new Set()
479
+ };
480
+ states.set(session, state);
481
+ const evaluatedTouches = new Set(state.touched);
482
+ let retryTouches = false;
483
+ const scope = ctx.multiRootScope.resolve(ctx.sandboxPolicy.resolve({ session }));
484
+ const granted = new Set(scope.additionalRoots);
485
+ const revoked = [...new Set([...state.scopes.values()].map((entry) => entry.root))].filter((root) => !granted.has(root));
486
+ const rendered = [];
487
+ const gone = [];
488
+ const updates = [];
489
+ if (scope.additionalRoots.length > 0) {
490
+ const api = await instructionsApi();
491
+ if (api !== void 0) {
492
+ let remaining = maxBytes;
493
+ for (const root of scope.additionalRoots) {
494
+ signal.throwIfAborted();
495
+ const plan = await planRoot(ctx, api, root, config, state, session, signal);
496
+ gone.push(...plan.gone);
497
+ if (plan.deliveries.length === 0) continue;
498
+ if (remaining <= 0) {
499
+ retryTouches = true;
500
+ continue;
501
+ }
502
+ const { text, omitted, truncated } = api.render(plan.deliveries.map((entry) => entry.file), { maxBytes: remaining });
503
+ const omittedPaths = new Set(omitted.map((file) => file.absolutePath));
504
+ const truncatedPaths = new Set(truncated.map((file) => file.displayPath));
505
+ if (omitted.length > 0 || truncated.length > 0 || text === "") retryTouches = true;
506
+ if (text === "") continue;
507
+ remaining -= Buffer.byteLength(text, "utf8");
508
+ rendered.push({
509
+ root,
510
+ text
511
+ });
512
+ for (const entry of plan.deliveries) {
513
+ if (omittedPaths.has(entry.file.absolutePath)) continue;
514
+ updates.push({
515
+ key: entry.key,
516
+ scope: {
517
+ root,
518
+ path: entry.file.absolutePath,
519
+ digest: truncatedPaths.has(entry.file.displayPath) ? void 0 : digestOf(entry.file.content)
520
+ }
521
+ });
522
+ }
523
+ }
524
+ }
525
+ }
526
+ const text = composeInstructionMessage(rendered, revoked, gone.map((entry) => entry.scope));
527
+ const message = text === void 0 ? void 0 : await createInstructionMessage({
528
+ text,
529
+ plugin: PLUGIN_SOURCE
530
+ });
531
+ signal.throwIfAborted();
532
+ if (!retryTouches) for (const touch of evaluatedTouches) state.touched.delete(touch);
533
+ if (text !== void 0) {
534
+ for (const root of revoked) for (const [key, entry] of state.scopes) if (entry.root === root) state.scopes.delete(key);
535
+ for (const entry of gone) state.scopes.delete(entry.key);
536
+ for (const entry of updates) state.scopes.set(entry.key, entry.scope);
537
+ }
538
+ return message;
539
+ }
540
+ /**
541
+ * Plan one additional root's delivery for this step.
542
+ *
543
+ * Which directories are worth walking is the whole design of phase 2, and the
544
+ * set is exactly three things: the root itself (top-level files, as in phase 1,
545
+ * so a change or a removal there is noticed every step), every directory a
546
+ * nested file was already delivered from (so a change or a removal is noticed
547
+ * without a new touch, like upstream's own reconcile), and the parent directory
548
+ * of every path the session has touched since the last evaluation — plus each
549
+ * of that directory's ancestors up to the root, because discovery reports an
550
+ * intermediate directory's file but only the exact directory's candidates
551
+ * survive the filter below.
552
+ * @param ctx - the row's context, for `fs` and the logger.
553
+ * @param api - the adapted upstream instruction surface.
554
+ * @param root - the canonical additional root.
555
+ * @param config - the row's configuration.
556
+ * @param state - the session's delivery state.
557
+ * @param session - the session, for the cwd a relative touch resolves against.
558
+ * @param signal - the turn's cancellation signal.
559
+ * @returns the files to send and the deliveries to withdraw.
560
+ */
561
+ async function planRoot(ctx, api, root, config, state, session, signal) {
562
+ const delivered = /* @__PURE__ */ new Map();
563
+ for (const [key, entry] of state.scopes) if (entry.root === root) delivered.set(key, entry);
564
+ const dirs = /* @__PURE__ */ new Set([root]);
565
+ for (const entry of delivered.values()) {
566
+ const dir = dirname(entry.path);
567
+ if (dir !== root) dirs.add(dir);
568
+ }
569
+ for (const touched of state.touched) {
570
+ const parent = parentDirectoryOf(touched, session);
571
+ if (parent === void 0 || !isCanonicallyUnder(parent, root)) continue;
572
+ let dir = parent;
573
+ for (;;) {
574
+ dirs.add(dir);
575
+ if (dir === root) break;
576
+ const up = dirname(dir);
577
+ if (up === dir) break;
578
+ dir = up;
579
+ }
580
+ }
581
+ const walks = [];
582
+ for (const dir of dirs) {
583
+ signal.throwIfAborted();
584
+ walks.push({
585
+ relativeDirectory: relativeDirectoryOf(root, dir),
586
+ read: await loadDirectory(ctx.fs, api, root, config, signal, dir)
587
+ });
588
+ }
589
+ walks.sort((left, right) => left.relativeDirectory.split(sep).length - right.relativeDirectory.split(sep).length || (left.relativeDirectory < right.relativeDirectory ? -1 : left.relativeDirectory > right.relativeDirectory ? 1 : 0));
590
+ const deliveries = [];
591
+ const gone = [];
592
+ for (const walk of walks) {
593
+ if (!walk.read.probed) continue;
594
+ const present = /* @__PURE__ */ new Set();
595
+ for (const candidate of walk.read.candidates) {
596
+ if (relativeDirectoryOf(root, dirname(candidate.absolutePath)) !== walk.relativeDirectory) continue;
597
+ present.add(basename(candidate.absolutePath));
598
+ }
599
+ for (const file of walk.read.files) {
600
+ if (relativeDirectoryOf(root, dirname(file.absolutePath)) !== walk.relativeDirectory) continue;
601
+ const key = scopeKey(root, walk.relativeDirectory, basename(file.absolutePath));
602
+ const previous = delivered.get(key);
603
+ if (previous !== void 0 && previous.digest === digestOf(file.content)) continue;
604
+ if (previous === void 0 && walk.relativeDirectory !== ".") {
605
+ if (state.scopes.size >= MAX_DELIVERED_SCOPES) {
606
+ if (!scopeCapWarned) {
607
+ scopeCapWarned = true;
608
+ ctx.logger.warn("multi-root workspace: additional-root instruction scopes capped at %d", MAX_DELIVERED_SCOPES);
609
+ }
610
+ continue;
611
+ }
612
+ }
613
+ deliveries.push({
614
+ key,
615
+ file
616
+ });
617
+ }
618
+ for (const [key, entry] of delivered) {
619
+ if (relativeDirectoryOf(root, dirname(entry.path)) !== walk.relativeDirectory) continue;
620
+ if (present.has(basename(entry.path))) continue;
621
+ gone.push({
622
+ key,
623
+ scope: {
624
+ root,
625
+ path: entry.path
626
+ }
627
+ });
628
+ }
629
+ }
630
+ return {
631
+ deliveries,
632
+ gone
633
+ };
634
+ }
635
+ /**
636
+ * Walk from `cwd` up to `root` and read the instruction candidates it reports.
637
+ *
638
+ * Two filters are applied to what the walk reports, and both are load-bearing:
639
+ *
640
+ * - **canonical containment** drops every candidate that is not inside the root
641
+ * itself, which is what excludes the user-global file and any ancestor's;
642
+ * - **the source cap** skips a file too large to be instructions, using `stat`
643
+ * so an oversized file is never read at all.
644
+ *
645
+ * The pre-filter candidates are returned alongside the loaded ones: they are how
646
+ * a caller tells "this file is gone" from "this file could not be read".
647
+ *
648
+ * `displayPath` is rewritten to the absolute path on purpose: discovery
649
+ * displays candidates relative to `projectRoot`, so every root's file would
650
+ * otherwise render as the same ambiguous `AGENTS.md`, and the absolute spelling
651
+ * is also the one the model must use in a tool call against a directory outside
652
+ * the session root.
653
+ * @param fs - the filesystem provider to read through.
654
+ * @param api - the adapted upstream instruction surface.
655
+ * @param root - the canonical additional root the walk stops at.
656
+ * @param config - the row's configuration.
657
+ * @param signal - the turn's cancellation signal.
658
+ * @param cwd - the directory the walk starts from; always inside `root`.
659
+ * @returns the directory read.
660
+ */
661
+ async function loadDirectory(fs, api, root, config, signal, cwd) {
662
+ const maxSourceBytes = config.maxSourceBytes ?? 32768;
663
+ let candidates;
664
+ try {
665
+ candidates = await api.discover({
666
+ cwd,
667
+ projectRoot: root,
668
+ signal,
669
+ ...config.instructionFileCandidates === void 0 ? {} : { instructionFileCandidates: config.instructionFileCandidates },
670
+ ...config.localInstructionFileCandidates === void 0 ? {} : { localInstructionFileCandidates: config.localInstructionFileCandidates }
671
+ });
672
+ } catch {
673
+ return {
674
+ candidates: [],
675
+ files: [],
676
+ probed: false
677
+ };
678
+ }
679
+ const files = [];
680
+ for (const candidate of candidates) {
681
+ if (!isCanonicallyUnder(canonicalPath(candidate.absolutePath), root)) continue;
682
+ try {
683
+ const target = await fs.resolve(candidate.absolutePath);
684
+ const info = await fs.stat(target, signal);
685
+ if (info === void 0 || info.type !== "file") continue;
686
+ if (info.size !== void 0 && info.size > maxSourceBytes) continue;
687
+ files.push({
688
+ ...candidate,
689
+ displayPath: candidate.absolutePath,
690
+ content: await fs.readText(target, signal)
691
+ });
692
+ } catch {
693
+ continue;
694
+ }
695
+ }
696
+ return {
697
+ candidates,
698
+ files,
699
+ probed: true
700
+ };
701
+ }
702
+ //#endregion
703
+ export { composeInstructionMessage as a, createInstructionMessage as c, apply as i, instructionsApi as l, DEFAULT_MAX_SOURCE_BYTES as n, inject as o, PLUGIN_SOURCE as r, touchedPathOfToolCall as s, DEFAULT_MAX_BYTES as t, renderInstructions as u };