@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16

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.md +23 -0
  2. package/CHANGELOG_EN.md +23 -0
  3. package/README.md +29 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1512 -470
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +346 -117
  9. package/lib/path-lock.js +54 -0
  10. package/lib/repo-root.js +60 -0
  11. package/lib/worktree.js +83 -0
  12. package/lib/write-checked.js +1 -1
  13. package/package.json +5 -5
  14. package/src/client/ChromeGlyph.tsx +5 -0
  15. package/src/client/CodeEditor.tsx +19 -1
  16. package/src/client/DiffViews.tsx +116 -121
  17. package/src/client/FileBrowser.tsx +196 -23
  18. package/src/client/GitWorkbenchPanel.module.css +1 -0
  19. package/src/client/GitWorkbenchPanel.tsx +100 -12
  20. package/src/client/SideRails.tsx +106 -0
  21. package/src/client/diff-cells.tsx +147 -0
  22. package/src/client/diff-nav.ts +4 -1
  23. package/src/client/dir-tree.ts +31 -1
  24. package/src/client/file-rows.ts +40 -0
  25. package/src/client/h-rail.ts +70 -0
  26. package/src/client/ignored-cache.ts +193 -0
  27. package/src/client/index.ts +22 -3
  28. package/src/client/locales.ts +12 -2
  29. package/src/client/row-heights.ts +225 -0
  30. package/src/client/styles/changes.css +33 -2
  31. package/src/client/styles/controls.css +5 -0
  32. package/src/client/styles/files.css +5 -0
  33. package/src/client/styles/rails.css +72 -0
  34. package/src/client/use-row-window.ts +7 -3
  35. package/src/client/use-variable-row-window.ts +210 -0
  36. package/src/dir-listing.ts +47 -0
  37. package/src/fs-remove.ts +5 -36
  38. package/src/index.ts +372 -118
  39. package/src/path-lock.ts +56 -0
  40. package/src/repo-root.ts +66 -0
  41. package/src/types/dsh-shim.d.ts +12 -2
  42. package/src/worktree.ts +97 -0
  43. package/src/write-checked.ts +1 -1
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@
42
42
  * @module @young1lin/dsh-ui-gitworkbench
43
43
  */
44
44
  import { randomBytes } from 'node:crypto'
45
- import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
45
+ import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
46
46
  import { homedir, tmpdir } from 'node:os'
47
47
  import { join } from 'node:path'
48
48
  import type { Readable } from 'node:stream'
@@ -62,11 +62,14 @@ import {
62
62
  type OpFailure, type PullMode, type Tracking,
63
63
  } from './git-ops.js'
64
64
  import {
65
- planFromStatus,
65
+ isSafeRelativePath, planFromStatus,
66
66
  type DiscardEffect, type DiscardPlan,
67
67
  } from './discard-ops.js'
68
+ import { shapeDirChildren, type DirChild } from './dir-listing.js'
68
69
  import { parseBlame, type BlameLine } from './blame.js'
69
70
  import { removePathInside } from './fs-remove.js'
71
+ import { resolveInside } from './path-lock.js'
72
+ import { resolveRepoRoot, rootedDir } from './repo-root.js'
70
73
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js'
71
74
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js'
72
75
  import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
@@ -77,7 +80,7 @@ import {
77
80
  type StyleEntry, type StyleFile,
78
81
  } from './style-store.js'
79
82
  import {
80
- bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
83
+ bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir,
81
84
  type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
82
85
  } from './worktree.js'
83
86
 
@@ -112,6 +115,11 @@ const SHORTLOG_CAP = 500
112
115
  /** Path list cap for the picker: a monorepo can outrun any popup; past this
113
116
  * the tree is cut and the truncation reported, never silent. */
114
117
  const TREE_PATH_CAP = 50_000
