@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/README.md +30 -5
- package/README_EN.md +1 -1
- package/lib/client.js +1600 -519
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +233 -52
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +133 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +122 -123
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +114 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-model.ts +20 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +18 -4
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +257 -55
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +153 -0
- 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,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, resolveEnterBranch, 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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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' },
|
|
@@ -361,19 +408,21 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
361
408
|
ctx.tools.register(defineTool({
|
|
362
409
|
name: 'worktree_enter',
|
|
363
410
|
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
364
|
-
+ 'always derived from the name (there is no dir parameter), the branch
|
|
411
|
+
+ 'always derived from the name (there is no dir parameter), the branch defaults to the name '
|
|
412
|
+
+ '(or pass branch to choose one — unlike the name it may contain slashes, e.g. feature/foo), '
|
|
365
413
|
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
366
414
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
367
415
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
368
416
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
369
417
|
parameters: {
|
|
370
|
-
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name
|
|
418
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name doubles as the default branch — pass branch to split them (no prefix is added either way). If the target directory already holds a registered worktree (e.g. one made by another tool), it is reused as-is with its own branch. Auto-generated when omitted or illegal.' },
|
|
419
|
+
branch: { type: 'string', description: 'Optional branch for a NEW worktree; defaults to the name. Unlike the name it may contain slashes (feature/foo) — the one spelling a directory name cannot express. Validated and REFUSED when illegal (never auto-generated); set aside — the worktree keeps its own branch, the hint says so — when the target directory already holds a registered worktree.' },
|
|
371
420
|
},
|
|
372
421
|
output: output(OP_SCHEMA),
|
|
373
|
-
execute: async (args: { name?: string }, exec: ToolRunContext) => {
|
|
422
|
+
execute: async (args: { name?: string; branch?: string }, exec: ToolRunContext) => {
|
|
374
423
|
const session = exec.agent?.session
|
|
375
424
|
if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
|
|
376
|
-
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal)
|
|
425
|
+
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, args?.branch, exec.signal)
|
|
377
426
|
},
|
|
378
427
|
presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
|
|
379
428
|
}))
|
|
@@ -396,7 +445,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
396
445
|
|
|
397
446
|
ctx.tools.register(defineTool({
|
|
398
447
|
name: 'worktree_status',
|
|
399
|
-
description: 'Show this session\'s
|
|
448
|
+
description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
|
|
449
|
+
+ 'and the repository\'s existing worktrees with branches.',
|
|
400
450
|
parameters: {},
|
|
401
451
|
output: output(STATUS_SCHEMA),
|
|
402
452
|
execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
|
|
@@ -555,6 +605,12 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
555
605
|
}
|
|
556
606
|
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal)
|
|
557
607
|
if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
|
|
608
|
+
// Empty for a TRACKED file is real, not a missing diff: the line-ending
|
|
609
|
+
// phantom (autocrlf / eol attributes make the stat check and the clean
|
|
610
|
+
// filter disagree) lists such files modified forever while git itself
|
|
611
|
+
// finds no content difference. Synthesizing a new-file segment for one
|
|
612
|
+
// would paint a whole-file addition git does not see.
|
|
613
|
+
if (!await this.isUntracked(root, path, signal)) return { diff: '' }
|
|
558
614
|
return { diff: await untrackedSegment(root, path) ?? '' }
|
|
559
615
|
}
|
|
560
616
|
|
|
@@ -582,32 +638,36 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
582
638
|
if (layer !== 'unstaged' && layer !== 'staged') {
|
|
583
639
|
throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`)
|
|
584
640
|
}
|
|
585
|
-
if (typeof path !== 'string' || !
|
|
641
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
586
642
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
587
643
|
}
|
|
588
644
|
// Repository root, not the session's directory: every path below is
|
|
589
645
|
// repository-relative (pathspecs resolve against the cwd, and so does
|
|
590
646
|
// the file read for the editor's target). See repo-root.ts.
|
|
591
647
|
const root = await this.rootedDirOf(worktreePath, signal)
|
|
648
|
+
// The unstaged layer READS THE FILE, so its absolute path is built by the
|
|
649
|
+
// lock rather than by a join — see path-lock.ts. Done here, once, so the
|
|
650
|
+
// one place that resolves a client path is visible in this method.
|
|
592
651
|
return layer === 'unstaged'
|
|
593
|
-
? await this.unstagedSides(root, path, signal)
|
|
652
|
+
? await this.unstagedSides(root, path, resolveInside(root, path), signal)
|
|
594
653
|
: await this.stagedSides(root, path, signal)
|
|
595
654
|
}
|
|
596
655
|
|
|
597
|
-
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
598
|
-
|
|
656
|
+
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
657
|
+
* `full` is that file's absolute path, already through the lock. */
|
|
658
|
+
private async unstagedSides(root: string, path: string, full: string, signal: AbortSignal): Promise<FileSides> {
|
|
599
659
|
// Size guard first, off the stat rather than a read: declining a file past
|
|
600
660
|
// the cap must not mean loading a pathological one whole first. Bytes are
|
|
601
661
|
// all a stat knows; the line half of the guard needs the read below.
|
|
602
662
|
try {
|
|
603
|
-
const info = await stat(
|
|
663
|
+
const info = await stat(full)
|
|
604
664
|
if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
|
|
605
665
|
} catch {
|
|
606
666
|
// Missing file: deleted in the working tree, which the diff below states.
|
|
607
667
|
}
|
|
608
668
|
let bytes: Buffer | null = null
|
|
609
669
|
try {
|
|
610
|
-
bytes = await readFile(
|
|
670
|
+
bytes = await readFile(full)
|
|
611
671
|
} catch {
|
|
612
672
|
bytes = null
|
|
613
673
|
}
|
|
@@ -860,12 +920,14 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
860
920
|
*/
|
|
861
921
|
@Remote('fileImage')
|
|
862
922
|
async fileImage(worktreePath: string, path: string, signal: AbortSignal): Promise<FileImage> {
|
|
863
|
-
if (typeof path !== 'string' || !
|
|
923
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
864
924
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
865
925
|
}
|
|
866
926
|
// 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
|
-
|
|
927
|
+
// every other path in this plugin is relative to (repo-root.ts) — and
|
|
928
|
+
// through the lock, because this is a raw read with no git in the way
|
|
929
|
+
// to refuse a path that leaves the repository (path-lock.ts).
|
|
930
|
+
const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path)
|
|
869
931
|
let size = 0
|
|
870
932
|
try {
|
|
871
933
|
const info = await stat(full)
|
|
@@ -1072,25 +1134,124 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1072
1134
|
|
|
1073
1135
|
/**
|
|
1074
1136
|
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
1075
|
-
* a directory tree client-side
|
|
1137
|
+
* a directory tree client-side — plus the ignored-but-present entries the
|
|
1138
|
+
* Files tab browses.
|
|
1139
|
+
*
|
|
1140
|
+
* The ignored listing is `ls-files --others --ignored --exclude-standard
|
|
1141
|
+
* --directory`: every ignored FILE is listed verbatim (`application-local.yml`
|
|
1142
|
+
* is exactly the file a browser must find and `ls-tree HEAD` cannot), while
|
|
1143
|
+
* every directory a rule ignores as a whole collapses to ONE line with a
|
|
1144
|
+
* trailing slash — `node_modules/` costs one entry, not the ~40k files
|
|
1145
|
+
* inside it. The list therefore scales with the .gitignore's coverage, not
|
|
1146
|
+
* the repository: measured 54 entries / 48ms on this checkout with
|
|
1147
|
+
* node_modules present.
|
|
1076
1148
|
*
|
|
1077
|
-
* `-z` is load-bearing: NUL-separated output is UNQUOTED,
|
|
1078
|
-
* would render non-ASCII names as quoted octal escapes
|
|
1079
|
-
* `core.quotepath` and hand the picker garbage.
|
|
1149
|
+
* `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
|
|
1150
|
+
* while the default would render non-ASCII names as quoted octal escapes
|
|
1151
|
+
* under `core.quotepath` and hand the picker garbage.
|
|
1080
1152
|
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
1081
1153
|
* @param signal - abort signal.
|
|
1082
1154
|
*/
|
|
1083
1155
|
@Remote('repoTree')
|
|
1084
|
-
async repoTree(worktreePath: string, signal: AbortSignal): Promise<{
|
|
1156
|
+
async repoTree(worktreePath: string, signal: AbortSignal): Promise<{
|
|
1157
|
+
paths: string[]
|
|
1158
|
+
truncated: boolean
|
|
1159
|
+
/** Ignored entries verbatim; directories keep their trailing slash, which
|
|
1160
|
+
* is the only thing that tells them from files. */
|
|
1161
|
+
ignored: string[]
|
|
1162
|
+
ignoredTruncated: boolean
|
|
1163
|
+
/** Present only when the ignored listing FAILED, so the browser can tell
|
|
1164
|
+
* "this repository ignores nothing" from "the question could not be
|
|
1165
|
+
* asked". Omitted on success — never `undefined`, which is not JSON. */
|
|
1166
|
+
ignoredError?: string
|
|
1167
|
+
}> {
|
|
1085
1168
|
// The repository root: unlike status and numstat, `ls-tree` prints
|
|
1086
1169
|
// cwd-RELATIVE paths — from a subdirectory every entry would lose the
|
|
1087
1170
|
// `server/` prefix and the picker would feed the log filter pathspecs
|
|
1088
1171
|
// that match nothing (repo-root.ts).
|
|
1089
1172
|
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1090
|
-
const res = await
|
|
1173
|
+
const [res, ignoredRes] = await Promise.all([
|
|
1174
|
+
this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
|
|
1175
|
+
this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
|
|
1176
|
+
])
|
|
1091
1177
|
const all = res.stdout.split('\0').filter(path => path.length > 0)
|
|
1092
1178
|
const truncated = all.length > TREE_PATH_CAP
|
|
1093
|
-
|
|
1179
|
+
const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0)
|
|
1180
|
+
const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP
|
|
1181
|
+
// `ls-tree`'s exit code is deliberately NOT checked: a repository with no
|
|
1182
|
+
// HEAD yet fails it, and "this repository has no files" is the honest
|
|
1183
|
+
// answer there. The ignored listing has no such legitimate failure, so a
|
|
1184
|
+
// non-zero exit is reported rather than served as an empty list — the one
|
|
1185
|
+
// shape that is indistinguishable from a repository ignoring nothing.
|
|
1186
|
+
const ignoredError = ignoredRes.exitCode === 0
|
|
1187
|
+
? undefined
|
|
1188
|
+
: (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500)
|
|
1189
|
+
return {
|
|
1190
|
+
paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
|
|
1191
|
+
truncated,
|
|
1192
|
+
ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
|
|
1193
|
+
ignoredTruncated,
|
|
1194
|
+
...(ignoredError !== undefined ? { ignoredError } : {}),
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* One level of one ignored directory, read from the filesystem — the step
|
|
1200
|
+
* behind clicking `node_modules/` open in the Files tab.
|
|
1201
|
+
*
|
|
1202
|
+
* Deliberately NOT a git listing: with `--directory`, a pathspec under an
|
|
1203
|
+
* ignored directory still collapses to that one directory line (probed:
|
|
1204
|
+
* `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
|
|
1205
|
+
* answers `node_modules/` and nothing else), and dropping `--directory`
|
|
1206
|
+
* would enumerate every file inside at once — the very flood the collapsed
|
|
1207
|
+
* listing exists to avoid. `readdir` is one level by construction and costs
|
|
1208
|
+
* milliseconds. What the entries ARE is inherited anyway: everything below
|
|
1209
|
+
* an ignored directory is ignored by descent, so the browser owes the
|
|
1210
|
+
* reader the directory's contents, not git's opinion of them.
|
|
1211
|
+
*
|
|
1212
|
+
* A directory that vanished between the listing and the click answers
|
|
1213
|
+
* empty: nothing to browse is the honest answer, and the next refresh
|
|
1214
|
+
* drops the row.
|
|
1215
|
+
*
|
|
1216
|
+
* The name says what it is FOR, not what it permits: nothing here checks
|
|
1217
|
+
* that `dir` is ignored, so this lists any directory inside the worktree.
|
|
1218
|
+
* That is the same reach the reader already has through `fileSides` and
|
|
1219
|
+
* `fileImage`, and adding an ignore check would only make the browser ask
|
|
1220
|
+
* git a second question to learn what it already knows from the listing it
|
|
1221
|
+
* was handed. What it does NOT permit is leaving the worktree, which is
|
|
1222
|
+
* what the path lock below is for.
|
|
1223
|
+
* @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
|
|
1224
|
+
* @param dir - repo-relative directory path, as the collapsed listing named it.
|
|
1225
|
+
* @param signal - abort signal.
|
|
1226
|
+
*/
|
|
1227
|
+
@Remote('ignoredDir')
|
|
1228
|
+
async ignoredDir(worktreePath: string, dir: string, signal: AbortSignal): Promise<{ entries: DirChild[]; truncated: boolean }> {
|
|
1229
|
+
// The same lock every filesystem read in this plugin passes: the browser
|
|
1230
|
+
// is a less trusted source of paths than git's own output, and `readdir`
|
|
1231
|
+
// obeys no repository boundary (path-lock.ts).
|
|
1232
|
+
const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir)
|
|
1233
|
+
let dirents
|
|
1234
|
+
try {
|
|
1235
|
+
dirents = await readdir(target, { withFileTypes: true })
|
|
1236
|
+
} catch {
|
|
1237
|
+
return { entries: [], truncated: false }
|
|
1238
|
+
}
|
|
1239
|
+
const plain = dirents
|
|
1240
|
+
.filter(entry => !entry.isSymbolicLink())
|
|
1241
|
+
.map(entry => ({ name: entry.name, dir: entry.isDirectory() }))
|
|
1242
|
+
// A pnpm-style layout makes every package row a symlink into the store;
|
|
1243
|
+
// `withFileTypes` reports the LINK, so a follow-up stat decides whether
|
|
1244
|
+
// the row expands. A broken link reads as a file and simply fails to
|
|
1245
|
+
// open, like any other dangling name.
|
|
1246
|
+
const links = dirents.filter(entry => entry.isSymbolicLink())
|
|
1247
|
+
const resolved = await Promise.all(links.map(async entry => {
|
|
1248
|
+
try {
|
|
1249
|
+
return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() }
|
|
1250
|
+
} catch {
|
|
1251
|
+
return { name: entry.name, dir: false }
|
|
1252
|
+
}
|
|
1253
|
+
}))
|
|
1254
|
+
return shapeDirChildren([...plain, ...resolved])
|
|
1094
1255
|
}
|
|
1095
1256
|
|
|
1096
1257
|
/**
|
|
@@ -1164,18 +1325,42 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1164
1325
|
}
|
|
1165
1326
|
}
|
|
1166
1327
|
|
|
1167
|
-
/**
|
|
1328
|
+
/**
|
|
1329
|
+
* The session's EFFECTIVE worktree binding — its own, else the nearest bound
|
|
1330
|
+
* ancestor's — or nulls when neither exists. This is what the chip follows,
|
|
1331
|
+
* so a subagent session shows the worktree its conversation works in without
|
|
1332
|
+
* ever holding a binding of its own (plain-identifier params; signal last).
|
|
1333
|
+
*/
|
|
1168
1334
|
@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 }
|
|
1335
|
+
async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null; inherited: boolean }> {
|
|
1336
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null, inherited: false }
|
|
1171
1337
|
const file = await this.bindingsIo().load()
|
|
1172
|
-
const
|
|
1173
|
-
return
|
|
1338
|
+
const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1339
|
+
return effective === undefined
|
|
1340
|
+
? { worktreePath: null, name: null, inherited: false }
|
|
1341
|
+
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited }
|
|
1174
1342
|
}
|
|
1175
1343
|
|
|
1176
|
-
/**
|
|
1344
|
+
/**
|
|
1345
|
+
* Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/`
|
|
1346
|
+
* and bind the session to it.
|
|
1347
|
+
*
|
|
1348
|
+
* The name is the identity — the directory — and the default branch.
|
|
1349
|
+
* branchName (optional) splits the two for the one spelling the name can
|
|
1350
|
+
* never express: a slash branch (`feature/foo` is a legal ref and an
|
|
1351
|
+
* impossible Windows directory). It applies to a FRESH create only — a
|
|
1352
|
+
* reused worktree keeps its own branch, and the hint says so when an
|
|
1353
|
+
* explicit request was set aside. An illegal branchName is refused, never
|
|
1354
|
+
* substituted: a branch is semantic in a way a directory label is not.
|
|
1355
|
+
* @param sessionId - session to bind to the worktree.
|
|
1356
|
+
* @param repoPath - caller's directory, used to locate the repository.
|
|
1357
|
+
* @param name - worktree name (directory + default branch), sanitized;
|
|
1358
|
+
* auto-generated when omitted or illegal.
|
|
1359
|
+
* @param branchName - branch for a fresh create; defaults to the name.
|
|
1360
|
+
* @param signal - abort signal.
|
|
1361
|
+
*/
|
|
1177
1362
|
@Remote('worktreeEnter')
|
|
1178
|
-
async worktreeEnter(sessionId: string, repoPath: string, name: string | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
|
|
1363
|
+
async worktreeEnter(sessionId: string, repoPath: string, name: string | undefined, branchName: string | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
|
|
1179
1364
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return { ok: false, error: 'sessionId is required' }
|
|
1180
1365
|
const cwd = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
|
|
1181
1366
|
const repoRoot = await this.repoRootOf(cwd, signal)
|
|
@@ -1205,13 +1390,16 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1205
1390
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
1206
1391
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim()
|
|
1207
1392
|
// The branch the session actually lands on: the reused worktree's own branch,
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1393
|
+
// branchName when the caller asked for one (slashes allowed — a branch can
|
|
1394
|
+
// spell what a directory cannot), else the name VERBATIM — no forced prefix.
|
|
1395
|
+
const choice = resolveEnterBranch(wtName, branchName, existing?.branch)
|
|
1396
|
+
if (!choice.ok) return { ok: false, error: choice.error }
|
|
1397
|
+
const branch = choice.branch
|
|
1210
1398
|
let reusedWorktree = false
|
|
1211
1399
|
let reusedBranch = false
|
|
1212
1400
|
let baseCommit: string | undefined
|
|
1213
1401
|
if (existing === undefined) {
|
|
1214
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
1402
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', branch, dir], signal)
|
|
1215
1403
|
if (add.exitCode === 0) {
|
|
1216
1404
|
baseCommit = headBefore.length > 0 ? headBefore : undefined
|
|
1217
1405
|
} else {
|
|
@@ -1219,11 +1407,11 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1219
1407
|
// commits), so a re-enter after remove finds the name present as a
|
|
1220
1408
|
// branch: verify the ref and check the existing branch out instead
|
|
1221
1409
|
// of failing on `-b`.
|
|
1222
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
1410
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal)
|
|
1223
1411
|
if (verified.exitCode !== 0) {
|
|
1224
1412
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` }
|
|
1225
1413
|
}
|
|
1226
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
1414
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, branch], signal)
|
|
1227
1415
|
if (retry.exitCode !== 0) {
|
|
1228
1416
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` }
|
|
1229
1417
|
}
|
|
@@ -1252,7 +1440,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1252
1440
|
const rel = `.agents/worktrees/${wtName}`
|
|
1253
1441
|
return {
|
|
1254
1442
|
ok: true, worktreePath: dir, branch,
|
|
1255
|
-
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1443
|
+
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${choice.branchOverridden ? ` Note: requested branch ${branchName} was not used; the reused worktree keeps its branch ${branch}.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1256
1444
|
}
|
|
1257
1445
|
}
|
|
1258
1446
|
|
|
@@ -1265,7 +1453,15 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1265
1453
|
return this.withBindings(async io => {
|
|
1266
1454
|
const file = await io.load()
|
|
1267
1455
|
const binding = file.bindings[sessionId]
|
|
1268
|
-
if (binding === undefined)
|
|
1456
|
+
if (binding === undefined) {
|
|
1457
|
+
// A subagent CAN see a worktree in its status while holding no binding
|
|
1458
|
+
// of its own (it works under its parent's). Naming that here keeps the
|
|
1459
|
+
// model from retrying an exit that cannot succeed.
|
|
1460
|
+
const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1461
|
+
return inherited === undefined
|
|
1462
|
+
? { ok: false, error: 'no worktree binding for this session' }
|
|
1463
|
+
: { 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' }
|
|
1464
|
+
}
|
|
1269
1465
|
if (remove === true) {
|
|
1270
1466
|
const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal)
|
|
1271
1467
|
if (status.exitCode !== 0) {
|
|
@@ -1297,21 +1493,26 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1297
1493
|
* Branches come back most-recently-committed first. With hundreds of them the
|
|
1298
1494
|
* order is what makes the list usable — the handful anyone is working on sit
|
|
1299
1495
|
* at the top, so the picker is useful before a single character is typed.
|
|
1300
|
-
* @param sessionId - session whose binding is looked up.
|
|
1496
|
+
* @param sessionId - session whose effective binding is looked up.
|
|
1301
1497
|
* @param repoPath - caller's directory, used when the session is unbound.
|
|
1302
1498
|
* @param signal - abort signal.
|
|
1303
|
-
* @returns the binding
|
|
1499
|
+
* @returns the effective binding (with whether an ancestor lent it), the
|
|
1500
|
+
* repository's worktrees, and its local branches.
|
|
1304
1501
|
*/
|
|
1305
1502
|
@Remote('worktreeStatus')
|
|
1306
|
-
async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
|
|
1503
|
+
async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; bindingInherited: boolean; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
|
|
1307
1504
|
const file = await this.bindingsIo().load()
|
|
1308
|
-
const
|
|
1505
|
+
const effective = typeof sessionId === 'string' && sessionId.length > 0
|
|
1506
|
+
? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1507
|
+
: undefined
|
|
1508
|
+
const binding = effective?.binding ?? null
|
|
1509
|
+
const bindingInherited = effective?.inherited ?? false
|
|
1309
1510
|
// Unbound: list the CALLER's repo. Falling back to the host's launch directory
|
|
1310
1511
|
// would answer about whatever directory dsh was started in, not this session's.
|
|
1311
1512
|
const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
|
|
1312
1513
|
const cwd = binding?.repoRoot ?? caller
|
|
1313
1514
|
const root = await this.repoRootOf(cwd, signal)
|
|
1314
|
-
if (root === null) return { binding, worktrees: [], branches: [], branchesTruncated: false }
|
|
1515
|
+
if (root === null) return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false }
|
|
1315
1516
|
const [listed, named] = await Promise.all([
|
|
1316
1517
|
this.git(root, ['worktree', 'list', '--porcelain'], signal),
|
|
1317
1518
|
this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
|
|
@@ -1320,6 +1521,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1320
1521
|
const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP)
|
|
1321
1522
|
return {
|
|
1322
1523
|
binding,
|
|
1524
|
+
bindingInherited,
|
|
1323
1525
|
worktrees: parseWorktreeList(listed.stdout),
|
|
1324
1526
|
branches,
|
|
1325
1527
|
branchesTruncated,
|
|
@@ -1805,7 +2007,7 @@ interface UntrackedMeasure {
|
|
|
1805
2007
|
async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
|
|
1806
2008
|
let bytes: Buffer
|
|
1807
2009
|
try {
|
|
1808
|
-
bytes = await readFile(
|
|
2010
|
+
bytes = await readFile(resolveInside(root, path))
|
|
1809
2011
|
} catch {
|
|
1810
2012
|
return { lineCount: 0, binary: false, diffable: false }
|
|
1811
2013
|
}
|
|
@@ -1831,7 +2033,7 @@ async function measureUntracked(root: string, path: string): Promise<UntrackedMe
|
|
|
1831
2033
|
async function untrackedSegment(root: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
|
|
1832
2034
|
let bytes: Buffer
|
|
1833
2035
|
try {
|
|
1834
|
-
bytes = await readFile(
|
|
2036
|
+
bytes = await readFile(resolveInside(root, path))
|
|
1835
2037
|
} catch {
|
|
1836
2038
|
return null
|
|
1837
2039
|
}
|
package/src/path-lock.ts
ADDED
|
@@ -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
|
+
}
|
package/src/types/dsh-shim.d.ts
CHANGED
|
@@ -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. */
|