dsh-multi-folder 0.2.0 → 0.2.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.
package/docs/design.md CHANGED
@@ -77,7 +77,17 @@ at apply time can yield `undefined` when the provider row activates later. There
77
77
  handler and the `multiFolder/*` remote endpoints) with an explicit
78
78
  `workspace-write` policy rooted at the config directory.
79
79
  - A per-process cache keyed by normalized workspace path hydrates lazily (on
80
- `agent/created`, `agent/pre-step`, and `tools/execute`).
80
+ `agent/created`, `agent/pre-step`, and `tools/execute`). On the interception
81
+ path hydration is **awaited**, not fire-and-forget: a first call that landed
82
+ before the config read resolved would otherwise see an empty cache, fall
83
+ through to the default pipeline, and be fenced against the PRIMARY workspace
84
+ root — a spurious `[sandbox: file access denied under workspace-write mode]`
85
+ for a secondary-directory mutation. `loadDirs` caches, so only the first call
86
+ pays the read. Because `sandbox-policy` realpath-canonicalizes the policy
87
+ workspace root while hydration is keyed by the session cwd **as spelled in
88
+ the header**, a workspace reached through a symlinked/junctioned ancestor can
89
+ spell the two differently; the interception consults BOTH keys (and the
90
+ config guard checks both config-path spellings) before falling through.
81
91
  - One shared **core** (`coreList` / `coreAdd` / `coreRemove` / `coreSet`)
82
92
  implements validation, canonicalization, sanitization, cache write-through,
83
93
  and persistence. The command channel and the remote channel both call it, so
