@young1lin/dsh-ui-gitworkbench 0.1.15 → 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 (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CHANGELOG_EN.md +16 -0
  3. package/README.md +4 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1519 -477
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +192 -41
  9. package/lib/path-lock.js +54 -0
  10. package/lib/worktree.js +83 -0
  11. package/lib/write-checked.js +1 -1
  12. package/package.json +1 -1
  13. package/src/client/ChromeGlyph.tsx +5 -0
  14. package/src/client/CodeEditor.tsx +19 -1
  15. package/src/client/DiffViews.tsx +116 -121
  16. package/src/client/FileBrowser.tsx +196 -23
  17. package/src/client/GitWorkbenchPanel.module.css +1 -0
  18. package/src/client/GitWorkbenchPanel.tsx +100 -12
  19. package/src/client/SideRails.tsx +106 -0
  20. package/src/client/diff-cells.tsx +147 -0
  21. package/src/client/diff-nav.ts +4 -1
  22. package/src/client/dir-tree.ts +31 -1
  23. package/src/client/file-rows.ts +40 -0
  24. package/src/client/h-rail.ts +70 -0
  25. package/src/client/ignored-cache.ts +193 -0
  26. package/src/client/index.ts +22 -3
  27. package/src/client/locales.ts +12 -2
  28. package/src/client/row-heights.ts +225 -0
  29. package/src/client/styles/changes.css +33 -2
  30. package/src/client/styles/controls.css +5 -0
  31. package/src/client/styles/files.css +5 -0
  32. package/src/client/styles/rails.css +72 -0
  33. package/src/client/use-row-window.ts +7 -3
  34. package/src/client/use-variable-row-window.ts +210 -0
  35. package/src/dir-listing.ts +47 -0
  36. package/src/fs-remove.ts +5 -36
  37. package/src/index.ts +217 -43
  38. package/src/path-lock.ts +56 -0
  39. package/src/types/dsh-shim.d.ts +12 -2
  40. package/src/worktree.ts +97 -0
  41. package/src/write-checked.ts +1 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * One lazily listed directory, shaped for the wire.
3
+ *
4
+ * The Files tab browses ignored directories by reading them from the
5
+ * filesystem ONE level at a time — git cannot do this scoped: a pathspec
6
+ * under `--directory` still collapses the whole ignored directory to a
7
+ * single line, and dropping `--directory` would enumerate every file inside
8
+ * `node_modules` at once. A `readdir` is one level by construction, costs
9
+ * milliseconds, and says what a browser wants to know: what is HERE.
10
+ *
11
+ * Only the shaping is pure (ordering, capping); the read itself stays in
12
+ * `index.ts`, which vitest cannot load. Same split as `fs-remove.ts`.
13
+ *
14
+ * @module @young1lin/dsh-ui-gitworkbench/dir-listing
15
+ */
16
+
17
+ /** One entry of a listed directory: a name plus whether expanding it again
18
+ * makes sense. `dir` decides the row's glyph and whether it is clickable
19
+ * as a folder. */
20
+ export interface DirChild {
21
+ readonly name: string
22
+ readonly dir: boolean
23
+ }
24
+
25
+ /** The most entries one expansion returns. A real directory level is a few
26
+ * hundred at most (`node_modules`'s own top level); a cap beyond that is a
27
+ * reported fuse against a pathological directory, not a working number. */
28
+ export const DIR_CHILD_CAP = 5_000
29
+
30
+ /**
31
+ * Order and cap raw readdir results: directories before files (the shape
32
+ * every file tree has, matching `treeRows`), each run by name, and a cut
33
+ * REPORTED rather than silent.
34
+ *
35
+ * @param raw - the directory's entries with their best-known dir-ness
36
+ * (symlinks already resolved by the caller).
37
+ * @param cap - most entries to return.
38
+ */
39
+ export function shapeDirChildren(
40
+ raw: readonly DirChild[],
41
+ cap: number = DIR_CHILD_CAP,
42
+ ): { entries: DirChild[]; truncated: boolean } {
43
+ const byName = (a: DirChild, b: DirChild): number => a.name.localeCompare(b.name)
44
+ const ordered = [...raw.filter(entry => entry.dir).sort(byName), ...raw.filter(entry => !entry.dir).sort(byName)]
45
+ const truncated = ordered.length > cap
46
+ return { entries: truncated ? ordered.slice(0, cap) : ordered, truncated }
47
+ }
package/src/fs-remove.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * `git clean` refuses paths it cannot index, which on Windows includes every
7
7
  * reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
8
8
  * any extension). So the removal goes through the filesystem, where git's own
