@agou/dsh-worktrees 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,211 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { canonical, commitAt, digest, exists, fail, git, inside, nulRecords, safeChild, samePath, treeEntries } from './git.js';
5
+
6
+ const MAX_FILES = 100_000;
7
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
8
+
9
+ /** Walk without following links, including ignored directories so embedded repositories cannot be destroyed. */
10
+ async function scan(root, signal) {
11
+ const directories = [root];
12
+ let count = 0;
13
+ while (directories.length) {
14
+ signal?.throwIfAborted();
15
+ const directory = directories.pop();
16
+ const entries = await fs.readdir(directory, { withFileTypes: true });
17
+ const names = new Set(entries.map(entry => entry.name.toLowerCase()));
18
+ if (!samePath(root, directory) && (names.has('.git') || (names.has('head') && names.has('objects') && names.has('refs')))) {
19
+ fail('NESTED_REPOSITORY', `An initialized submodule or embedded repository exists at ${directory}.`);
20
+ }
21
+ for (const entry of entries) {
22
+ if (samePath(root, directory) && entry.name === '.git') continue;
23
+ if (++count > MAX_FILES) fail('FILE_LIMIT', 'The checkout contains too many entries to snapshot safely.');
24
+ if (entry.isDirectory()) directories.push(path.join(directory, entry.name));
25
+ else if (!entry.isFile() && !entry.isSymbolicLink()) fail('SPECIAL_FILE', `Cannot snapshot the special filesystem entry ${path.join(directory, entry.name)}.`);
26
+ }
27
+ }
28
+ }
29
+
30
+ async function indexDigest(record) {
31
+ const target = path.join(record.gitDir, 'index');
32
+ const stat = await exists(target);
33
+ if (!stat) return null;
34
+ if (!stat.isFile() || stat.isSymbolicLink()) fail('UNSAFE_INDEX', 'The checkout index is not an ordinary file.');
35
+ return digest(await fs.readFile(target));
36
+ }
37
+
38
+ async function fileBytes(target, stat) {
39
+ if (stat.isSymbolicLink()) return fs.readlink(target, { encoding: 'buffer' });
40
+ if (!stat.isFile()) fail('SPECIAL_FILE', `Cannot snapshot the special filesystem entry ${target}.`);
41
+ if (stat.size > MAX_FILE_BYTES) fail('FILE_LIMIT', `The file exceeds the ${MAX_FILE_BYTES} byte snapshot limit: ${target}.`);
42
+ const handle = await fs.open(target, 'r');
43
+ try {
44
+ const opened = await handle.stat();
45
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size) fail('CHECKOUT_CHANGED', `The file changed while snapshotting: ${target}.`);
46
+ return await handle.readFile();
47
+ } finally {
48
+ await handle.close();
49
+ }
50
+ }
51
+
52
+ async function assertParents(root, target) {
53
+ let directory = path.dirname(target);
54
+ while (!samePath(directory, root)) {
55
+ if (!inside(root, directory)) fail('UNSAFE_TREE', 'A snapshot path escapes its checkout.');
56
+ const stat = await fs.lstat(directory);
57
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('UNSAFE_TREE', `A snapshot path traverses a link: ${directory}.`);
58
+ directory = path.dirname(directory);
59
+ }
60
+ }
61
+
62
+ async function currentTree(record, temporaryIndex, signal) {
63
+ await scan(record.path, signal);
64
+ const entries = treeEntries((await git(record.path, ['ls-tree', '-r', '-z', '--full-tree', 'HEAD'], { signal })).bytes);
65
+ const known = new Map(entries.map(entry => [entry.name, entry]));
66
+ const index = await git(record.path, ['ls-files', '--stage', '-z'], { signal });
67
+ for (const line of nulRecords(index.bytes)) {
68
+ const match = /^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(line);
69
+ if (!match) fail('INVALID_INDEX', 'Git returned an invalid index entry.');
70
+ const name = match[4];
71
+ if (match[1] === '160000' && match[3] !== '0') fail('SUBMODULE_CONFLICT', 'A conflicted submodule cannot be snapshotted safely.');
72
+ if (!known.has(name) || match[3] === '0') known.set(name, { name, mode: match[1], oid: match[2] });
73
+ }
74
+ const untracked = await git(record.path, ['ls-files', '--others', '--exclude-standard', '-z'], { signal });
75
+ for (const name of nulRecords(untracked.bytes)) if (!known.has(name)) known.set(name, { name, mode: '100644' });
76
+ if (known.size > MAX_FILES) fail('FILE_LIMIT', 'The checkout contains too many files to snapshot safely.');
77
+ const output = [];
78
+ for (const item of [...known.values()].sort((a, b) => Buffer.compare(Buffer.from(a.name), Buffer.from(b.name)))) {
79
+ signal?.throwIfAborted();
80
+ const target = safeChild(record.path, item.name);
81
+ const stat = await exists(target);
82
+ if (!stat) continue;
83
+ await assertParents(record.path, target);
84
+ if (item.mode === '160000') {
85
+ if (!stat.isDirectory() || (await fs.readdir(target)).length) fail('INITIALIZED_SUBMODULE', `The submodule at ${target} is initialized or contains files.`);
86
+ output.push(`160000 ${item.oid}\t${item.name}\0`);
87
+ continue;
88
+ }
89
+ if (stat.isDirectory()) continue;
90
+ const bytes = await fileBytes(target, stat);
91
+ const after = await fs.lstat(target);
92
+ if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.mode !== stat.mode) {
93
+ fail('CHECKOUT_CHANGED', `The file changed while snapshotting: ${target}.`);
94
+ }
95
+ const mode = stat.isSymbolicLink() ? '120000' : process.platform === 'win32'
96
+ ? (item.mode === '100755' ? '100755' : '100644')
97
+ : (stat.mode & 0o111 ? '100755' : '100644');
98
+ const oid = (await git(record.path, ['hash-object', '-w', '--no-filters', '--stdin'], { signal, input: bytes })).stdout.trim();
99
+ output.push(`${mode} ${oid}\t${item.name}\0`);
100
+ }
101
+ const env = { GIT_INDEX_FILE: temporaryIndex };
102
+ await git(record.path, ['read-tree', '--empty'], { signal, env });
103
+ if (output.length) await git(record.path, ['update-index', '-z', '--index-info'], { signal, env, input: Buffer.from(output.join('')) });
104
+ return (await git(record.path, ['write-tree'], { signal, env })).stdout.trim();
105
+ }
106
+
107
+ async function withTemporaryIndex(callback) {
108
+ const parent = await canonical(tmpdir());
109
+ const directory = await fs.mkdtemp(path.join(parent, 'dsh-worktrees-index-'));
110
+ const index = path.join(directory, 'index');
111
+ try {
112
+ return await callback(index);
113
+ } finally {
114
+ if (!path.isAbsolute(directory) || !samePath(path.dirname(directory), parent) || !path.basename(directory).startsWith('dsh-worktrees-index-')) {
115
+ fail('UNSAFE_TEMP', 'Refusing to clean an unverified temporary index directory.');
116
+ }
117
+ for (const file of [index, `${index}.lock`]) {
118
+ const stat = await exists(file);
119
+ if (stat && stat.isFile() && !stat.isSymbolicLink()) await fs.unlink(file);
120
+ }
121
+ await fs.rmdir(directory);
122
+ }
123
+ }
124
+
125
+ /** Build objects without Git filters and never write to the checkout's index or stash. */
126
+ export async function makeSnapshot(record, signal) {
127
+ const head = await commitAt(record.path, 'HEAD', signal);
128
+ const originalIndex = await indexDigest(record);
129
+ const tree = await withTemporaryIndex(index => currentTree(record, index, signal));
130
+ if (await commitAt(record.path, 'HEAD', signal) !== head || await indexDigest(record) !== originalIndex) {
131
+ fail('CHECKOUT_CHANGED', 'HEAD or the user index changed while creating the snapshot.');
132
+ }
133
+ const headTree = (await git(record.path, ['rev-parse', '--verify', 'HEAD^{tree}'], { signal })).stdout.trim();
134
+ let commit = head;
135
+ if (tree !== headTree) {
136
+ commit = (await git(record.path, ['-c', 'commit.gpgSign=false', 'commit-tree', tree, '-p', head], {
137
+ signal,
138
+ input: Buffer.from(`DSH worktree snapshot ${record.id}\n`),
139
+ env: {
140
+ GIT_AUTHOR_NAME: 'DSH Worktrees', GIT_AUTHOR_EMAIL: 'worktrees@localhost',
141
+ GIT_COMMITTER_NAME: 'DSH Worktrees', GIT_COMMITTER_EMAIL: 'worktrees@localhost',
142
+ },
143
+ })).stdout.trim();
144
+ }
145
+ return { commit, head, tree, index: originalIndex };
146
+ }
147
+
148
+ export async function assertSnapshotUnchanged(record, snapshot, signal) {
149
+ if (await commitAt(record.path, 'HEAD', signal) !== snapshot.head || await indexDigest(record) !== snapshot.index) {
150
+ fail('CHECKOUT_CHANGED', 'HEAD or the user index changed after creating the snapshot.');
151
+ }
152
+ const tree = await withTemporaryIndex(index => currentTree(record, index, signal));
153
+ if (tree !== snapshot.tree || await commitAt(record.path, 'HEAD', signal) !== snapshot.head || await indexDigest(record) !== snapshot.index) {
154
+ fail('CHECKOUT_CHANGED', 'Files, HEAD, or the user index changed after creating the snapshot. The checkout was kept.');
155
+ }
156
+ }
157
+
158
+ /** Materialize raw blobs so .gitattributes cannot rewrite saved bytes during restoration. */
159
+ export async function materialize(record, commit, signal) {
160
+ const entries = treeEntries((await git(record.path, ['ls-tree', '-r', '-z', '--full-tree', commit], { signal })).bytes);
161
+ const allowed = new Set();
162
+ for (const entry of entries) {
163
+ safeChild(record.path, entry.name);
164
+ let name = entry.name;
165
+ while (name.includes('/')) {
166
+ name = name.slice(0, name.lastIndexOf('/'));
167
+ allowed.add(name);
168
+ }
169
+ allowed.add(entry.name);
170
+ }
171
+ const directories = [{ target: record.path, relative: '' }];
172
+ while (directories.length) {
173
+ signal?.throwIfAborted();
174
+ const { target, relative } = directories.pop();
175
+ for (const entry of await fs.readdir(target, { withFileTypes: true })) {
176
+ if (!relative && entry.name === '.git') continue;
177
+ const name = relative ? `${relative}/${entry.name}` : entry.name;
178
+ if (!allowed.has(name)) fail('OCCUPIED_PATH', `The incomplete checkout contains an unexpected entry: ${name}.`);
179
+ if (entry.isDirectory()) directories.push({ target: path.join(target, entry.name), relative: name });
180
+ }
181
+ }
182
+ for (const entry of entries) {
183
+ signal?.throwIfAborted();
184
+ const target = safeChild(record.path, entry.name);
185
+ const parentParts = entry.name.split('/').slice(0, -1);
186
+ let parent = record.path;
187
+ for (const part of parentParts) {
188
+ parent = path.join(parent, part);
189
+ const stat = await exists(parent);
190
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) fail('OCCUPIED_PATH', `A checkout directory is occupied: ${parent}.`);
191
+ if (!stat) await fs.mkdir(parent);
192
+ }
193
+ const stat = await exists(target);
194
+ if (entry.mode === '160000') {
195
+ if (stat && (!stat.isDirectory() || (await fs.readdir(target)).length)) fail('INITIALIZED_SUBMODULE', `The submodule path is occupied: ${target}.`);
196
+ if (!stat) await fs.mkdir(target);
197
+ continue;
198
+ }
199
+ const bytes = (await git(record.path, ['cat-file', 'blob', entry.oid], { signal })).bytes;
200
+ if (stat) {
201
+ if (entry.mode === '120000' ? !stat.isSymbolicLink() : !stat.isFile() || stat.isSymbolicLink()) fail('OCCUPIED_PATH', `The checkout path has changed type: ${target}.`);
202
+ if (!(await fileBytes(target, stat)).equals(bytes)) fail('OCCUPIED_PATH', `The incomplete checkout contains changed bytes: ${target}.`);
203
+ continue;
204
+ }
205
+ if (entry.mode === '120000') {
206
+ const linkTarget = process.platform === 'win32' ? new TextDecoder('utf-8', { fatal: true }).decode(bytes) : bytes;
207
+ await fs.symlink(linkTarget, target);
208
+ } else await fs.writeFile(target, bytes, { flag: 'wx', mode: entry.mode === '100755' ? 0o755 : 0o644 });
209
+ }
210
+ await git(record.path, ['read-tree', commit], { signal });
211
+ }
package/lib/state.js ADDED
@@ -0,0 +1,97 @@
1
+ import path from 'node:path';
2
+ import { samePath } from './git.js';
3
+
4
+ export function createInitialState() {
5
+ return { version: 1, worktrees: {}, bindings: {}, preferences: {}, accepted: {} };
6
+ }
7
+
8
+ function check(condition, message) {
9
+ if (!condition) throw new TypeError(`Invalid worktree state: ${message}`);
10
+ }
11
+
12
+ function object(value) {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
14
+ }
15
+
16
+ function text(value) {
17
+ return typeof value === 'string' && value.length > 0 && value.length <= 8192 && !value.includes('\0');
18
+ }
19
+
20
+ function identifier(value) {
21
+ return text(value) && !['__proto__', 'prototype', 'constructor'].includes(value);
22
+ }
23
+
24
+ function timestamp(value) {
25
+ return Number.isSafeInteger(value) && value >= 0;
26
+ }
27
+
28
+ function absolute(value) {
29
+ return text(value) && path.isAbsolute(value);
30
+ }
31
+
32
+ function choice(value) {
33
+ return object(value) && ['local', 'new'].includes(value.mode) && (value.mode === 'local' ? value.baseRef === undefined || text(value.baseRef) : text(value.baseRef));
34
+ }
35
+
36
+ function nullableText(value) {
37
+ return value === null || text(value);
38
+ }
39
+
40
+ export function freeze(value) {
41
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
42
+ for (const item of Object.values(value)) freeze(item);
43
+ Object.freeze(value);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ /** Validate a complete aggregate before loading or persisting it; callers receive an independent value. */
49
+ export function validateState(value) {
50
+ check(object(value) && value.version === 1, 'unsupported version');
51
+ for (const name of ['worktrees', 'bindings', 'preferences', 'accepted']) check(object(value[name]), `${name} must be a map`);
52
+ const statuses = ['creating', 'ready', 'snapshotting', 'removing', 'removed', 'restoring', 'missing', 'error'];
53
+ for (const [id, record] of Object.entries(value.worktrees)) {
54
+ check(object(record) && /^[a-f0-9-]{36}$/.test(id) && record.id === id, 'invalid worktree id');
55
+ check(identifier(record.workspaceId) && typeof record.workspaceTitle === 'string', `invalid workspace for ${id}`);
56
+ for (const name of ['sourceRoot', 'commonDir', 'managedRoot', 'path', 'originalPath']) check(absolute(record[name]), `invalid ${name} for ${id}`);
57
+ check(samePath(record.path, record.originalPath), `changed original path for ${id}`);
58
+ check(samePath(path.dirname(record.path), record.managedRoot) && path.basename(record.path) === `dsh-${id}`, `unsafe managed path for ${id}`);
59
+ check(record.gitDir === null || (absolute(record.gitDir) && samePath(path.dirname(record.gitDir), path.join(record.commonDir, 'worktrees'))), `invalid Git admin directory for ${id}`);
60
+ check(text(record.baseRef) && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(record.baseCommit), `invalid base for ${id}`);
61
+ check(/^[a-f0-9]{64}$/.test(record.token), `invalid ownership token for ${id}`);
62
+ check(timestamp(record.createdAt) && timestamp(record.lastUsedAt) && (record.ownerlessAt === null || timestamp(record.ownerlessAt)), `invalid timestamp for ${id}`);
63
+ check(statuses.includes(record.status) && nullableText(record.error), `invalid lifecycle for ${id}`);
64
+ check(record.currentBranch === undefined || nullableText(record.currentBranch), `invalid current branch for ${id}`);
65
+ check(record.snapshotRef === `refs/dsh-worktrees/snapshots/${id}`, `invalid snapshot ref for ${id}`);
66
+ for (const name of ['snapshotCommit', 'snapshotPendingCommit']) check(record[name] === null || /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(record[name]), `invalid ${name} for ${id}`);
67
+ check(Array.isArray(record.sessionIds) && record.sessionIds.every(identifier) && new Set(record.sessionIds).size === record.sessionIds.length, `invalid sessions for ${id}`);
68
+ check(typeof record.materialized === 'boolean' && typeof record.archivePending === 'boolean', `invalid operation state for ${id}`);
69
+ check(record.operationId === null || text(record.operationId), `invalid operation id for ${id}`);
70
+ check(identifier(record.ownerSessionId), `invalid owner for ${id}`);
71
+ }
72
+ for (const [sessionId, binding] of Object.entries(value.bindings)) {
73
+ check(identifier(sessionId) && object(binding) && binding.sessionId === sessionId && choice(binding), 'invalid binding');
74
+ check(identifier(binding.workspaceId) && absolute(binding.workspacePath) && absolute(binding.path) && typeof binding.workspaceTitle === 'string', `invalid binding workspace for ${sessionId}`);
75
+ check(timestamp(binding.preparedAt) && (binding.acceptedAt === null || timestamp(binding.acceptedAt)), `invalid binding time for ${sessionId}`);
76
+ check(['pending', 'ready', 'error'].includes(binding.status) && nullableText(binding.error), `invalid binding status for ${sessionId}`);
77
+ if (binding.mode === 'new') {
78
+ const record = value.worktrees[binding.worktreeId];
79
+ check(record && samePath(binding.path, record.path) && record.sessionIds.includes(sessionId), `missing worktree for ${sessionId}`);
80
+ } else {
81
+ check(binding.worktreeId === null && samePath(binding.path, binding.workspacePath), `invalid local binding for ${sessionId}`);
82
+ }
83
+ }
84
+ for (const [workspaceId, preference] of Object.entries(value.preferences)) {
85
+ check(identifier(workspaceId) && choice(preference) && timestamp(preference.acceptedAt) && identifier(preference.sessionId) && text(preference.requestId), 'invalid preference');
86
+ }
87
+ for (const [key, accepted] of Object.entries(value.accepted)) {
88
+ check(object(accepted) && identifier(accepted.sessionId) && text(accepted.requestId) && timestamp(accepted.acceptedAt), 'invalid accepted request');
89
+ check(key === JSON.stringify([accepted.sessionId, accepted.requestId]), 'invalid accepted request key');
90
+ }
91
+ return structuredClone(value);
92
+ }
93
+
94
+ export function requireIdentifier(value, name) {
95
+ if (!identifier(value)) throw new TypeError(`${name} must be a nonempty identifier.`);
96
+ return value;
97
+ }
package/locale/en.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "meta": {
3
+ "title": "Worktrees",
4
+ "description": "Choose a Git branch or a new local worktree for each conversation, with recoverable background cleanup."
5
+ },
6
+ "ui": {
7
+ "nav": "Worktrees",
8
+ "worktree": "Worktree",
9
+ "branch": "Branch",
10
+ "local": "Local",
11
+ "new": "New local worktree",
12
+ "loading": "Loading…",
13
+ "detached": "Detached HEAD",
14
+ "noGit": "This workspace is not a Git repository.",
15
+ "noBranches": "No branches are available.",
16
+ "noCommit": "Create a commit in this repository before creating a worktree.",
17
+ "gitUnavailable": "Git is unavailable. Local sessions can still use this workspace.",
18
+ "locked": "This session will continue in its prepared directory.",
19
+ "snapshotHelp": "Git-ignored files are not included. Restored changes are recorded in a snapshot commit.",
20
+ "localHint": "Use the source directory. Choosing a branch checks it out immediately.",
21
+ "newHint": "Create a local worktree when the first message is sent.",
22
+ "baseHint": "Choose the base branch for the new worktree.",
23
+ "directory": "Worktree directory",
24
+ "directoryHelp": "Applies to newly created worktrees. Existing worktrees keep their paths.",
25
+ "chooseDirectory": "Choose folder",
26
+ "maxRetained": "Maximum retained worktrees",
27
+ "retentionHelp": "Eligible worktrees above this limit are snapshotted before cleanup. Pinned sessions and active work are protected, so the total can exceed the limit.",
28
+ "invalidMaximum": "Enter a whole number of at least 1.",
29
+ "directoryRequired": "Enter a worktree directory.",
30
+ "managed": "Managed worktrees",
31
+ "refresh": "Refresh",
32
+ "empty": "No managed worktrees.",
33
+ "untitled": "Untitled session",
34
+ "openSessions": "Open session",
35
+ "archived": "Archived",
36
+ "basedOn": "Based on {branch}",
37
+ "rowWorktree": "Worktree",
38
+ "hoverPath": "Worktree path: {path}",
39
+ "hoverBranch": "Worktree branch: {branch}",
40
+ "ready": "Ready",
41
+ "active": "Active",
42
+ "pinned": "Pinned",
43
+ "protected": "Protected",
44
+ "creating": "Creating",
45
+ "snapshotting": "Saving snapshot",
46
+ "removing": "Cleaning up",
47
+ "removed": "Cleaned up",
48
+ "missing": "Directory missing",
49
+ "restoring": "Restoring",
50
+ "error": "Error",
51
+ "restore": "Restore",
52
+ "delete": "Delete",
53
+ "cancel": "Cancel",
54
+ "close": "Close",
55
+ "restoreTitle": "Restore worktree?",
56
+ "restoreBody": "This worktree has been cleaned up. Restore its saved snapshot to continue in a detached worktree.",
57
+ "deleteTitle": "Delete worktree?",
58
+ "deleteBody": "This removes the worktree directory and archives its associated chats. A saved snapshot remains available for restoration.",
59
+ "deleteProtected": "Active worktrees and worktrees with pinned sessions cannot be deleted.",
60
+ "actionError": "Worktree operation failed",
61
+ "checkoutError": "Could not switch branch",
62
+ "archivedHelp": "Unarchive this session in the sidebar to open it.",
63
+ "invalidResponse": "The worktree service returned an invalid response.",
64
+ "notReady": "The worktree is not ready. Refresh and try again.",
65
+ "adapterUnavailable": "This DSH version does not expose the supported conversation send method."
66
+ }
67
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "meta": {
3
+ "title": "工作树",
4
+ "description": "为会话选择 Git 分支或新建本地工作树,支持保存快照后自动清理和恢复。"
5
+ },
6
+ "ui": {
7
+ "nav": "工作树",
8
+ "worktree": "工作树",
9
+ "branch": "分支",
10
+ "local": "本地",
11
+ "new": "新建本地工作树",
12
+ "loading": "正在加载…",
13
+ "detached": "分离的 HEAD",
14
+ "noGit": "此工作区不是 Git 仓库。",
15
+ "noBranches": "没有可用的分支。",
16
+ "noCommit": "请先在仓库中创建提交,再新建工作树。",
17
+ "gitUnavailable": "Git 不可用,此工作区仍可用于本地会话。",
18
+ "locked": "此会话将在已经准备好的目录中继续。",
19
+ "snapshotHelp": "快照不包含被 Git 忽略的文件。恢复后的修改会记录为一次快照提交。",
20
+ "localHint": "使用源目录。选择分支会立即切换源目录的分支。",
21
+ "newHint": "发送第一条消息时创建本地工作树。",
22
+ "baseHint": "选择新工作树的基础分支。",
23
+ "directory": "工作树目录",
24
+ "directoryHelp": "仅影响新建的工作树,现有工作树的路径保持不变。",
25
+ "chooseDirectory": "选择文件夹",
26
+ "maxRetained": "最多保留工作树数量",
27
+ "retentionHelp": "超过此数量的可清理工作树会先保存快照,再清理目录。置顶会话和正在运行的工作树受到保护,因此总数可能超过此限制。",
28
+ "invalidMaximum": "请输入不小于 1 的整数。",
29
+ "directoryRequired": "请输入工作树目录。",
30
+ "managed": "管理工作树",
31
+ "refresh": "刷新",
32
+ "empty": "暂无托管工作树。",
33
+ "untitled": "未命名会话",
34
+ "openSessions": "打开会话",
35
+ "archived": "已归档",
36
+ "basedOn": "基于 {branch}",
37
+ "rowWorktree": "工作树",
38
+ "hoverPath": "工作树路径:{path}",
39
+ "hoverBranch": "工作树分支:{branch}",
40
+ "ready": "就绪",
41
+ "active": "运行中",
42
+ "pinned": "已置顶",
43
+ "protected": "受到保护",
44
+ "creating": "正在创建",
45
+ "snapshotting": "正在保存快照",
46
+ "removing": "正在清理",
47
+ "removed": "已清理",
48
+ "missing": "目录缺失",
49
+ "restoring": "正在恢复",
50
+ "error": "错误",
51
+ "restore": "恢复",
52
+ "delete": "删除",
53
+ "cancel": "取消",
54
+ "close": "关闭",
55
+ "restoreTitle": "恢复工作树?",
56
+ "restoreBody": "此工作树已经清理。恢复保存的快照后,将在分离的工作树中继续工作。",
57
+ "deleteTitle": "删除工作树?",
58
+ "deleteBody": "这会删除工作树目录,并归档关联会话。保存的快照仍可用于恢复。",
59
+ "deleteProtected": "正在运行的工作树和包含置顶会话的工作树不能删除。",
60
+ "actionError": "工作树操作失败",
61
+ "checkoutError": "无法切换分支",
62
+ "archivedHelp": "请先在侧边栏取消归档,再打开此会话。",
63
+ "invalidResponse": "工作树服务返回了无效结果。",
64
+ "notReady": "工作树尚未就绪,请刷新后重试。",
65
+ "adapterUnavailable": "此 DSH 版本没有提供受支持的会话发送方法。"
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@agou/dsh-worktrees",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Creates detached worktrees from chat, remembers workspace choices, and manages recoverable background cleanup.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "agoudbg",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/agoudbg/agou-dsh-plugins.git",
11
+ "directory": "packages/dsh-worktrees"
12
+ },
13
+ "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "git", "worktree", "web-ui"],
14
+ "engines": { "node": ">=22", "dsh": "0.2.1-rc.5" },
15
+ "exports": {
16
+ ".": "./index.js",
17
+ "./client": "./client.js",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./icon": "./icon.svg"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test",
23
+ "locales": "node scripts/sync-locales.mjs",
24
+ "locales:check": "node scripts/sync-locales.mjs --check"
25
+ },
26
+ "files": ["index.js", "host.js", "transport.js", "discovery.js", "client.js", "lib/*.js", "cordis.patch.yml", "locale/*.json", "icon.svg", "README.md", "LICENSE"],
27
+ "dependencies": {
28
+ "@deepseek-ai/schemastery": "3.18.5",
29
+ "zod": "^4.6.7"
30
+ },
31
+ "dsh": {
32
+ "bundle": { "patch": "./cordis.patch.yml" },
33
+ "client": {
34
+ "platform": "web",
35
+ "immediately": true,
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-ui-conversation",
38
+ "@deepseek-ai/dsh-client-ui-workspace",
39
+ "@deepseek-ai/dsh-client-ui-settings-general"
40
+ ]
41
+ }
42
+ }
43
+ }
package/transport.js ADDED
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { RPC_ENDPOINTS } from './host.js';
3
+
4
+ /** Register exact authenticated routes on the existing Connection transport. */
5
+ export function registerTransport(ctx, host) {
6
+ for (const endpoint of RPC_ENDPOINTS) {
7
+ const method = `dsh-worktrees/${endpoint}`;
8
+ const envelope = z.object({
9
+ type: z.literal('client-request'),
10
+ rpcId: z.string(),
11
+ method: z.literal(method),
12
+ payload: z.unknown(),
13
+ });
14
+ ctx.effect(() => ctx.connection.fetch.register({
15
+ path: `/api/${method}`,
16
+ methods: ['POST'],
17
+ requestBody: 'buffered',
18
+ async fetch(request) {
19
+ const admission = ctx.connection.admit(request);
20
+ if ('rejection' in admission) return new Response('unauthorized', { status: admission.rejection });
21
+ if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') return new Response('content type must be application/json', { status: 415 });
22
+ let message;
23
+ try { message = envelope.parse(await request.json()); }
24
+ catch { return new Response('invalid worktree request envelope', { status: 400 }); }
25
+ const result = await host.rpc(endpoint, message.payload, request.signal, admission.peer);
26
+ return Response.json({ type: 'server-response', rpcId: message.rpcId, result });
27
+ },
28
+ }));
29
+ }
30
+ }