118
+ /** Cap on the ignored entry listing `repoTree` rides along. The listing is
119
+ * proportional to the .gitignore's coverage, not the repository (git
120
+ * collapses every fully-ignored directory to one line), so real repos sit in
121
+ * the tens; this is a reported fuse for a pathological ignore setup. */
122
+ const IGNORED_PATH_CAP = 5_000
115
123
  /**
116
124
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
117
125
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -127,6 +135,21 @@ const COMMIT_HASH = /^[0-9a-fA-F]{4,40}$/
127
135
 
128
136
  export type { GitCommit } from './git-log.js'
129
137
 
138
+ /**
139
+ * The slice of dsh's `agent/session-start` payload this plugin reads: just the
140
+ * session identity and its parent edge. Declared structurally (not imported
141
+ * from `@deepseek-ai/dsh-agent`, which is not a peer of this plugin) so the
142
+ * shape this code depends on is pinned here regardless of host-side changes.
143
+ */
144
+ interface SessionStartEvent {
145
+ readonly agent: {
146
+ readonly session: {
147
+ readonly id: string
148
+ readonly header: { readonly parentSession?: string }
149
+ }
150
+ }
151
+ }
152
+
130
153
  export interface WorkbenchStats {
131
154
  readonly worktreePath: string
132
155
  readonly branch: string
@@ -269,10 +292,32 @@ export class GitWorkbenchService extends TypertRemoteService {
269
292
  */
270
293
  private readonly bindingMirror = new Map<string, WorktreeBinding>()
271
294
 
295
+ /**
296
+ * Session id → parent session id, as `agent/session-start` delivered it. A
297
+ * subagent header names its parent, and that edge is all the lineage walk
298
+ * needs: a child session without a binding of its own works under its
299
+ * nearest bound ancestor (see {@link resolveEffectiveBinding}). The map is
300
+ * never pruned — it holds one short string per session this process has
301
+ * seen, and a stale edge can only make a lookup walk further, never lie.
302
+ */
303
+ private readonly parentOf = new Map<string, string>()
304
+
272
305
  constructor(ctx: Context) {
273
306
  super(ctx, 'gitWorkbench')
274
307
  this.registerWorktreeTools(ctx)
275
308
  this.registerWorktreePrompt(ctx)
309
+ // Fires for every session the process publishes — fresh subagents and
310
+ // sessions whose loop resumes from disk. An IDLE session's edge is absent
311
+ // until its loop (re)starts, so a lookup can simply find no ancestor right
312
+ // after a host restart — the pre-feature behavior, fail-soft. `events.on`
313
+ // (not the typed `ctx.on` overload) because the event is declared by
314
+ // @deepseek-ai/dsh-agent, which this plugin does not depend on; the
315
+ // listener lives on this ctx's fiber.
316
+ ctx.events.on('agent/session-start', (payload: SessionStartEvent) => {
317
+ const session = payload.agent?.session
318
+ const parent = lineageEdgeOf(session?.header)
319
+ if (session !== undefined && parent !== undefined) this.parentOf.set(session.id, parent)
320
+ })
276
321
  // Hydrate the mirror through the same queue as the mutations, so a binding
277
322
  // written before hydration finishes is not overwritten by the stale read.
278
323
  // A failed read leaves the mirror empty: sessions then get no standing
@@ -304,16 +349,18 @@ export class GitWorkbenchService extends TypertRemoteService {
304
349
  name: 'worktree:binding',
305
350
  order: 115,
306
351
  text: (context) => {
307
- const sessionId = context.agent?.session.id
308
- const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId)
309
- if (binding === undefined) return ''
310
- const rel = `.agents/worktrees/${binding.name}`
311
- const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`
312
- return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
313
- + 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
314
- + `- shell commands: pass workdir "${rel}"\n`
315
- + `- file tools: prefix every path with ${rel}/\n`
316
- + 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.'
352
+ const session = context.agent?.session
353
+ if (session === undefined) return ''
354
+ // First hop straight off the live header: the prompt must not depend
355
+ // on the session-start event having been seen (a plugin reload
356
+ // mid-session repopulates the map only through later events).
357
+ const parent = lineageEdgeOf(session.header)
358
+ if (parent !== undefined && !this.parentOf.has(session.id)) {
359
+ this.parentOf.set(session.id, parent)
360
+ }
361
+ const effective = resolveEffectiveBinding(session.id, this.parentOf, id => this.bindingMirror.get(id))
362
+ if (effective === undefined) return ''
363
+ return bindingNotice(effective.binding.name, effective.binding.branch, effective.inherited)
317
364
  },
318
365
  })
319
366
  })
@@ -351,6 +398,7 @@ export class GitWorkbenchService extends TypertRemoteService {
351
398
  ok: { type: 'boolean' },
352
399
  error: { type: 'string' },
353
400
  binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
401
+ bindingInherited: { type: 'boolean' },
354
402
  worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
355
403
  branches: { type: 'array', items: { type: 'string' } },
356
404
  branchesTruncated: { type: 'boolean' },
@@ -395,7 +443,8 @@ export class GitWorkbenchService extends TypertRemoteService {
395
443
 
396
444
  ctx.tools.register(defineTool({
397
445
  name: 'worktree_status',
398
- description: 'Show this session\'s bound worktree (if any) and the repository\'s existing worktrees with branches.',
446
+ description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
447
+ + 'and the repository\'s existing worktrees with branches.',
399
448
  parameters: {},
400
449
  output: output(STATUS_SCHEMA),
401
450
  execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
@@ -424,10 +473,20 @@ export class GitWorkbenchService extends TypertRemoteService {
424
473
  // the browser. The tree and the counters need only `status` and
425
474
  // `--numstat`, both of which stay around 110-140ms at that size, and the
426
475
  // pane already fetches the file it is actually showing through `fileDiff`.
427
- const [statusInfo, numstat, revInfo] = await Promise.all([
476
+ // The fourth read resolves where the rest of this method must run: the
477
+ // repository ROOT. The paths this method handles are repository-relative
478
+ // (that is what porcelain status prints wherever it runs), so the
479
+ // untracked files read below must be joined against the root — a session
480
+ // opened at `repo/server` would otherwise look for `repo/server/server/f`.
481
+ // The three git reads themselves are cwd-INSENSITIVE (their output names
482
+ // paths from the repository root whatever directory they run in), so they
483
+ // stay on the session's own directory and the root resolve rides along in
484
+ // the same batch: the polled call pays for it in wall time not at all.
485
+ const [statusInfo, numstat, revInfo, root] = await Promise.all([
428
486
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
429
487
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
430
488
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
489
+ this.rootedDirOf(worktreePath, signal),
431
490
  ])
432
491
  if (statusInfo.exitCode !== 0) {
433
492
  const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : ''
@@ -443,7 +502,7 @@ export class GitWorkbenchService extends TypertRemoteService {
443
502
  // segments into the payload — is gone with the payload itself; `fileDiff`
444
503
  // synthesizes the one segment the reader has actually opened.
445
504
  const untracked = files.filter(file => file.status === 'untracked')
446
- const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
505
+ const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path))
447
506
 
448
507
  for (const [index, file] of untracked.entries()) {
449
508
  const measure = measured[index]
@@ -513,33 +572,38 @@ export class GitWorkbenchService extends TypertRemoteService {
513
572
  */
514
573
  @Remote('fileDiff')
515
574
  async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
516
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
517
575
  if (typeof path !== 'string' || path.length === 0) return { diff: '' }
576
+ // The repository root, not the session's directory: the pathspec below
577
+ // is repository-relative, and git resolves pathspecs against the cwd —
578
+ // from a subdirectory `diff HEAD -- server/f` looks for
579
+ // `server/server/f`, matches nothing, and reports an EMPTY diff with
580
+ // exit 0. (repo-root.ts holds the whole story.)
581
+ const root = await this.rootedDirOf(worktreePath, signal)
518
582
  if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
519
583
  if (!isRefName(base) || !isRefName(head)) return { diff: '' }
520
- const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
584
+ const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
521
585
  if (ranged.exitCode === 0) return { diff: ranged.stdout }
522
586
  // Unrelated histories have no merge base for `A...B` to diff from; the
523
587
  // two-tip diff still answers what differs, exactly as `compareRefs` does.
524
588
  if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
525
- const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal)
589
+ const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal)
526
590
  return { diff: tips.exitCode === 0 ? tips.stdout : '' }
527
591
  }
528
592
  if (typeof commit === 'string' && commit.length > 0) {
529
593
  if (!COMMIT_HASH.test(commit)) return { diff: '' }
530
- const key = cacheKey(cwd, commit, path)
594
+ const key = cacheKey(root, commit, path)
531
595
  const cached = this.commitDiffCache.get(key)
532
596
  if (cached !== undefined) return { diff: cached }
533
597
  // `--first-parent` for the same reason as in `commitStats`: without it a
534
598
  // merge commit has no diff to show and the pane opens empty.
535
- const shown = await this.git(cwd, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
599
+ const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
536
600
  if (shown.exitCode !== 0) return { diff: '' }
537
601
  this.commitDiffCache.set(key, shown.stdout)
538
602
  return { diff: shown.stdout }
539
603
  }
540
- const tracked = await this.git(cwd, ['diff', 'HEAD', '--', path], signal)
604
+ const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal)
541
605
  if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
542
- return { diff: await untrackedSegment(cwd, path) ?? '' }
606
+ return { diff: await untrackedSegment(root, path) ?? '' }
543
607
  }
544
608
 
545
609
  /**
@@ -555,7 +619,8 @@ export class GitWorkbenchService extends TypertRemoteService {
555
619
  * nothing for them and the unstaged layer falls back to the synthesized
556
620
  * new-file segment `fileDiff` already uses.
557
621
  *
558
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
622
+ * @param worktreePath - directory the session opened; git runs at its
623
+ * repository root. Empty falls back to the host cwd.
559
624
  * @param path - repository-relative path, as the drawer lists it.
560
625
  * @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
561
626
  * @param signal - abort signal.
@@ -565,41 +630,48 @@ export class GitWorkbenchService extends TypertRemoteService {
565
630
  if (layer !== 'unstaged' && layer !== 'staged') {
566
631
  throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`)
567
632
  }
568
- if (typeof path !== 'string' || !isSafePathArg(path)) {
633
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
569
634
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
570
635
  }
571
- const cwd = this.cwdOf(worktreePath)
636
+ // Repository root, not the session's directory: every path below is
637
+ // repository-relative (pathspecs resolve against the cwd, and so does
638
+ // the file read for the editor's target). See repo-root.ts.
639
+ const root = await this.rootedDirOf(worktreePath, signal)
640
+ // The unstaged layer READS THE FILE, so its absolute path is built by the
641
+ // lock rather than by a join — see path-lock.ts. Done here, once, so the
642
+ // one place that resolves a client path is visible in this method.
572
643
  return layer === 'unstaged'
573
- ? await this.unstagedSides(cwd, path, signal)
574
- : await this.stagedSides(cwd, path, signal)
644
+ ? await this.unstagedSides(root, path, resolveInside(root, path), signal)
645
+ : await this.stagedSides(root, path, signal)
575
646
  }
576
647
 
577
- /** The unstaged layer: diff index→worktree, target = the working-tree file. */
578
- private async unstagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
648
+ /** The unstaged layer: diff index→worktree, target = the working-tree file.
649
+ * `full` is that file's absolute path, already through the lock. */
650
+ private async unstagedSides(root: string, path: string, full: string, signal: AbortSignal): Promise<FileSides> {
579
651
  // Size guard first, off the stat rather than a read: declining a file past
580
652
  // the cap must not mean loading a pathological one whole first. Bytes are
581
653
  // all a stat knows; the line half of the guard needs the read below.
582
654
  try {
583
- const info = await stat(join(cwd, path))
655
+ const info = await stat(full)
584
656
  if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
585
657
  } catch {
586
658
  // Missing file: deleted in the working tree, which the diff below states.
587
659
  }
588
660
  let bytes: Buffer | null = null
589
661
  try {
590
- bytes = await readFile(join(cwd, path))
662
+ bytes = await readFile(full)
591
663
  } catch {
592
664
  bytes = null
593
665
  }
594
666
  if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
595
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
667
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
596
668
  }
597
669
  if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
598
670
  return { ...emptySides(), tooLarge: true }
599
671
  }