9
- * refusal to leave the repository does not apply — hence the checks here
10
- * rather than a bare `rm`.
9
+ * refusal to leave the repository does not apply — hence the path lock in
10
+ * `path-lock.ts` rather than a bare `rm`.
11
11
  *
12
12
  * Lives outside `index.ts` so vitest can load it: the class there needs the
13
13
  * dsh runtime, and the property worth testing is "what does this delete, and
@@ -17,37 +17,8 @@
17
17
  */
18
18
 
19
19
  import { rm } from 'node:fs/promises'
20
- import { resolve, sep } from 'node:path'
21
20
 
22
- import { isSafeRelativePath } from './discard-ops.js'
23
-
24
- /**
25
- * Resolve a repo-relative path against the worktree root, refusing to leave it.
26
- *
27
- * The second lock rather than the only one: {@link isSafeRelativePath} already
28
- * rejected traversal spellings when the plan was made. This re-checks the
29
- * RESOLVED path, which is the form the filesystem acts on, so a path that
30
- * survives the first check by being spelled unusually still has to land inside
31
- * the root to be acted on.
32
- *
33
- * @param root - the worktree directory, absolute.
34
- * @param relative - repo-relative path from a plan step.
35
- * @returns the absolute path to act on.
36
- * @throws if the path is not a safe relative path, resolves outside the root,
37
- * or IS the root.
38
- */
39
- export function resolveInside(root: string, relative: string): string {
40
- if (!isSafeRelativePath(relative)) {
41
- throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`)
42
- }
43
- const base = resolve(root)
44
- const target = resolve(base, relative)
45
- if (target === base) throw new Error('refusing to delete the worktree root')
46
- if (!target.startsWith(base + sep)) {
47
- throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
48
- }
49
- return target
50
- }
21
+ import { resolveInside } from './path-lock.js'
51
22
 
52
23
  /**
53
24
  * Remove one entry from the worktree, having proven it is inside it.
@@ -63,10 +34,8 @@ export function resolveInside(root: string, relative: string): string {
63
34
  * `force` makes an absent entry a success: the reader asked for it to be gone,
64
35
  * and it is.
65
36
  *
66
- * A symlinked directory inside the worktree could still point outward; that is
67
- * a repository someone already has write access to, and resolving link targets
68
- * per segment on every delete would cost a stat per segment for a case git
69
- * itself does not defend against.
37
+ * A symlinked directory inside the worktree could still point outward the
38
+ * limit of a lexical resolve, stated where the lock is.
70
39
  *
71
40
  * @param root - the worktree directory, absolute.
72
41
  * @param relative - repo-relative path from a plan step.
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,13 @@ 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'
70
72
  import { resolveRepoRoot, rootedDir } from './repo-root.js'
71
73
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js'
72
74
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js'
@@ -78,7 +80,7 @@ import {
78
80
  type StyleEntry, type StyleFile,
79
81
  } from './style-store.js'
80
82
  import {
81
- bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
83
+ bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir,
82
84
  type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
83
85
  } from './worktree.js'
84
86
 
@@ -113,6 +115,11 @@ const SHORTLOG_CAP = 500
113
115
  /** Path list cap for the picker: a monorepo can outrun any popup; past this
114
116
  * the tree is cut and the truncation reported, never silent. */
115
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
116
123
  /**
117
124
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
118
125
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -128,6 +135,21 @@ const COMMIT_HASH = /^[0-9a-fA-F]{4,40}$/
128
135
 
129
136
  export type { GitCommit } from './git-log.js'
130
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
+
131
153
  export interface WorkbenchStats {
132
154
  readonly worktreePath: string
133
155
  readonly branch: string
@@ -270,10 +292,32 @@ export class GitWorkbenchService extends TypertRemoteService {
270
292
  */
271
293
  private readonly bindingMirror = new Map<string, WorktreeBinding>()
272
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
+
273
305
  constructor(ctx: Context) {
274
306
  super(ctx, 'gitWorkbench')
275
307
  this.registerWorktreeTools(ctx)
276
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
+ })
277
321
  // Hydrate the mirror through the same queue as the mutations, so a binding
278
322
  // written before hydration finishes is not overwritten by the stale read.
279
323
  // A failed read leaves the mirror empty: sessions then get no standing
@@ -305,16 +349,18 @@ export class GitWorkbenchService extends TypertRemoteService {
305
349
  name: 'worktree:binding',
306
350
  order: 115,
307
351
  text: (context) => {
308
- const sessionId = context.agent?.session.id
309
- const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId)
310
- if (binding === undefined) return ''
311
- const rel = `.agents/worktrees/${binding.name}`
312
- const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`
313
- return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
314
- + 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
315
- + `- shell commands: pass workdir "${rel}"\n`
316
- + `- file tools: prefix every path with ${rel}/\n`
317
- + '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)
318
364
  },
319
365
  })
