@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,6 @@
1
+ - insert:
2
+ - id: worktrees
3
+ name: dsh-worktrees
4
+ config:
5
+ directory: !!js dshHomePath('worktrees')
6
+ maxRetained: 15
package/discovery.js ADDED
@@ -0,0 +1,232 @@
1
+ import path from 'node:path';
2
+ import { bounded, canonical, commitAt, digest, git, repository, samePath, WorktreeError } from './lib/git.js';
3
+
4
+ const CONCURRENCY = 4;
5
+ const EMPTY = Object.freeze({ records: Object.freeze([]), diagnostics: Object.freeze([]) });
6
+
7
+ function pathKey(value) {
8
+ return process.platform === 'win32' ? value.toLowerCase() : value;
9
+ }
10
+
11
+ function missingPath(error) {
12
+ return ['INVALID_PATH', 'ENOENT', 'ENOTDIR'].includes(error?.code);
13
+ }
14
+
15
+ function notWorktree(error) {
16
+ return error?.code === 'GIT_FAILED' && /fatal: (?:not a git repository \(or any (?:of the parent directories|parent up to mount point)[^\r\n]*\)|this operation must be run in a work tree)/i.test(error.message);
17
+ }
18
+
19
+ function diagnostic(error, directory, sessionIds) {
20
+ return Object.freeze({
21
+ ...(directory ? { path: directory } : {}),
22
+ ...(sessionIds ? { sessionIds: Object.freeze([...sessionIds]) } : {}),
23
+ code: typeof error?.code === 'string' ? error.code : 'DISCOVERY_FAILED',
24
+ message: error instanceof Error ? error.message : String(error),
25
+ });
26
+ }
27
+
28
+ function managedSessions(state) {
29
+ return new Set(Object.values(state?.bindings ?? {}).filter(binding => binding?.mode === 'new' && binding.worktreeId).map(binding => binding.sessionId));
30
+ }
31
+
32
+ /** Read-only linked-checkout metadata. No refs, index entries, or worktree files are changed. */
33
+ async function inspectDirectory(directory, signal) {
34
+ let inspected;
35
+ try {
36
+ inspected = await repository(directory, signal);
37
+ } catch (error) {
38
+ if (notWorktree(error)) return null;
39
+ throw error;
40
+ }
41
+ const gitDirResult = await git(inspected.root, ['rev-parse', '--absolute-git-dir'], { signal, maxOutput: 64 * 1024 });
42
+ const gitDir = await canonical(gitDirResult.stdout.replace(/\r?\n$/, ''));
43
+ if (samePath(gitDir, inspected.commonDir)) return null;
44
+ const branch = await git(inspected.root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { signal, allowed: [0, 1], maxOutput: 64 * 1024 });
45
+ const head = await commitAt(inspected.root, 'HEAD', signal, true);
46
+ return Object.freeze({
47
+ id: `external:${digest(pathKey(inspected.root))}`,
48
+ path: inspected.root,
49
+ currentBranch: branch.code === 0 ? branch.stdout.replace(/\r?\n$/, '') : null,
50
+ head,
51
+ baseRef: null,
52
+ status: 'ready',
53
+ external: true,
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Observe existing linked worktrees without adopting them into managed state.
59
+ * ctx only needs sessionController.list({}, signal) and workspaceRegistry.list().
60
+ * Each refresh reads session membership; Git metadata is cached for cacheMs.
61
+ * Concurrent refresh calls share one scan. Cancelling the final waiter stops it.
62
+ */
63
+ export class LinkedWorktreeDiscovery {
64
+ constructor(ctx, { cacheMs = 30_000, maxDirectories = 256, scanTimeoutMs = 10_000 } = {}) {
65
+ for (const [name, value, minimum] of [['cacheMs', cacheMs, 0], ['maxDirectories', maxDirectories, 1], ['scanTimeoutMs', scanTimeoutMs, 1]]) {
66
+ if (!Number.isSafeInteger(value) || value < minimum) throw new TypeError(`${name} must be an integer of at least ${minimum}.`);
67
+ }
68
+ this.ctx = ctx;
69
+ this.cacheMs = cacheMs;
70
+ this.maxDirectories = maxDirectories;
71
+ this.scanTimeoutMs = scanTimeoutMs;
72
+ this.cache = new Map();
73
+ this.value = EMPTY;
74
+ this.pending = null;
75
+ this.managed = new Set();
76
+ this.generation = 0;
77
+ this.disposed = false;
78
+ }
79
+
80
+ snapshot() {
81
+ return this.value;
82
+ }
83
+
84
+ invalidate() {
85
+ this.generation += 1;
86
+ this.cache.clear();
87
+ this.pending?.controller.abort(new WorktreeError('DISCOVERY_INVALIDATED', 'Worktree discovery was invalidated.'));
88
+ this.pending = null;
89
+ }
90
+
91
+ dispose() {
92
+ this.disposed = true;
93
+ this.invalidate();
94
+ this.value = EMPTY;
95
+ }
96
+
97
+ refresh(state, signal) {
98
+ signal?.throwIfAborted();
99
+ if (this.disposed) return Promise.reject(new WorktreeError('DISCOVERY_DISPOSED', 'Worktree discovery is disposed.'));
100
+ this.managed = managedSessions(state);
101
+ this.value = this.excludeManaged(this.value);
102
+ let pending = this.pending;
103
+ if (!pending || pending.controller.signal.aborted) {
104
+ pending = { controller: new AbortController(), waiters: 0, promise: null };
105
+ const generation = this.generation;
106
+ const timer = setTimeout(() => pending.controller.abort(new WorktreeError('DISCOVERY_TIMEOUT', `Worktree discovery exceeded ${this.scanTimeoutMs} ms.`)), this.scanTimeoutMs);
107
+ timer.unref();
108
+ pending.promise = this.scan(pending.controller.signal, generation).then(value => {
109
+ if (!pending.controller.signal.aborted && generation === this.generation && !this.disposed) this.value = this.excludeManaged(value);
110
+ return this.value;
111
+ }).finally(() => {
112
+ clearTimeout(timer);
113
+ if (this.pending === pending) this.pending = null;
114
+ });
115
+ this.pending = pending;
116
+ }
117
+ pending.waiters += 1;
118
+ return bounded(pending.promise, signal).finally(() => {
119
+ pending.waiters -= 1;
120
+ if (pending.waiters === 0 && this.pending === pending) pending.controller.abort(new WorktreeError('ABORTED', 'Worktree discovery has no remaining callers.'));
121
+ });
122
+ }
123
+
124
+ excludeManaged(value) {
125
+ const records = value.records.map(record => {
126
+ const sessionIds = record.sessionIds.filter(id => !this.managed.has(id));
127
+ return sessionIds.length === record.sessionIds.length ? record : Object.freeze({ ...record, sessionIds: Object.freeze(sessionIds) });
128
+ }).filter(record => record.sessionIds.length);
129
+ if (records.length === value.records.length && records.every((record, index) => record === value.records[index])) return value;
130
+ return Object.freeze({ records: Object.freeze(records), diagnostics: value.diagnostics });
131
+ }
132
+
133
+ async inspect(directory, signal, inFlight, generation) {
134
+ let real;
135
+ try {
136
+ real = await canonical(directory);
137
+ } catch (error) {
138
+ if (missingPath(error)) return { record: null };
139
+ throw error;
140
+ }
141
+ signal.throwIfAborted();
142
+ const key = pathKey(real);
143
+ const cached = this.cache.get(key);
144
+ if (cached) {
145
+ this.cache.delete(key);
146
+ if (cached.expiresAt > Date.now()) {
147
+ this.cache.set(key, cached);
148
+ return cached.result;
149
+ }
150
+ }
151
+ if (inFlight.has(key)) return inFlight.get(key);
152
+ const promise = inspectDirectory(real, signal).then(record => ({ record }), error => {
153
+ signal.throwIfAborted();
154
+ // ENOENT here can be a missing Git executable, so it remains observable.
155
+ return { error };
156
+ }).then(result => {
157
+ if (generation === this.generation && !signal.aborted) {
158
+ this.cache.set(key, { result, expiresAt: Date.now() + this.cacheMs });
159
+ while (this.cache.size > this.maxDirectories) this.cache.delete(this.cache.keys().next().value);
160
+ }
161
+ return result;
162
+ });
163
+ inFlight.set(key, promise);
164
+ return promise;
165
+ }
166
+
167
+ async scan(signal, generation) {
168
+ const value = await bounded(this.ctx.sessionController.list({}, signal), signal);
169
+ signal.throwIfAborted();
170
+ if (!Array.isArray(value?.items)) throw new TypeError('Session list must contain an items array.');
171
+ const workspaces = this.ctx.workspaceRegistry.list();
172
+ if (!Array.isArray(workspaces)) throw new TypeError('Workspace list must be an array.');
173
+ const membership = new Map();
174
+ for (const workspace of workspaces) {
175
+ for (const sessionId of workspace.sessionIds) if (!membership.has(sessionId)) membership.set(sessionId, workspace);
176
+ }
177
+ const directories = new Map();
178
+ for (const session of value.items) {
179
+ if (typeof session?.sessionId !== 'string' || !session.sessionId || this.managed.has(session.sessionId)) continue;
180
+ const projected = session.projections?.values?.workingDirectory;
181
+ const directory = projected === undefined ? session.cwd : projected;
182
+ if (typeof directory !== 'string' || !path.isAbsolute(directory) || directory.includes('\0')) continue;
183
+ const key = pathKey(directory);
184
+ if (!directories.has(key)) directories.set(key, { directory, sessionIds: new Set() });
185
+ directories.get(key).sessionIds.add(session.sessionId);
186
+ }
187
+ const diagnostics = [];
188
+ const entries = [...directories.values()];
189
+ if (entries.length > this.maxDirectories) diagnostics.push(diagnostic(new WorktreeError('DISCOVERY_LIMIT', `Inspected at most ${this.maxDirectories} of ${entries.length} session directories.`)));
190
+ const queue = entries.slice(0, this.maxDirectories);
191
+ const results = new Array(queue.length);
192
+ const inFlight = new Map();
193
+ let next = 0;
194
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
195
+ while (next < queue.length) {
196
+ signal.throwIfAborted();
197
+ const index = next++;
198
+ try {
199
+ results[index] = await this.inspect(queue[index].directory, signal, inFlight, generation);
200
+ } catch (error) {
201
+ signal.throwIfAborted();
202
+ results[index] = { error };
203
+ }
204
+ }
205
+ }));
206
+ signal.throwIfAborted();
207
+ const records = new Map();
208
+ for (let index = 0; index < queue.length; index += 1) {
209
+ const { directory, sessionIds } = queue[index];
210
+ const { record, error } = results[index];
211
+ if (error) { diagnostics.push(diagnostic(error, directory, sessionIds)); continue; }
212
+ if (!record) continue;
213
+ let grouped = records.get(record.id);
214
+ if (!grouped) { grouped = { ...record, sessionIds: new Set() }; records.set(record.id, grouped); }
215
+ for (const sessionId of sessionIds) {
216
+ grouped.sessionIds.add(sessionId);
217
+ const workspace = membership.get(sessionId);
218
+ if (!grouped.workspaceId && workspace) {
219
+ grouped.workspaceId = workspace.id;
220
+ grouped.workspaceTitle = workspace.title;
221
+ }
222
+ }
223
+ }
224
+ // Bound cached metadata even when sessions keep moving between directories.
225
+ const now = Date.now();
226
+ for (const [key, cached] of this.cache) if (cached.expiresAt <= now) this.cache.delete(key);
227
+ return Object.freeze({
228
+ records: Object.freeze([...records.values()].map(record => Object.freeze({ ...record, sessionIds: Object.freeze([...record.sessionIds]) }))),
229
+ diagnostics: Object.freeze(diagnostics),
230
+ });
231
+ }
232
+ }
package/host.js ADDED
@@ -0,0 +1,412 @@
1
+ import path from 'node:path';
2
+ import { stat } from 'node:fs/promises';
3
+ import { z } from 'zod';
4
+ import { WorktreeManager } from './lib/manager.js';
5
+ import { LinkedWorktreeDiscovery } from './discovery.js';
6
+
7
+ const id = z.string().min(1).max(256);
8
+ const schemas = {
9
+ workspace: z.object({ workspaceId: id }).strict(),
10
+ checkout: z.object({ workspaceId: id, branch: z.string().min(1).max(4096) }).strict(),
11
+ prepare: z.object({
12
+ sessionId: id,
13
+ operationId: id,
14
+ choice: z.object({ mode: z.enum(['local', 'new']), baseRef: z.string().min(1).max(4096).optional() }).strict().optional(),
15
+ }).strict(),
16
+ settle: z.object({ sessionId: id, operationId: id }).strict(),
17
+ list: z.object({ refresh: z.boolean().optional() }).strict(),
18
+ restore: z.object({ worktreeId: id }).strict(),
19
+ remove: z.object({ worktreeId: id }).strict(),
20
+ configure: z.object({
21
+ directory: z.string().min(1).max(32767).optional(),
22
+ maxRetained: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(),
23
+ expectedRevision: z.number().int().nonnegative(),
24
+ }).strict(),
25
+ };
26
+ export const RPC_ENDPOINTS = Object.freeze(Object.keys(schemas));
27
+
28
+ function errorMessage(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+
32
+ function within(root, target) {
33
+ if (!root || !target) return false;
34
+ const relative = path.relative(path.resolve(root), path.resolve(target));
35
+ return relative === '' || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
36
+ }
37
+
38
+ function workspaceValue(workspace) {
39
+ return { workspaceId: workspace.id, path: workspace.path, title: workspace.title };
40
+ }
41
+
42
+ /** Extract only accepted human input, including queued messages, from a durable inbox splice. */
43
+ export function acceptedMessages(event) {
44
+ if (event.type !== 'agent/inbox/spliced') return [];
45
+ return (event.data.inserted ?? []).filter(message => message.source?.kind === 'user').map(message => ({
46
+ requestId: message.source.rpcId ?? message.id ?? `${event.seq}`,
47
+ acceptedAt: event.time,
48
+ }));
49
+ }
50
+
51
+ /** Host adapter: explicit Session/Workspace identities; no ambient active Session. */
52
+ export class WorktreeHost {
53
+ constructor(ctx, config, storage, Manager = WorktreeManager) {
54
+ this.ctx = ctx;
55
+ this.config = config;
56
+ this.controller = new AbortController();
57
+ this.operations = new Set();
58
+ this.reservations = new Map();
59
+ this.accepting = new Map();
60
+ this.reconciled = new Map();
61
+ this.discovery = new LinkedWorktreeDiscovery(ctx);
62
+ this.discoveryPending = null;
63
+ this.discoveryNext = 0;
64
+ this.discoveryDiagnostics = '';
65
+ this.cleanupTimer = null;
66
+ this.cleanupRunning = false;
67
+ this.cleanupRequested = false;
68
+ this.retryDelay = 30_000;
69
+ this.manager = new Manager({
70
+ storage,
71
+ getConfig: () => this.configuration(),
72
+ setDirectory: (sessionId, directory) => this.setDirectory(sessionId, directory),
73
+ isProtected: record => this.protection(record),
74
+ archiveSessions: async ids => {
75
+ for (const sessionId of ids) await this.ctx.workspaceRegistry.archiveSession(sessionId);
76
+ },
77
+ });
78
+ }
79
+
80
+ configuration() {
81
+ return { directory: this.config.directory.get(), maxRetained: this.config.maxRetained.get() };
82
+ }
83
+
84
+ track(operation) {
85
+ this.operations.add(operation);
86
+ operation.then(() => this.operations.delete(operation), () => this.operations.delete(operation));
87
+ return operation;
88
+ }
89
+
90
+ report(error) {
91
+ if (!this.controller.signal.aborted) this.ctx.logger.warn(`worktrees: ${errorMessage(error)}`);
92
+ }
93
+
94
+ workspace(workspaceId) {
95
+ const workspace = this.ctx.workspaceRegistry.get(workspaceId);
96
+ if (!workspace) throw new Error('The workspace is no longer registered.');
97
+ return workspaceValue(workspace);
98
+ }
99
+
100
+ workspaceFor(session) {
101
+ const workspace = this.ctx.workspaceRegistry.list().find(item => item.sessionIds.includes(session.id));
102
+ if (workspace) return workspaceValue(workspace);
103
+ const binding = this.manager.list().bindings[session.id];
104
+ return binding ? { workspaceId: binding.workspaceId, path: binding.workspacePath, title: binding.workspaceTitle } : undefined;
105
+ }
106
+
107
+ async resolveAgent(sessionId) {
108
+ const result = await this.ctx.sessionController.resolveAgent(sessionId);
109
+ if ('error' in result) throw result.error;
110
+ this.controller.signal.throwIfAborted();
111
+ return result.agent;
112
+ }
113
+
114
+ async setDirectory(sessionId, directory) {
115
+ const agent = this.ctx.agents.get(sessionId);
116
+ if (!agent) throw new Error('The session was closed before its worktree was ready.');
117
+ const current = this.ctx.workingDirectory.get(agent.session);
118
+ const binding = this.manager.list().bindings[sessionId];
119
+ // Local sessions retain native navigation; managed sessions retain navigation inside their checkout.
120
+ if (binding?.acceptedAt != null && (binding.mode === 'local' || within(directory, current))) return;
121
+ await this.ctx.workingDirectory.set(agent, directory, this.controller.signal);
122
+ }
123
+
124
+ async assertAvailable(agent, signal) {
125
+ if (!this.manager.list().bindings[agent.id]?.worktreeId) return;
126
+ const binding = await this.manager.assertAvailable(agent.id, signal);
127
+ if (binding?.mode !== 'new') return;
128
+ const current = this.ctx.workingDirectory.get(agent.session);
129
+ if (!within(binding.path, current)) return;
130
+ let currentDirectory;
131
+ try { currentDirectory = await stat(current); }
132
+ catch (error) {
133
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
134
+ }
135
+ signal?.throwIfAborted();
136
+ // Native ensure() falls back to the immutable source cwd. Keep recovery inside this verified checkout.
137
+ if (!currentDirectory?.isDirectory()) await this.ctx.workingDirectory.set(agent, binding.path, signal);
138
+ }
139
+
140
+ async protection(record) {
141
+ const reasons = [];
142
+ const pinned = new Set(this.ctx.workspaceRegistry.pinnedSessionIds);
143
+ for (const sessionId of record.sessionIds) {
144
+ if (pinned.has(sessionId)) reasons.push('pinned');
145
+ if (this.reservations.has(sessionId)) reasons.push('sending');
146
+ try {
147
+ const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, async () => []);
148
+ if (activity.length) reasons.push('active');
149
+ } catch (error) {
150
+ reasons.push('activity-unavailable');
151
+ this.report(error);
152
+ }
153
+ }
154
+ // An explicitly registered workspace rooted in a checkout is never disposable.
155
+ if (this.ctx.workspaceRegistry.list().some(item => within(record.path, item.path))) reasons.push('workspace');
156
+ return [...new Set(reasons)];
157
+ }
158
+
159
+ observe(session, event) {
160
+ const binding = this.manager.list().bindings[session.id];
161
+ const accepted = acceptedMessages(event).filter(item => binding && item.acceptedAt >= binding.preparedAt);
162
+ if (!accepted.length) return Promise.resolve();
163
+ const previous = this.accepting.get(session.id) ?? Promise.resolve();
164
+ const operation = previous.catch(() => {}).then(async () => {
165
+ for (const item of accepted) await this.manager.remember({ sessionId: session.id, ...item });
166
+ });
167
+ this.accepting.set(session.id, operation);
168
+ this.track(operation).catch(error => this.report(error));
169
+ return operation;
170
+ }
171
+
172
+ async reconcile(session) {
173
+ const events = session.snapshotEvents();
174
+ const cursor = this.reconciled.get(session.id) ?? 0;
175
+ for (const event of events.slice(cursor)) await this.observe(session, event);
176
+ this.reconciled.set(session.id, events.length);
177
+ }
178
+
179
+ async inherit(agent, signal) {
180
+ const session = agent.session;
181
+ if (!session.header.parentSession || this.manager.list().bindings[session.id]) return;
182
+ const parent = this.manager.list().bindings[session.header.parentSession];
183
+ if (!parent?.worktreeId) return;
184
+ const workspace = this.workspaceFor(session) ?? { workspaceId: parent.workspaceId, path: parent.workspacePath, title: parent.workspaceTitle };
185
+ await this.manager.bindInheritedSession(session.id, session.header.parentSession, workspace, this.ctx.workingDirectory.get(session), signal);
186
+ }
187
+
188
+ async prepare(request, peer, signal) {
189
+ const present = this.reservations.get(request.sessionId);
190
+ if (present && (present.operationId !== request.operationId || present.peerId !== peer.id)) {
191
+ const error = new Error('Another message is preparing this session. Wait for it to finish, then send again.');
192
+ error.code = 'SEND_IN_PROGRESS';
193
+ throw error;
194
+ }
195
+ let reservation = present;
196
+ if (!reservation) {
197
+ const release = () => {
198
+ if (this.reservations.get(request.sessionId) === reservation) this.reservations.delete(request.sessionId);
199
+ };
200
+ reservation = { operationId: request.operationId, peerId: peer.id, release, unsubscribe: () => peer.signal.removeEventListener('abort', release) };
201
+ this.reservations.set(request.sessionId, reservation);
202
+ peer.signal.addEventListener('abort', release, { once: true });
203
+ }
204
+ try {
205
+ const agent = await this.resolveAgent(request.sessionId);
206
+ await this.inherit(agent, signal);
207
+ await this.reconcile(agent.session);
208
+ const workspace = this.workspaceFor(agent.session);
209
+ if (!workspace) {
210
+ await this.settle(request, peer);
211
+ return { ready: true, actualDirectory: this.ctx.workingDirectory.get(agent.session), passthrough: true };
212
+ }
213
+ const binding = this.manager.list().bindings[request.sessionId];
214
+ const hasHumanHistory = agent.session.snapshotEvents().some(event => acceptedMessages(event).length > 0 || event.type === 'user/message' && event.data.source?.kind === 'user');
215
+ if (!binding && hasHumanHistory) {
216
+ if (request.choice?.mode === 'new') throw new Error('A new worktree can only be selected before this session’s first message.');
217
+ await this.settle(request, peer);
218
+ return { ready: true, actualDirectory: this.ctx.workingDirectory.get(agent.session), passthrough: true };
219
+ }
220
+ const choice = request.choice ?? (binding ? { mode: binding.mode, ...(binding.baseRef ? { baseRef: binding.baseRef } : {}) } : (await this.manager.inspectWorkspace(workspace, signal)).choice);
221
+ const prepared = await this.manager.prepare({ sessionId: request.sessionId, workspace, choice, operationId: request.operationId }, signal);
222
+ if (prepared.requiresRestore && !prepared.worktree.snapshotCommit) throw Object.assign(new Error(`The worktree directory is missing and no recovery snapshot is available: ${prepared.worktree.path}`), { code: 'DIRECTORY_MISSING' });
223
+ if (prepared.requiresRestore) return {
224
+ requiresRestore: true,
225
+ worktreeId: prepared.worktree.id,
226
+ path: prepared.worktree.path,
227
+ snapshotRef: prepared.worktree.snapshotRef,
228
+ };
229
+ this.requestCleanup();
230
+ return { ready: true, actualDirectory: this.ctx.workingDirectory.get(agent.session), ...(prepared.worktree ? { worktreeId: prepared.worktree.id } : {}) };
231
+ } catch (error) {
232
+ await this.settle(request, peer).catch(settleError => this.report(settleError));
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async settle(request, peer) {
238
+ const reservation = this.reservations.get(request.sessionId);
239
+ if (!reservation || reservation.operationId !== request.operationId || reservation.peerId !== peer.id) return { settled: true };
240
+ const agent = this.ctx.agents.get(request.sessionId);
241
+ try {
242
+ if (agent) await this.reconcile(agent.session);
243
+ await this.accepting.get(request.sessionId);
244
+ } finally {
245
+ reservation.unsubscribe();
246
+ reservation.release();
247
+ }
248
+ this.requestCleanup();
249
+ return { settled: true };
250
+ }
251
+
252
+ descriptor() {
253
+ const descriptor = this.ctx.settings.describe({ redactSecrets: true }).find(item => item.ns === 'worktrees');
254
+ if (!descriptor) throw new Error('The worktrees settings entry is not active.');
255
+ return descriptor;
256
+ }
257
+
258
+ requestDiscovery(invalidate = false) {
259
+ if (this.controller.signal.aborted) return;
260
+ if (invalidate) {
261
+ this.discovery.invalidate();
262
+ this.discoveryNext = 0;
263
+ }
264
+ if (this.discoveryPending || Date.now() < this.discoveryNext) return;
265
+ this.discoveryNext = Date.now() + 30_000;
266
+ this.discoveryPending = this.track(this.discovery.refresh(this.manager.list(), this.controller.signal).then(value => {
267
+ const diagnostics = JSON.stringify(value.diagnostics);
268
+ if (diagnostics !== this.discoveryDiagnostics) {
269
+ this.discoveryDiagnostics = diagnostics;
270
+ for (const item of value.diagnostics) this.report(new Error(`Worktree discovery: ${item.message}`));
271
+ }
272
+ }).catch(error => {
273
+ if (error.code !== 'DISCOVERY_INVALIDATED') this.report(error);
274
+ }).finally(() => {
275
+ this.discoveryPending = null;
276
+ if (this.discoveryNext === 0) this.requestDiscovery();
277
+ }));
278
+ }
279
+
280
+ async list(refresh, signal) {
281
+ if (refresh) await this.manager.refresh(signal);
282
+ this.requestDiscovery();
283
+ const state = this.manager.list();
284
+ const records = [];
285
+ for (const record of Object.values(state.worktrees)) {
286
+ signal?.throwIfAborted();
287
+ const protectionReasons = await this.protection(record);
288
+ const { token: _token, ...visible } = record;
289
+ records.push({ ...visible, snapshotRef: record.snapshotCommit ? record.snapshotRef : null, repositoryRoot: record.sourceRoot, protected: protectionReasons.length > 0, protectionReasons });
290
+ }
291
+ return {
292
+ records,
293
+ observed: this.discovery.snapshot().records,
294
+ bindings: Object.values(state.bindings),
295
+ preferences: Object.entries(state.preferences).map(([workspaceId, choice]) => ({ workspaceId, ...choice })),
296
+ config: this.configuration(),
297
+ revision: this.descriptor().revision,
298
+ directoryPickerKind: this.ctx.get('directoryPicker')?.capability().kind ?? null,
299
+ };
300
+ }
301
+
302
+ async restore(worktreeId, signal) {
303
+ const record = await this.manager.restore(worktreeId, signal);
304
+ this.requestCleanup();
305
+ return { worktreeId: record.id, path: record.path };
306
+ }
307
+
308
+ async remove(worktreeId, signal) {
309
+ const record = await this.manager.remove(worktreeId, signal);
310
+ this.requestCleanup();
311
+ return { worktreeId: record.id, status: record.status };
312
+ }
313
+
314
+ async configure(request) {
315
+ const { expectedRevision, ...patch } = schemas.configure.parse(request);
316
+ if (patch.directory && !path.isAbsolute(patch.directory)) throw new Error('Worktree directory must be an absolute path on the Host.');
317
+ await this.ctx.settings.update('worktrees', patch, expectedRevision);
318
+ this.requestCleanup();
319
+ return { config: this.configuration(), revision: this.descriptor().revision };
320
+ }
321
+
322
+ async dispatch(endpoint, input, signal, peer) {
323
+ if (peer?.principal?.kind !== 'owner') throw Object.assign(new Error('Worktree management requires the Host owner.'), { code: 'FORBIDDEN' });
324
+ if (!Object.hasOwn(schemas, endpoint)) throw Object.assign(new Error('Unknown worktree operation.'), { code: 'NOT_FOUND' });
325
+ const request = schemas[endpoint].parse(input ?? {});
326
+ signal.throwIfAborted();
327
+ switch (endpoint) {
328
+ case 'workspace': return this.manager.inspectWorkspace(this.workspace(request.workspaceId), signal);
329
+ case 'checkout': return this.manager.checkout(this.workspace(request.workspaceId), request.branch, signal);
330
+ case 'prepare': return this.prepare(request, peer, signal);
331
+ case 'settle': return this.settle(request, peer);
332
+ case 'list': return this.list(request.refresh === true, signal);
333
+ case 'restore': return this.restore(request.worktreeId, signal);
334
+ case 'remove': return this.remove(request.worktreeId, signal);
335
+ case 'configure': return this.configure(request);
336
+ default: throw new Error('Unknown worktree operation.');
337
+ }
338
+ }
339
+
340
+ rpc(endpoint, input, signal, peer) {
341
+ const control = AbortSignal.any([signal, this.controller.signal, peer.signal]);
342
+ return this.track(this.dispatch(endpoint, input, control, peer).then(value => ({ ok: true, value }), error => ({
343
+ ok: false,
344
+ error: { code: typeof error.code === 'string' ? error.code : 'WORKTREE_ERROR', message: errorMessage(error), details: {} },
345
+ })));
346
+ }
347
+
348
+ requestCleanup(delay = 1000) {
349
+ if (this.controller.signal.aborted) return;
350
+ this.cleanupRequested = true;
351
+ if (this.cleanupRunning || this.cleanupTimer) return;
352
+ this.cleanupTimer = setTimeout(() => {
353
+ this.cleanupTimer = null;
354
+ this.track(this.clean()).catch(error => this.report(error));
355
+ }, delay);
356
+ this.cleanupTimer.unref?.();
357
+ }
358
+
359
+ async clean() {
360
+ if (this.controller.signal.aborted || this.cleanupRunning || !this.cleanupRequested) return;
361
+ this.cleanupRequested = false;
362
+ this.cleanupRunning = true;
363
+ try {
364
+ const result = await this.manager.cleanup(this.controller.signal);
365
+ if (result.errors.length) {
366
+ for (const item of result.errors) this.report(new Error(item.message));
367
+ this.requestCleanup(this.retryDelay);
368
+ this.retryDelay = Math.min(this.retryDelay * 2, 300_000);
369
+ } else {
370
+ this.retryDelay = 30_000;
371
+ if (result.removed.length && result.eligible - result.removed.length > this.configuration().maxRetained) this.cleanupRequested = true;
372
+ }
373
+ } finally {
374
+ this.cleanupRunning = false;
375
+ if (this.cleanupRequested) this.requestCleanup(this.retryDelay);
376
+ }
377
+ }
378
+
379
+ async start() {
380
+ // Recover only sends prepared since the last recorded admission; reading never resumes an Agent.
381
+ for (const binding of Object.values(this.manager.list().bindings)) {
382
+ if (binding.acceptedAt != null && binding.preparedAt <= binding.acceptedAt) continue;
383
+ try {
384
+ const inspected = await this.ctx.sessionController.inspect(binding.sessionId, this.controller.signal);
385
+ for (const event of inspected.events) {
386
+ for (const accepted of acceptedMessages(event).filter(item => item.acceptedAt >= binding.preparedAt)) await this.manager.remember({ sessionId: binding.sessionId, ...accepted });
387
+ }
388
+ } catch (error) { this.report(error); }
389
+ }
390
+ await this.manager.refresh(this.controller.signal);
391
+ this.requestDiscovery();
392
+ this.requestCleanup();
393
+ }
394
+
395
+ context(agent) {
396
+ const binding = agent && this.manager.list().bindings[agent.id];
397
+ const record = binding?.worktreeId && this.manager.list().worktrees[binding.worktreeId];
398
+ if (!record || record.status === 'removed') return '';
399
+ return `Managed Git worktree: ${JSON.stringify(record.path)}. Created from ${JSON.stringify(record.baseRef)} at ${record.baseCommit}. This checkout was created detached. Before making authorized code changes, inspect Git HEAD and create a task branch from the current HEAD if still detached; honor a branch name requested by the user. Do not create a branch for a read-only task, or commit or push without the user's instruction.`;
400
+ }
401
+
402
+ async dispose() {
403
+ this.controller.abort(new Error('Worktree plugin disposed.'));
404
+ this.discovery.dispose();
405
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
406
+ this.cleanupTimer = null;
407
+ for (const reservation of this.reservations.values()) reservation.unsubscribe();
408
+ this.reservations.clear();
409
+ await this.manager.dispose();
410
+ await Promise.allSettled([...this.operations]);
411
+ }
412
+ }
package/icon.svg ADDED
@@ -0,0 +1,18 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g transform="translate(1.8 1.8) scale(0.9)">
3
+ <path d="M11 12V24M11 19H16C20.4 19 25 16.4 25 12" stroke="url(#worktree-branches)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
4
+ <rect x="7.5" y="7" width="7" height="7" rx="2" fill="#86B8F5" />
5
+ <rect x="7.5" y="22" width="7" height="7" rx="2" fill="#5269E1" />
6
+ <rect x="21.5" y="7" width="7" height="7" rx="2" fill="url(#worktree-leaf)" />
7
+ </g>
8
+ <defs>
9
+ <linearGradient id="worktree-branches" x1="11" y1="12" x2="18" y2="24" gradientUnits="userSpaceOnUse">
10
+ <stop stop-color="#86B8F5" />
11
+ <stop offset="1" stop-color="#5269E1" />
12
+ </linearGradient>
13
+ <linearGradient id="worktree-leaf" x1="22" y1="7" x2="28" y2="14" gradientUnits="userSpaceOnUse">
14
+ <stop stop-color="#51CFDB" />
15
+ <stop offset="1" stop-color="#108792" />
16
+ </linearGradient>
17
+ </defs>
18
+ </svg>