@@ -200,9 +210,13 @@ window.__ModuleLoader__.load({
200
210
  renders directly ABOVE the composer card, in the same band as the
201
211
  git-branch chip. The entry receives the dock owner share (`{ session,
202
212
  input }`) plus the standard `useSessions` / `useWorkspaces` selector hooks,
203
- so the hero phase (`composerPhase === 'blank' && (openState === 'open' ||
204
- blank)`) and the target workspace come from framework props instead of DOM
205
- probing. The row stays **in flow** — `display:flex` with the official hero
213
+ so the hero phase and the target workspace come from framework props instead
214
+ of DOM probing. Detection is DSH-version-adaptive: a shell whose
215
+ `SessionSnapshot` carries `composerPhase` uses
216
+ `composerPhase === 'blank' && (openState === 'open' || blank)`, while DSH
217
+ 0.1.2 (no `composerPhase`) uses the settled-blank fallback
218
+ `blank && !running && !promptAttempted && (openState === 'open' || blank)`.
219
+ The row stays **in flow** — `display:flex` with the official hero
206
220
  row's 20px indent, no absolute positioning — so the framework's list-slot
207
221
  arrangement keeps it clear of every other plugin's dock row. It renders
208
222
  only on the session-creation page; an active session keeps its entry in the
@@ -255,10 +269,12 @@ window.__ModuleLoader__.load({
255
269
  Lifting this to real multi-root confinement needs an upstream change
256
270
  (`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
257
271
  accepting several workspace write SIDs).
258
- - Intercepted secondary-directory writes bypass the fs observation policy: they emit
259
- no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
260
- This is deliberate — secondary directories sit outside the primary workspace's
261
- observation domain.
272
+ - Intercepted secondary-directory mutations do not participate in the
273
+ `fs/write-intent` / `fs/edit-intent` intent guards (the interception calls
274
+ the backend unconditionally, as a full replacement of the tool body), but
275
+ they DO emit `fs/observed` with a presence observation after success, exactly
276
+ like the shipped tools — so the observation layer stays coherent with the
277
+ file content a re-rooted write/edit produced.
262
278
  - `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
263
279
  their default presentation.
264
280
  - `sandbox_permissions` escalation on `pwsh`/`bash` calls in secondary directories is
package/lib/client.js CHANGED
@@ -214,12 +214,13 @@ window.__ModuleLoader__.load({
214
214
 
215
215
  // ------------------------------------------------------------- plugin
216
216
  var name = 'dsh-multi-folder';
217
- var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
217
+ var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'uiWorkspace', 'connection', 'sessions', 'locale'];
218
218
 
219
219
  function apply(ctx) {
220
220
  var slots = ctx.slots;
221
221
  var remote = ctx.remote;
222
222
  var workspaces = ctx.workspaces;
223
+ var uiWorkspace = ctx.uiWorkspace;
223
224
  var connection = ctx.connection;
224
225
  var sessions = ctx.sessions;
225
226
  var locale = ctx.locale;
@@ -398,7 +399,18 @@ window.__ModuleLoader__.load({
398
399
  }
399
400
 
400
401
  function addDirectory() {
401
- workspaces.pickDirectory().then(function (path) {
402
+ // DSH 0.1.2 moved the directory picker onto `uiWorkspace`; earlier
403
+ // shells kept it on `workspaces`. Pick whichever service actually
404
+ // exposes it (and call it with the right receiver), surfacing a clear
405
+ // error when neither does instead of throwing synchronously.
406
+ var picker = (uiWorkspace && typeof uiWorkspace.pickDirectory === 'function')
407
+ ? uiWorkspace
408
+ : (workspaces && typeof workspaces.pickDirectory === 'function') ? workspaces : null;
409
+ if (picker === null) {
410
+ patch({ error: 'multi-folder: the directory picker service is unavailable' });
411
+ return;
412
+ }
413
+ picker.pickDirectory().then(function (path) {
402
414
  if (path === null || path === undefined) return;
403
415
  var snapshot = getSnapshot();
404
416
  if (snapshot.sessionId) {
@@ -763,10 +775,22 @@ window.__ModuleLoader__.load({
763
775
  /** The shell's own hero predicate, read from the dock's owner share: a
764
776
  * blank conversation with an open session. A still-loading blank
765
777
  * session already lists as blank, so it may enter the hero phase
766
- * before the composer snapshot settles to `open`. */
778
+ * before the composer snapshot settles to `open`.
779
+ *
780
+ * DSH 0.1.2 exposes no `composerPhase` on SessionSnapshot; the hero
781
+ * phase there is a settled blank session (mirroring ConversationRoot's
782
+ * `hero = sessionId === undefined || (shellPhase === 'blank' && ...)`),
783
+ * so the blank/lifecycle fields are used as the fallback. Newer DSH
784
+ * shells that do carry `composerPhase` keep their original check. */
767
785
  function isHeroPhase(session, blank) {
768
786
  if (!session) return false;
769
- return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
787
+ if (session.composerPhase !== undefined) {
788
+ return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
789
+ }
790
+ return session.blank === true
791
+ && !session.running
792
+ && !session.promptAttempted
793
+ && (blank === true || session.openState === 'open');
770
794
  }
771
795
 
772
796
  /** The workspace path a session belongs to, from the workspaces list. */
package/lib/index.js CHANGED
@@ -16,6 +16,12 @@
16
16
  * here with the session's standing sandbox policy re-rooted to that
17
17
  * directory — identical semantics to the primary workspace in every mode
18
18
  * (read-only denies, workspace-write allows, danger-full-access allows).
19
+ * Interception hydrates the configuration AWAITED (never a fire-and-forget
20
+ * read) and looks the dirs up by BOTH the policy root and the header cwd
21
+ * spelling, so a cold first call and a symlinked workspace cannot fall
22
+ * through to the default pipeline and surface a spurious workspace-write
23
+ * denial for a secondary-directory mutation. Successful write/edit
24
+ * short-circuits emit `fs/observed` like the shipped tools.
19
25
  * Background shell runs (`run_in_background: true`) register with the
20
26
  * generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
21
27
  * mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
@@ -539,8 +545,19 @@ export function apply(ctx) {
539
545
  }
540
546
 
541
547
  ctx.on('tools/execute', async (exec, next) => {
542
- if (exec.agent && exec.agent.session && exec.agent.session.header) {
543
- hydrate(exec.agent.session.header.cwd)
548
+ // Hydration must be AWAITED on the interception path, not fire-and-forget:
549
+ // a first call that arrives before the config read resolves would see an
550
+ // empty dirs cache, fall through to the default pipeline, and be fenced
551
+ // against the PRIMARY workspace root — surfacing as a spurious
552
+ // `[sandbox: file access denied under workspace-write mode]` for a
553
+ // secondary-directory write/edit. `loadDirs` caches, so only the first
554
+ // call pays the read.
555
+ const headerCwd =
556
+ exec.agent && exec.agent.session && exec.agent.session.header
557
+ ? exec.agent.session.header.cwd
558
+ : undefined
559
+ if (typeof headerCwd === 'string' && headerCwd.length > 0) {
560
+ await loadDirs(headerCwd)
544
561
  }
545
562
  if (!INTERCEPT_TOOLS.has(exec.name)) return next()
546
563
  try {
@@ -549,6 +566,13 @@ export function apply(ctx) {
549
566
  const primary = standing.workspaceRoot
550
567
  // An explicit escalation request belongs to the default pipeline.
551
568
  if (args && args.sandbox_permissions !== undefined) return next()
569
+ // The policy root is realpath-canonicalized by sandbox-policy while
570
+ // hydration is keyed by the session cwd as spelled in the header; on a
571
+ // workspace reached through a symlinked/junctioned ancestor the two
572
+ // spellings differ, so consult both keys before falling through.
573
+ const dirs =
574
+ dirsForSync(primary) ??
575
+ (typeof headerCwd === 'string' && headerCwd.length > 0 ? dirsForSync(headerCwd) : null)
552
576
 
553
577
  if (exec.name === 'write' || exec.name === 'edit') {
554
578
  const filePath = args && typeof args.file_path === 'string' ? args.file_path : null
@@ -559,15 +583,18 @@ export function apply(ctx) {
559
583
  const abs = fs.processPath(target)
560
584
  // Security boundary: configuration is user-managed. Reject direct
561
585
  // write/edit attempts against the host-owned config file, even before
562
- // any directory matching.
563
- if (wsKey(abs) === wsKey(configPathFor(primary))) {
586
+ // any directory matching. Both spellings of the workspace key are
587
+ // checked (see the dirs lookup above).
588
+ const guardHits = new Set(
589
+ [primary, headerCwd].filter((p) => typeof p === 'string' && p.length > 0).map((p) => wsKey(configPathFor(p))),
590
+ )
591
+ if (guardHits.has(wsKey(abs))) {
564
592
  return {
565
593
  isError: true,
566
594
  error: { message: 'multi-folder configuration is user-managed' },
567
595
  content: [{ type: 'text', text: CONFIG_GUARD_TEXT }],
568
596
  }
569
597
  }
570
- const dirs = dirsForSync(primary)
571
598
  if (dirs === null) return next()
572
599
  const hit = longestRootFirst(dirs).find((d) => pathInside(abs, d))
573
600
  if (hit === undefined) return next()
@@ -575,6 +602,11 @@ export function apply(ctx) {
575
602
 
576
603
  if (exec.name === 'write') {
577
604
  const outcome = await fs.writeText(target, String(args.content), undefined, exec.signal, policy)
605
+ // Keep the observation layer coherent with the shipped write tool's
606
+ // contract: a successful create/update is a presence observation.
607
+ if (typeof ctx.emit === 'function') {
608
+ ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
609
+ }
578
610
  const displayPath = displayPathOf(target, filePath)
579
611
  const value = {
580
612
  path: displayPath,
@@ -603,6 +635,9 @@ export function apply(ctx) {
603
635
  exec.signal,
604
636
  policy,
605
637
  )
638
+ if (typeof ctx.emit === 'function') {
639
+ ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
640
+ }
606
641
  const displayPath = displayPathOf(target, filePath)
607
642
  const value = { path: displayPath, before: outcome.before, after: outcome.after }
608
643
  const text = replaceAll
@@ -614,7 +649,6 @@ export function apply(ctx) {
614
649
  if (exec.name === 'pwsh' || exec.name === 'bash') {
615
650
  const shell = ctx.get('shell')
616
651
  if (shell === undefined) return next()
617
- const dirs = dirsForSync(primary)
618
652
  if (dirs === null) return next()
619
653
  const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
620
654
  const joined = rawWorkdir === null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -42,6 +42,10 @@
42
42
  "compatibility": {
43
43
  "node": ">=20",
44
44
  "dshReleases": {
45
+ "0.1.2-alpha.5": "compatible",
46
+ "0.1.2-alpha.4": "compatible",
47
+ "0.1.2-alpha.3": "compatible",
48
+ "0.1.2-alpha.2": "compatible",
45
49
  "0.1.1-rc.2": "incompatible",
46
50
  "0.1.2-alpha.1": "compatible"
47
51
  }