600
- const diff = await this.layerDiffText(cwd, path, 'unstaged', signal)
672
+ const diff = await this.layerDiffText(root, path, 'unstaged', signal)
601
673
  if (binaryDiffOutput(diff)) {
602
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
674
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
603
675
  }
604
676
  // The diff half of the guard, AFTER the artifact exists: the target-side
605
677
  // checks above cannot see a worktree-deleted large file (no target to
@@ -610,7 +682,7 @@ export class GitWorkbenchService extends TypertRemoteService {
610
682
  diff,
611
683
  diffSha: sha1Hex(diff),
612
684
  targetText: bytes === null ? '' : bytes.toString('utf8'),
613
- targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
685
+ targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
614
686
  binary: false,
615
687
  tooLarge: false,
616
688
  // Only the unstaged layer can report this, and only it needs to: the
@@ -620,11 +692,11 @@ export class GitWorkbenchService extends TypertRemoteService {
620
692
  }
621
693
 
622
694
  /** The staged layer: diff HEAD→index, target = the index blob. */
623
- private async stagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
695
+ private async stagedSides(root: string, path: string, signal: AbortSignal): Promise<FileSides> {
624
696
  // `:path` resolves the stage-0 index entry: the target text when it exists,
625
697
  // and a failed resolution (no entry) is the empty target, not an error.
626
- const shown = await this.git(cwd, ['show', `:${path}`], signal)
627
- const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
698
+ const shown = await this.git(root, ['show', `:${path}`], signal)
699
+ const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
628
700
  const targetText = shown.exitCode === 0 ? shown.stdout : ''
629
701
  const targetBytes = Buffer.from(targetText, 'utf8')
630
702
  if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
@@ -633,7 +705,7 @@ export class GitWorkbenchService extends TypertRemoteService {
633
705
  if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
634
706
  return { ...emptySides(), tooLarge: true, targetSha: sha }
635
707
  }
636
- const diff = await this.layerDiffText(cwd, path, 'staged', signal)
708
+ const diff = await this.layerDiffText(root, path, 'staged', signal)
637
709
  if (binaryDiffOutput(diff)) {
638
710
  return { ...emptySides(), binary: true, targetSha: sha }
639
711
  }
@@ -655,8 +727,8 @@ export class GitWorkbenchService extends TypertRemoteService {
655
727
  }
656
728
 
657
729
  /** Blob sha of the working-tree file, '' when git cannot hash it. */
658
- private async worktreeBlobSha(cwd: string, path: string, signal: AbortSignal): Promise<string> {
659
- const hashed = await this.git(cwd, ['hash-object', '--', path], signal)
730
+ private async worktreeBlobSha(root: string, path: string, signal: AbortSignal): Promise<string> {
731
+ const hashed = await this.git(root, ['hash-object', '--', path], signal)
660
732
  return hashed.exitCode === 0 ? hashed.stdout.trim() : ''
661
733
  }
662
734
 
@@ -669,19 +741,19 @@ export class GitWorkbenchService extends TypertRemoteService {
669
741
  * its own fresh fetch, so the two ends of the stale comparison are over the
670
742
  * SAME text by construction, not by two fetch sites staying in step.
671
743
  */
672
- private async layerDiffText(cwd: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
744
+ private async layerDiffText(root: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
673
745
  if (layer === 'staged') {
674
- return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
746
+ return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
675
747
  }
676
- const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
748
+ const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
677
749
  // Untracked files have no index entry, so `git diff` reports nothing for
678
750
  // them; the synthesized new-file segment is their unstaged diff. The
679
751
  // trailing newline is added here because every diff git prints carries
680
752
  // one: this text is what `applyBlocks` re-emits as a patch file, and a
681
753
  // patch whose last line has no LF is "corrupt patch" to `git apply` —
682
754
  // which is exactly what a real-git drive of the untracked path caught.
683
- if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
684
- const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP)
755
+ if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
756
+ const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP)
685
757
  return segment === null ? '' : `${segment}\n`
686
758
  }
687
759
  return diff
@@ -700,7 +772,7 @@ export class GitWorkbenchService extends TypertRemoteService {
700
772
  * shared with `fileSides`, and the tmpfile pair. Every failure comes back as
701
773
  * a result — the method never throws across the RPC boundary.
702
774
  *
703
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
775
+ * @param worktreePath - directory the session opened; git runs at its repository root.
704
776
  * @param path - repository-relative path, as the drawer lists it.
705
777
  * @param layer - the layer the block was selected on; the mode decides which
706
778
  * one that may be.
@@ -712,14 +784,16 @@ export class GitWorkbenchService extends TypertRemoteService {
712
784
  */
713
785
  @Remote('applyBlocks')
714
786
  async applyBlocks(worktreePath: string, path: string, layer: string, diffSha: string, lines: readonly number[], mode: string, signal: AbortSignal): Promise<GitOpResult> {
715
- const cwd = this.cwdOf(worktreePath)
787
+ // The repository root — the patch's pathspecs are repository-relative
788
+ // and `git apply` resolves them against the cwd (repo-root.ts).
789
+ const root = await this.rootedDirOf(worktreePath, signal)
716
790
  const io: ApplyBlocksIo = {
717
791
  git: (dir, argv) => this.git(dir, argv, signal),
718
- layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
792
+ layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
719
793
  writePatch: writeTmpPatch,
720
794
  dropPatch: dropTmpPatch,
721
795
  }
722
- return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode)
796
+ return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode)
723
797
  }
724
798
 
725
799
  /**
@@ -741,7 +815,7 @@ export class GitWorkbenchService extends TypertRemoteService {
741
815
  * check and this method are one thing, and no unchecked write RPC exists or
742
816
  * may be added in this plugin.
743
817
  *
744
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
818
+ * @param worktreePath - directory the session opened; git runs at its repository root.
745
819
  * @param path - repository-relative path, as the drawer lists it.
746
820
  * @param text - the editor buffer, verbatim; written as bytes (LF as given).
747
821
  * @param expectedSha - the `targetSha` the buffer was opened with ('' when
@@ -751,7 +825,10 @@ export class GitWorkbenchService extends TypertRemoteService {
751
825
  */
752
826
  @Remote('writeChecked')
753
827
  async writeChecked(worktreePath: string, path: string, text: string, expectedSha: string, signal: AbortSignal): Promise<WriteResult> {
754
- const cwd = this.cwdOf(worktreePath)
828
+ // The repository root — the save target joins the same base every
829
+ // other path here is relative to, and that base is the root, not the
830
+ // directory the session opened (repo-root.ts).
831
+ const root = await this.rootedDirOf(worktreePath, signal)
755
832
  const io: WriteCheckedIo = {
756
833
  git: (dir, argv) => this.git(dir, argv, signal),
757
834
  exists: async p => {
@@ -768,7 +845,7 @@ export class GitWorkbenchService extends TypertRemoteService {
768
845
  remove: async p => { await rm(p, { force: true }) },
769
846
  delay: ms => new Promise(resolve => { setTimeout(resolve, ms) }),
770
847
  }
771
- return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
848
+ return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
772
849
  }
773
850
 
774
851
  /**
@@ -780,7 +857,7 @@ export class GitWorkbenchService extends TypertRemoteService {
780
857
  * rather than missing. Read-only, no index or worktree is touched, so this
781
858
  * needs none of the confirmation machinery the write paths carry.
782
859
  *
783
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
860
+ * @param worktreePath - directory the session opened; git runs at its repository root.
784
861
  * @param path - repository-relative path, as the drawer lists it.
785
862
  * @param signal - abort signal.
786
863
  */
@@ -789,10 +866,13 @@ export class GitWorkbenchService extends TypertRemoteService {
789
866
  if (typeof path !== 'string' || !isSafePathArg(path)) {
790
867
  return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` }
791
868
  }
792
- const cwd = this.cwdOf(worktreePath)
869
+ // The repository root: blame's pathspec resolves against the cwd, and
870
+ // from a subdirectory the repository-relative path doubles up
871
+ // (`server/server/f`) into a fatal "no such path" (repo-root.ts).
872
+ const root = await this.rootedDirOf(worktreePath, signal)
793
873
  // `--` keeps a path that looks like a revision from being read as one, as
794
874
  // every other pathspec in this plugin does.
795
- const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal)
875
+ const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal)
796
876
  if (run.exitCode !== 0) {
797
877
  // An untracked file has no blame, and git says so; that message is the
798
878
  // honest thing to show rather than an empty gutter.
@@ -826,16 +906,20 @@ export class GitWorkbenchService extends TypertRemoteService {
826
906
  *
827
907
  * Read-only — nothing is spawned, nothing is written.
828
908
  *
829
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
909
+ * @param worktreePath - directory the session opened; git runs at its repository root.
830
910
  * @param path - repository-relative path, as the drawer lists it.
831
911
  * @param signal - abort signal.
832
912
  */
833
913
  @Remote('fileImage')
834
914
  async fileImage(worktreePath: string, path: string, signal: AbortSignal): Promise<FileImage> {
835
- if (typeof path !== 'string' || !isSafePathArg(path)) {
915
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
836
916
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
837
917
  }
838
- const full = join(this.cwdOf(worktreePath), path)
918
+ // The repository root — the image is read from disk at the same base
919
+ // every other path in this plugin is relative to (repo-root.ts) — and
920
+ // through the lock, because this is a raw read with no git in the way
921
+ // to refuse a path that leaves the repository (path-lock.ts).
922
+ const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path)
839
923
  let size = 0
840
924
  try {
841
925
  const info = await stat(full)
@@ -871,10 +955,10 @@ export class GitWorkbenchService extends TypertRemoteService {
871
955
  * Those are the files whose diff has to be synthesized rather than asked of
872
956
  * `git diff`, which reports nothing for them.
873
957
  */
874
- private async isUntracked(cwd: string, path: string, signal: AbortSignal): Promise<boolean> {
875
- const listed = await this.git(cwd, ['ls-files', '--', path], signal)
958
+ private async isUntracked(root: string, path: string, signal: AbortSignal): Promise<boolean> {
959
+ const listed = await this.git(root, ['ls-files', '--', path], signal)
876
960
  if (listed.exitCode !== 0 || listed.stdout.trim().length > 0) return false
877
- const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
961
+ const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
878
962
  return head.exitCode !== 0
879
963
  }
880
964
 
@@ -909,11 +993,17 @@ export class GitWorkbenchService extends TypertRemoteService {
909
993
  // empty pane. Against the first parent the answer is well defined and is the
910
994
  // useful one: what this merge brought into the branch it landed on. On a
911
995
  // single-parent commit the flag is a no-op, byte for byte.
996
+ // The repository root: no pathspec here today, but the cache key below
997
+ // must describe the same repository to a later caller, and running from
998
+ // the root is the one rule every read in this plugin follows
999
+ // (repo-root.ts). Resolved after the cache probe so a hit spawns
1000
+ // nothing.
1001
+ const root = await this.rootedDirOf(worktreePath, signal)
912
1002
  const [meta, numstat, nameStatus, patch] = await Promise.all([
913
- this.git(cwd, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
914
- this.git(cwd, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
915
- this.git(cwd, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
916
- this.git(cwd, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
1003
+ this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
1004
+ this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
1005
+ this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
1006
+ this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
917
1007
  ])
918
1008
  if (meta.exitCode !== 0) {
919
1009
  const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : ''
@@ -979,6 +1069,10 @@ export class GitWorkbenchService extends TypertRemoteService {
979
1069
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
980
1070
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
981
1071
  const effective = filter ?? emptyLogFilter()
1072
+ // The repository root: a filter's pathspec is repository-relative, and
1073
+ // git resolves pathspecs against the cwd — from a subdirectory a
1074
+ // filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
1075
+ const root = await this.rootedDirOf(worktreePath, signal)
982
1076
  // Reading one row beyond the page answers "is there more" without a second
983
1077
  // traversal of the log.
984
1078
  //
@@ -992,7 +1086,7 @@ export class GitWorkbenchService extends TypertRemoteService {
992
1086
  // Filter args go LAST: their segment ends with `--` + pathspecs, and
993
1087
  // nothing after that separator may be parsed as a flag.
994
1088
  const log = await this.git(
995
- cwd,
1089
+ root,
996
1090
  ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)],
997
1091
  signal,
998
1092
  )
@@ -1032,21 +1126,124 @@ export class GitWorkbenchService extends TypertRemoteService {
1032
1126
 
1033
1127
  /**
1034
1128
  * Every file path on HEAD — the filter popup's path picker, aggregated into
1035
- * a directory tree client-side.
1129
+ * a directory tree client-side — plus the ignored-but-present entries the
1130
+ * Files tab browses.
1131
+ *
1132
+ * The ignored listing is `ls-files --others --ignored --exclude-standard
1133
+ * --directory`: every ignored FILE is listed verbatim (`application-local.yml`
1134
+ * is exactly the file a browser must find and `ls-tree HEAD` cannot), while
1135
+ * every directory a rule ignores as a whole collapses to ONE line with a
1136
+ * trailing slash — `node_modules/` costs one entry, not the ~40k files
1137
+ * inside it. The list therefore scales with the .gitignore's coverage, not
1138
+ * the repository: measured 54 entries / 48ms on this checkout with
1139
+ * node_modules present.
1036
1140
  *
1037
- * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
1038
- * would render non-ASCII names as quoted octal escapes under
1039
- * `core.quotepath` and hand the picker garbage.
1141
+ * `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
1142
+ * while the default would render non-ASCII names as quoted octal escapes
1143
+ * under `core.quotepath` and hand the picker garbage.
1040
1144
  * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
1041
1145
  * @param signal - abort signal.
1042
1146
  */
1043
1147
  @Remote('repoTree')
1044
- async repoTree(worktreePath: string, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean }> {
1045
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
1046
- const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal)
1148
+ async repoTree(worktreePath: string, signal: AbortSignal): Promise<{
1149
+ paths: string[]
1150
+ truncated: boolean
1151
+ /** Ignored entries verbatim; directories keep their trailing slash, which
1152
+ * is the only thing that tells them from files. */
1153
+ ignored: string[]
1154
+ ignoredTruncated: boolean
1155
+ /** Present only when the ignored listing FAILED, so the browser can tell
1156
+ * "this repository ignores nothing" from "the question could not be
1157
+ * asked". Omitted on success — never `undefined`, which is not JSON. */
1158
+ ignoredError?: string
1159
+ }> {
1160
+ // The repository root: unlike status and numstat, `ls-tree` prints
1161
+ // cwd-RELATIVE paths — from a subdirectory every entry would lose the
1162
+ // `server/` prefix and the picker would feed the log filter pathspecs
1163
+ // that match nothing (repo-root.ts).
1164
+ const root = await this.rootedDirOf(worktreePath, signal)
1165
+ const [res, ignoredRes] = await Promise.all([
1166
+ this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
1167
+ this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
1168
+ ])
1047
1169
  const all = res.stdout.split('\0').filter(path => path.length > 0)
1048
1170
  const truncated = all.length > TREE_PATH_CAP
1049
- return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated }
1171
+ const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0)
1172
+ const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP
1173
+ // `ls-tree`'s exit code is deliberately NOT checked: a repository with no
1174
+ // HEAD yet fails it, and "this repository has no files" is the honest
1175
+ // answer there. The ignored listing has no such legitimate failure, so a
1176
+ // non-zero exit is reported rather than served as an empty list — the one
1177
+ // shape that is indistinguishable from a repository ignoring nothing.
1178
+ const ignoredError = ignoredRes.exitCode === 0
1179
+ ? undefined
1180
+ : (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500)
1181
+ return {
1182
+ paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
1183
+ truncated,
1184
+ ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
1185
+ ignoredTruncated,
1186
+ ...(ignoredError !== undefined ? { ignoredError } : {}),
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * One level of one ignored directory, read from the filesystem — the step
1192
+ * behind clicking `node_modules/` open in the Files tab.
1193
+ *
1194
+ * Deliberately NOT a git listing: with `--directory`, a pathspec under an
1195
+ * ignored directory still collapses to that one directory line (probed:
1196
+ * `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
1197
+ * answers `node_modules/` and nothing else), and dropping `--directory`
1198
+ * would enumerate every file inside at once — the very flood the collapsed
1199
+ * listing exists to avoid. `readdir` is one level by construction and costs
1200
+ * milliseconds. What the entries ARE is inherited anyway: everything below
1201
+ * an ignored directory is ignored by descent, so the browser owes the
1202
+ * reader the directory's contents, not git's opinion of them.
1203
+ *
1204
+ * A directory that vanished between the listing and the click answers
1205
+ * empty: nothing to browse is the honest answer, and the next refresh
1206
+ * drops the row.
1207
+ *
1208
+ * The name says what it is FOR, not what it permits: nothing here checks
1209
+ * that `dir` is ignored, so this lists any directory inside the worktree.
1210
+ * That is the same reach the reader already has through `fileSides` and
1211
+ * `fileImage`, and adding an ignore check would only make the browser ask
1212
+ * git a second question to learn what it already knows from the listing it
1213
+ * was handed. What it does NOT permit is leaving the worktree, which is
1214
+ * what the path lock below is for.
1215
+ * @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
1216
+ * @param dir - repo-relative directory path, as the collapsed listing named it.
1217
+ * @param signal - abort signal.
1218
+ */
1219
+ @Remote('ignoredDir')
1220
+ async ignoredDir(worktreePath: string, dir: string, signal: AbortSignal): Promise<{ entries: DirChild[]; truncated: boolean }> {
1221
+ // The same lock every filesystem read in this plugin passes: the browser
1222
+ // is a less trusted source of paths than git's own output, and `readdir`
1223
+ // obeys no repository boundary (path-lock.ts).
1224
+ const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir)
1225
+ let dirents
1226
+ try {
1227
+ dirents = await readdir(target, { withFileTypes: true })
1228
+ } catch {
1229
+ return { entries: [], truncated: false }
1230
+ }
1231
+ const plain = dirents
1232
+ .filter(entry => !entry.isSymbolicLink())
1233
+ .map(entry => ({ name: entry.name, dir: entry.isDirectory() }))
1234
+ // A pnpm-style layout makes every package row a symlink into the store;
1235
+ // `withFileTypes` reports the LINK, so a follow-up stat decides whether
1236
+ // the row expands. A broken link reads as a file and simply fails to
1237
+ // open, like any other dangling name.
1238
+ const links = dirents.filter(entry => entry.isSymbolicLink())
1239
+ const resolved = await Promise.all(links.map(async entry => {
1240
+ try {
1241
+ return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() }
1242
+ } catch {
1243
+ return { name: entry.name, dir: false }
1244
+ }
1245
+ }))
1246
+ return shapeDirChildren([...plain, ...resolved])
1050
1247
  }
1051
1248
 
1052
1249
  /**
@@ -1120,13 +1317,20 @@ export class GitWorkbenchService extends TypertRemoteService {
1120
1317
  }
1121
1318
  }
1122
1319
 
1123
- /** The session's worktree binding, or nulls when unbound (plain-identifier params; signal last). */
1320
+ /**
1321
+ * The session's EFFECTIVE worktree binding — its own, else the nearest bound
1322
+ * ancestor's — or nulls when neither exists. This is what the chip follows,
1323
+ * so a subagent session shows the worktree its conversation works in without
1324
+ * ever holding a binding of its own (plain-identifier params; signal last).
1325
+ */
1124
1326
  @Remote('sessionWorktree')
1125
- async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null }> {
1126
- if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null }
1327
+ async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null; inherited: boolean }> {
1328
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null, inherited: false }
1127
1329
  const file = await this.bindingsIo().load()
1128
- const binding = file.bindings[sessionId]
1129
- return binding === undefined ? { worktreePath: null, name: null } : { worktreePath: binding.worktreePath, name: binding.name }
1330
+ const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
1331
+ return effective === undefined
1332
+ ? { worktreePath: null, name: null, inherited: false }
1333
+ : { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited }
1130
1334
  }
1131
1335
 
1132
1336
  /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
@@ -1221,7 +1425,15 @@ export class GitWorkbenchService extends TypertRemoteService {
1221
1425
  return this.withBindings(async io => {
1222
1426
  const file = await io.load()
1223
1427
  const binding = file.bindings[sessionId]
1224
- if (binding === undefined) return { ok: false, error: 'no worktree binding for this session' }
1428
+ if (binding === undefined) {
1429
+ // A subagent CAN see a worktree in its status while holding no binding
1430
+ // of its own (it works under its parent's). Naming that here keeps the
1431
+ // model from retrying an exit that cannot succeed.
1432
+ const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
1433
+ return inherited === undefined
1434
+ ? { ok: false, error: 'no worktree binding for this session' }
1435
+ : { ok: false, error: 'this session has no binding of its own; its worktree is entered by a parent session — ask the parent session to call worktree_exit' }
1436
+ }
1225
1437
  if (remove === true) {
1226
1438
  const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal)
1227
1439
  if (status.exitCode !== 0) {
@@ -1253,21 +1465,26 @@ export class GitWorkbenchService extends TypertRemoteService {
1253
1465
  * Branches come back most-recently-committed first. With hundreds of them the
1254
1466
  * order is what makes the list usable — the handful anyone is working on sit
1255
1467
  * at the top, so the picker is useful before a single character is typed.
1256
- * @param sessionId - session whose binding is looked up.
1468
+ * @param sessionId - session whose effective binding is looked up.
1257
1469
  * @param repoPath - caller's directory, used when the session is unbound.
1258
1470
  * @param signal - abort signal.
1259
- * @returns the binding, the repository's worktrees, and its local branches.
1471
+ * @returns the effective binding (with whether an ancestor lent it), the
1472
+ * repository's worktrees, and its local branches.
1260
1473
  */
1261
1474
  @Remote('worktreeStatus')
1262
- async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
1475
+ async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; bindingInherited: boolean; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
1263
1476
  const file = await this.bindingsIo().load()
1264
- const binding = typeof sessionId === 'string' && sessionId.length > 0 ? file.bindings[sessionId] ?? null : null
1477
+ const effective = typeof sessionId === 'string' && sessionId.length > 0
1478
+ ? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
1479
+ : undefined
1480
+ const binding = effective?.binding ?? null
1481
+ const bindingInherited = effective?.inherited ?? false
1265
1482
  // Unbound: list the CALLER's repo. Falling back to the host's launch directory
1266
1483
  // would answer about whatever directory dsh was started in, not this session's.
1267
1484
  const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
1268
1485
  const cwd = binding?.repoRoot ?? caller
1269
1486
  const root = await this.repoRootOf(cwd, signal)
1270
- if (root === null) return { binding, worktrees: [], branches: [], branchesTruncated: false }
1487
+ if (root === null) return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false }
1271
1488
  const [listed, named] = await Promise.all([
1272
1489
  this.git(root, ['worktree', 'list', '--porcelain'], signal),
1273
1490
  this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
@@ -1276,6 +1493,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1276
1493
  const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP)
1277
1494
  return {
1278
1495
  binding,
1496
+ bindingInherited,
1279
1497
  worktrees: parseWorktreeList(listed.stdout),
1280
1498
  branches,
1281
1499
  branchesTruncated,
@@ -1358,25 +1576,31 @@ export class GitWorkbenchService extends TypertRemoteService {
1358
1576
 
1359
1577
  /**
1360
1578
  * Add paths to the index.
1361
- * @param worktreePath - directory to run in.
1579
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1362
1580
  * @param paths - repository-relative paths; an empty list is refused rather
1363
1581
  * than turned into a whole-tree `git add`.
1364
1582
  * @param signal - abort signal.
1365
1583
  */
1366
1584
  @Remote('stage')
1367
1585
  async stage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
1368
- return this.writeOp(worktreePath, () => stageArgv(asPathList(paths)), signal)
1586
+ // The repository root: `git add` resolves its pathspecs against the cwd,
1587
+ // and from a subdirectory a repository-relative path dies with
1588
+ // "pathspec did not match any files" (repo-root.ts).
1589
+ const root = await this.rootedDirOf(worktreePath, signal)
1590
+ return this.writeOp(root, () => stageArgv(asPathList(paths)), signal)
1369
1591
  }
1370
1592
 
1371
1593
  /**
1372
1594
  * Remove paths from the index, leaving the working tree untouched.
1373
- * @param worktreePath - directory to run in.
1595
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1374
1596
  * @param paths - repository-relative paths.
1375
1597
  * @param signal - abort signal.
1376
1598
  */
1377
1599
  @Remote('unstage')
1378
1600
  async unstage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
1379
- return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal)
1601
+ // Same rule as `stage` — the pathspecs are repository-relative.
1602
+ const root = await this.rootedDirOf(worktreePath, signal)
1603
+ return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal)
1380
1604
  }
1381
1605
 
1382
1606
  /**
@@ -1388,7 +1612,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1388
1612
  * cannot come back" is exactly the difference the reader is being asked
1389
1613
  * about. So the dialog is built from this, read fresh, rather than from the
1390
1614
  * row that was clicked.
1391
- * @param worktreePath - directory to run in.
1615
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1392
1616
  * @param path - repository-relative path, as the drawer lists it.
1393
1617
  * @param signal - abort signal.
1394
1618
  * @returns the effect and whether it is irreversible; `effect` is absent when
@@ -1401,9 +1625,12 @@ export class GitWorkbenchService extends TypertRemoteService {
1401
1625
  previousPath?: string
1402
1626
  error?: string
1403
1627
  }> {
1628
+ // The repository root — the plan's argv and delete paths are
1629
+ // repository-relative (repo-root.ts).
1630
+ const root = await this.rootedDirOf(worktreePath, signal)
1404
1631
  let plan: DiscardPlan | null
1405
1632
  try {
1406
- plan = await this.planDiscard(worktreePath, path, signal)
1633
+ plan = await this.planDiscard(root, path, signal)
1407
1634
  } catch (error) {
1408
1635
  return { error: error instanceof Error ? error.message : String(error) }
1409
1636
  }
@@ -1427,7 +1654,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1427
1654
  * it. `expectedEffect` is what the reader was shown and agreed to: if the
1428
1655
  * file changed underneath the dialog — staged, edited, reverted by someone
1429
1656
  * else — the freshly derived effect no longer matches and nothing is done.
1430
- * @param worktreePath - directory to run in.
1657
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1431
1658
  * @param path - repository-relative path, as the drawer lists it.
1432
1659
  * @param expectedEffect - the effect the confirmation stated; blank skips
1433
1660
  * the agreement check, which only the reversible
@@ -1437,9 +1664,13 @@ export class GitWorkbenchService extends TypertRemoteService {
1437
1664
  */
1438
1665
  @Remote('discardFile')
1439
1666
  async discardFile(worktreePath: string, path: string, expectedEffect: string | undefined, signal: AbortSignal): Promise<GitOpResult & { effect?: DiscardEffect }> {
1667
+ // The repository root, resolved ONCE for the plan and the steps alike:
1668
+ // every step's argv and delete path came out of a whole-tree porcelain
1669
+ // status, so they are repository-relative (repo-root.ts).
1670
+ const root = await this.rootedDirOf(worktreePath, signal)
1440
1671
  let plan: DiscardPlan | null
1441
1672
  try {
1442
- plan = await this.planDiscard(worktreePath, path, signal)
1673
+ plan = await this.planDiscard(root, path, signal)
1443
1674
  } catch (error) {
1444
1675
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1445
1676
  }
@@ -1454,10 +1685,9 @@ export class GitWorkbenchService extends TypertRemoteService {
1454
1685
  }
1455
1686
  }
1456
1687
 
1457
- const cwd = this.cwdOf(worktreePath)
1458
1688
  for (const step of plan.steps) {
1459
1689
  if (step.kind === 'git') {
1460
- const result = await this.git(cwd, step.argv, signal)
1690
+ const result = await this.git(root, step.argv, signal)
1461
1691
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
1462
1692
  if (failure !== null) {
1463
1693
  return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
@@ -1465,7 +1695,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1465
1695
  continue
1466
1696
  }
1467
1697
  try {
1468
- await removePathInside(cwd, step.path)
1698
+ await removePathInside(root, step.path)
1469
1699
  } catch (error) {
1470
1700
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1471
1701
  }
@@ -1481,12 +1711,14 @@ export class GitWorkbenchService extends TypertRemoteService {
1481
1711
  * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1482
1712
  * other" where the truth is "undo the rename".
1483
1713
  */
1484
- private async planDiscard(worktreePath: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
1714
+ private async planDiscard(root: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
1485
1715
  if (typeof path !== 'string' || !isSafePathArg(path)) {
1486
1716
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
1487
1717
  }
1488
- const cwd = this.cwdOf(worktreePath)
1489
- const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
1718
+ // Runs at the repository root the caller resolved: the plan's paths
1719
+ // must agree with the directory its steps execute in, and with the
1720
+ // client's `path`, which the drawer lists repository-relative.
1721
+ const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
1490
1722
  if (status.exitCode !== 0) {
1491
1723
  throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed')
1492
1724
  }
@@ -1502,7 +1734,8 @@ export class GitWorkbenchService extends TypertRemoteService {
1502
1734
  */
1503
1735
  @Remote('commit')
1504
1736
  async commit(worktreePath: string, message: string, amend: boolean | undefined, signal: AbortSignal): Promise<GitOpResult> {
1505
- return this.writeOp(worktreePath, () => commitArgv(String(message ?? ''), amend === true), signal)
1737
+ // No pathspec in this argv the session's own directory suffices.
1738
+ return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal)
1506
1739
  }
1507
1740
 
1508
1741
  /**
@@ -1513,7 +1746,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1513
1746
  */
1514
1747
  @Remote('fetch')
1515
1748
  async fetch(worktreePath: string, signal: AbortSignal): Promise<GitOpResult & { tracking?: Tracking }> {
1516
- const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS)
1749
+ const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS)
1517
1750
  if (!result.ok) return result
1518
1751
  // The point of fetching is the count it produces, so report it in the same
1519
1752
  // round trip rather than making the client ask again.
@@ -1531,7 +1764,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1531
1764
  @Remote('pull')
1532
1765
  async pull(worktreePath: string, mode: string | undefined, signal: AbortSignal): Promise<GitOpResult> {
1533
1766
  const chosen: PullMode = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only'
1534
- return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
1767
+ return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
1535
1768
  }
1536
1769
 
1537
1770
  /**
@@ -1550,12 +1783,18 @@ export class GitWorkbenchService extends TypertRemoteService {
1550
1783
  const tracking = parseTracking(status.stdout)
1551
1784
  if (tracking.detached) return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' }
1552
1785
  if (tracking.branch.length === 0) return { ok: false, failure: 'unknown', error: 'no branch to push' }
1553
- return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
1786
+ return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
1554
1787
  }
1555
1788
 
1556
- /** Shared shape for every write op: run it, classify what went wrong. */
1789
+ /** Shared shape for every write op: run it, classify what went wrong.
1790
+ *
1791
+ * Takes the DIRECTORY to run in, already resolved: callers carrying
1792
+ * repository-relative pathspecs pass the rooted directory
1793
+ * ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
1794
+ * pass the session's own directory.
1795
+ */
1557
1796
  private async writeOp(
1558
- worktreePath: string,
1797
+ dir: string,
1559
1798
  build: () => readonly string[],
1560
1799
  signal: AbortSignal,
1561
1800
  graceMs?: number,
@@ -1568,7 +1807,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1568
1807
  // list or a blank commit message takes.
1569
1808
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1570
1809
  }
1571
- const result = await this.git(this.cwdOf(worktreePath), argv, signal, graceMs)
1810
+ const result = await this.git(dir, argv, signal, graceMs)
1572
1811
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
1573
1812
  if (failure === null) return { ok: true, output: result.stdout.trim().slice(-1000) }
1574
1813
  // The classification is a hint; the real text rides along beside it, because
@@ -1637,10 +1876,25 @@ export class GitWorkbenchService extends TypertRemoteService {
1637
1876
  }
1638
1877
 
1639
1878
  /** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
1640
- private async repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
1641
- const out = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal)
1642
- if (out.exitCode !== 0) return null
1643
- return out.stdout.trim().replace(/\\/g, '/') || null
1879
+ private repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
1880
+ return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd)
1881
+ }
1882
+
1883
+ /**
1884
+ * The directory to run git in and join paths against for a session's
1885
+ * `worktreePath`: the repository ROOT, falling back to the directory
1886
+ * itself outside a repository (the caller's own git run then fails the
1887
+ * way it always did, and that error is the honest one to show).
1888
+ *
1889
+ * The drawer's paths are repository-relative — porcelain status and
1890
+ * `--numstat` print them that way wherever they run — while pathspecs,
1891
+ * `:path` revisions, `hash-object` arguments and `join(dir, path)` all
1892
+ * resolve against the directory a command runs in. Those two halves only
1893
+ * agree at the root, so every method that carries a path runs there.
1894
+ * `repo-root.ts` tells the whole story, with the probes that caught it.
1895
+ */
1896
+ private rootedDirOf(worktreePath: string | undefined, signal: AbortSignal): Promise<string> {
1897
+ return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath))
1644
1898
  }
1645
1899
 
1646
1900
  /**
@@ -1718,14 +1972,14 @@ interface UntrackedMeasure {
1718
1972
  * count newlines is the expensive half of this pass, and most untracked files
1719
1973
  * never reach the bundled diff. Never throws; an unreadable file reports zero
1720
1974
  * lines and nothing to diff.
1721
- * @param cwd - worktree the path is relative to.
1975
+ * @param root - repository root the path is relative to.
1722
1976
  * @param path - repository-relative file path.
1723
1977
  * @returns the file's line count, binary flag, and whether a diff may be built.
1724
1978
  */
1725
- async function measureUntracked(cwd: string, path: string): Promise<UntrackedMeasure> {
1979
+ async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
1726
1980
  let bytes: Buffer
1727
1981
  try {
1728
- bytes = await readFile(join(cwd, path))
1982
+ bytes = await readFile(resolveInside(root, path))
1729
1983
  } catch {
1730
1984
  return { lineCount: 0, binary: false, diffable: false }
1731
1985
  }
@@ -1742,16 +1996,16 @@ async function measureUntracked(cwd: string, path: string): Promise<UntrackedMea
1742
1996
  *
1743
1997
  * `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
1744
1998
  * `/dev/null` as a repo-relative path. Never throws.
1745
- * @param cwd - worktree the path is relative to.
1999
+ * @param root - repository root the path is relative to.
1746
2000
  * @param path - repository-relative file path.
1747
2001
  * @param byteCap - refuse files larger than this; defaults to the stats
1748
2002
  * payload's budget, which `fileSides` raises to its own.
1749
2003
  * @returns the segment, or null when the file is missing, binary, or oversized.
1750
2004
  */
1751
- async function untrackedSegment(cwd: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
2005
+ async function untrackedSegment(root: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
1752
2006
  let bytes: Buffer
1753
2007
  try {
1754
- bytes = await readFile(join(cwd, path))
2008
+ bytes = await readFile(resolveInside(root, path))
1755
2009
  } catch {
1756
2010
  return null
1757
2011
  }