320
366
  })
@@ -352,6 +398,7 @@ export class GitWorkbenchService extends TypertRemoteService {
352
398
  ok: { type: 'boolean' },
353
399
  error: { type: 'string' },
354
400
  binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
401
+ bindingInherited: { type: 'boolean' },
355
402
  worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
356
403
  branches: { type: 'array', items: { type: 'string' } },
357
404
  branchesTruncated: { type: 'boolean' },
@@ -396,7 +443,8 @@ export class GitWorkbenchService extends TypertRemoteService {
396
443
 
397
444
  ctx.tools.register(defineTool({
398
445
  name: 'worktree_status',
399
- 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.',
400
448
  parameters: {},
401
449
  output: output(STATUS_SCHEMA),
402
450
  execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
@@ -582,32 +630,36 @@ export class GitWorkbenchService extends TypertRemoteService {
582
630
  if (layer !== 'unstaged' && layer !== 'staged') {
583
631
  throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`)
584
632
  }
585
- if (typeof path !== 'string' || !isSafePathArg(path)) {
633
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
586
634
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
587
635
  }
588
636
  // Repository root, not the session's directory: every path below is
589
637
  // repository-relative (pathspecs resolve against the cwd, and so does
590
638
  // the file read for the editor's target). See repo-root.ts.
591
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.
592
643
  return layer === 'unstaged'
593
- ? await this.unstagedSides(root, path, signal)
644
+ ? await this.unstagedSides(root, path, resolveInside(root, path), signal)
594
645
  : await this.stagedSides(root, path, signal)
595
646
  }
596
647
 
597
- /** The unstaged layer: diff index→worktree, target = the working-tree file. */
598
- private async unstagedSides(root: 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> {
599
651
  // Size guard first, off the stat rather than a read: declining a file past
600
652
  // the cap must not mean loading a pathological one whole first. Bytes are
601
653
  // all a stat knows; the line half of the guard needs the read below.
602
654
  try {
603
- const info = await stat(join(root, path))
655
+ const info = await stat(full)
604
656
  if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
605
657
  } catch {
606
658
  // Missing file: deleted in the working tree, which the diff below states.
607
659
  }
608
660
  let bytes: Buffer | null = null
609
661
  try {
610
- bytes = await readFile(join(root, path))
662
+ bytes = await readFile(full)
611
663
  } catch {
612
664
  bytes = null
613
665
  }
@@ -860,12 +912,14 @@ export class GitWorkbenchService extends TypertRemoteService {
860
912
  */
861
913
  @Remote('fileImage')
862
914
  async fileImage(worktreePath: string, path: string, signal: AbortSignal): Promise<FileImage> {
863
- if (typeof path !== 'string' || !isSafePathArg(path)) {
915
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
864
916
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
865
917
  }
866
918
  // The repository root — the image is read from disk at the same base
867
- // every other path in this plugin is relative to (repo-root.ts).
868
- const full = join(await this.rootedDirOf(worktreePath, signal), path)
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)
869
923
  let size = 0
870
924
  try {
871
925
  const info = await stat(full)
@@ -1072,25 +1126,124 @@ export class GitWorkbenchService extends TypertRemoteService {
1072
1126
 
1073
1127
  /**
1074
1128
  * Every file path on HEAD — the filter popup's path picker, aggregated into
1075
- * a directory tree client-side.
1129
+ * a directory tree client-side — plus the ignored-but-present entries the
1130
+ * Files tab browses.
1076
1131
  *
1077
- * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
1078
- * would render non-ASCII names as quoted octal escapes under
1079
- * `core.quotepath` and hand the picker garbage.
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.
1140
+ *
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.
1080
1144
  * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
1081
1145
  * @param signal - abort signal.
1082
1146
  */
1083
1147
  @Remote('repoTree')
1084
- async repoTree(worktreePath: string, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean }> {
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
+ }> {
1085
1160
  // The repository root: unlike status and numstat, `ls-tree` prints
1086
1161
  // cwd-RELATIVE paths — from a subdirectory every entry would lose the
1087
1162
  // `server/` prefix and the picker would feed the log filter pathspecs
1088
1163
  // that match nothing (repo-root.ts).
1089
1164
  const root = await this.rootedDirOf(worktreePath, signal)
1090
- const res = await this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], 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
+ ])
1091
1169
  const all = res.stdout.split('\0').filter(path => path.length > 0)
1092
1170
  const truncated = all.length > TREE_PATH_CAP
1093
- 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])
1094
1247
  }
1095
1248
 
1096
1249
  /**
@@ -1164,13 +1317,20 @@ export class GitWorkbenchService extends TypertRemoteService {
1164
1317
  }
1165
1318
  }
1166
1319
 
1167
- /** 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
+ */
1168
1326
  @Remote('sessionWorktree')
1169
- async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null }> {
1170
- 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 }
1171
1329
  const file = await this.bindingsIo().load()
1172
- const binding = file.bindings[sessionId]
1173
- 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 }
1174
1334
  }
1175
1335
 
1176
1336
  /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
@@ -1265,7 +1425,15 @@ export class GitWorkbenchService extends TypertRemoteService {
1265
1425
  return this.withBindings(async io => {
1266
1426
  const file = await io.load()
1267
1427
  const binding = file.bindings[sessionId]
1268
- 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
+ }
1269
1437
  if (remove === true) {
1270
1438
  const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal)
1271
1439
  if (status.exitCode !== 0) {
@@ -1297,21 +1465,26 @@ export class GitWorkbenchService extends TypertRemoteService {
1297
1465
  * Branches come back most-recently-committed first. With hundreds of them the
1298
1466
  * order is what makes the list usable — the handful anyone is working on sit
1299
1467
  * at the top, so the picker is useful before a single character is typed.
1300
- * @param sessionId - session whose binding is looked up.
1468
+ * @param sessionId - session whose effective binding is looked up.
1301
1469
  * @param repoPath - caller's directory, used when the session is unbound.
1302
1470
  * @param signal - abort signal.
1303
- * @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.
1304
1473
  */
1305
1474
  @Remote('worktreeStatus')
1306
- 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 }> {
1307
1476
  const file = await this.bindingsIo().load()
1308
- 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
1309
1482
  // Unbound: list the CALLER's repo. Falling back to the host's launch directory
1310
1483
  // would answer about whatever directory dsh was started in, not this session's.
1311
1484
  const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
1312
1485
  const cwd = binding?.repoRoot ?? caller
1313
1486
  const root = await this.repoRootOf(cwd, signal)
1314
- if (root === null) return { binding, worktrees: [], branches: [], branchesTruncated: false }
1487
+ if (root === null) return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false }
1315
1488
  const [listed, named] = await Promise.all([
1316
1489
  this.git(root, ['worktree', 'list', '--porcelain'], signal),
1317
1490
  this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
@@ -1320,6 +1493,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1320
1493
  const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP)
1321
1494
  return {
1322
1495
  binding,
1496
+ bindingInherited,
1323
1497
  worktrees: parseWorktreeList(listed.stdout),
1324
1498
  branches,
1325
1499
  branchesTruncated,
@@ -1805,7 +1979,7 @@ interface UntrackedMeasure {
1805
1979
  async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
1806
1980
  let bytes: Buffer
1807
1981
  try {
1808
- bytes = await readFile(join(root, path))
1982
+ bytes = await readFile(resolveInside(root, path))
1809
1983
  } catch {
1810
1984
  return { lineCount: 0, binary: false, diffable: false }
1811
1985
  }
@@ -1831,7 +2005,7 @@ async function measureUntracked(root: string, path: string): Promise<UntrackedMe
1831
2005
  async function untrackedSegment(root: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
1832
2006
  let bytes: Buffer
1833
2007
  try {
1834
- bytes = await readFile(join(root, path))
2008
+ bytes = await readFile(resolveInside(root, path))
1835
2009
  } catch {
1836
2010
  return null
1837
2011
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The one place a client-supplied path becomes an absolute path on disk.
3
+ *
4
+ * git needs no such lock: whatever pathspec it is handed, it will not read or
5
+ * write outside the repository, which is why `isSafePathArg` only has to keep
6
+ * a path from being mistaken for an option. The moment a path leaves git and
7
+ * reaches `readFile`, `stat` or `readdir`, that backstop is gone — `join(root,
8
+ * '../../../etc/passwd')` is just a path, and the browser is the least trusted
9
+ * source of paths this plugin has.
10
+ *
11
+ * So every filesystem call in the host resolves through here, and the RPCs
12
+ * that do it are pinned by `host-rooted-paths.test.ts` so a new one cannot
13
+ * quietly join a client string onto the root instead.
14
+ *
15
+ * What this does NOT defend against, deliberately and for the same reason
16
+ * `fs-remove.ts` says so: `resolve` is lexical, so a SYMLINK inside the
17
+ * worktree that points outward still resolves inside and is followed. Closing
18
+ * that means a `realpath` per segment on every read of every file, for a case
19
+ * git itself does not defend against and that presupposes write access to the
20
+ * repository the reader already opened.
21
+ *
22
+ * @module @young1lin/dsh-ui-gitworkbench/path-lock
23
+ */
24
+
25
+ import { resolve, sep } from 'node:path'
26
+
27
+ import { isSafeRelativePath } from './discard-ops.js'
28
+
29
+ /**
30
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
31
+ *
32
+ * Two locks, not one. {@link isSafeRelativePath} rejects the traversal
33
+ * SPELLINGS — absolute paths, drive letters, UNC prefixes, NUL bytes, any `..`
34
+ * segment including one buried mid-path. The `startsWith` below re-checks the
35
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
36
+ * survives the first check by being spelled unusually still has to land inside
37
+ * the root to be acted on.
38
+ *
39
+ * @param root - the worktree directory, absolute.
40
+ * @param relative - repo-relative path from the client or from git's output.
41
+ * @returns the absolute path to act on.
42
+ * @throws if the path is not a safe relative path, resolves outside the root,
43
+ * or IS the root.
44
+ */
45
+ export function resolveInside(root: string, relative: string): string {
46
+ if (!isSafeRelativePath(relative)) {
47
+ throw new Error(`unsafe path argument: ${JSON.stringify(relative)}`)
48
+ }
49
+ const base = resolve(root)
50
+ const target = resolve(base, relative)
51
+ if (target === base) throw new Error('refusing to act on the worktree root itself')
52
+ if (!target.startsWith(base + sep)) {
53
+ throw new Error(`path escapes the worktree: ${JSON.stringify(relative)}`)
54
+ }
55
+ return target
56
+ }
@@ -12,17 +12,27 @@ declare module '@deepseek-ai/cordis' {
12
12
  /** ToolRuntime.register — returns the dispose callback; loose here, see file header. */
13
13
  register(definition: unknown): () => void
14
14
  }
15
+ /**
16
+ * Event bus, mixed onto ctx in real cordis. Only `on` is mirrored, loose:
17
+ * the typed event table lives in the @deepseek-ai/dsh-* packages this shim
18
+ * stands in for. Listeners are fiber-owned and removed on unload.
19
+ */
20
+ events: {
21
+ on(name: string, listener: (...args: any[]) => void): void
22
+ }
15
23
  /**
16
24
  * SystemPrompt registry. Only `context()` is mirrored — the dynamic
17
25
  * per-assembly contribution, whose `text` provider is SYNCHRONOUS (see the
18
26
  * real `PromptContext` in packages/core/system-prompt/src/index.ts). The
19
- * `agent` field on the assemble context is merged in by `dsh-agent`.
27
+ * `agent` field on the assemble context is merged in by `dsh-agent`; its
28
+ * session header carries the parent edge a subagent is born with
29
+ * (`SessionHeader.parentSession` in packages/core/session).
20
30
  */
21
31
  systemPrompt: {
22
32
  context(input: {
23
33
  name: string
24
34
  order: number
25
- text: (context: { agent?: { session: { id: string } } }) => string
35
+ text: (context: { agent?: { session: { id: string; header?: { parentSession?: string } } } }) => string
26
36
  }): () => void
27
37
  }
28
38
  /** Mount a child scope once the named services are available; loose here, see file header. */