@timurproko/a1 0.2.7-dev.1287 → 0.2.7-dev.1290

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.js +4 -0
  3. package/dist/cli/capabilities.d.ts +1 -0
  4. package/dist/cli/capabilities.js +1 -1
  5. package/dist/cli/dispatch.d.ts +4 -0
  6. package/dist/cli/dispatch.js +34 -1
  7. package/dist/cli/session-color.d.ts +6 -0
  8. package/dist/cli/session-color.js +44 -0
  9. package/dist/cli/session-context.js +5 -0
  10. package/dist/composition/owned-settings.d.ts +2 -2
  11. package/dist/composition/owned-settings.js +2 -2
  12. package/dist/composition/owned-ui.js +31 -11
  13. package/dist/composition/session-colors.d.ts +5 -0
  14. package/dist/composition/session-colors.js +33 -0
  15. package/dist/composition/session-runtime-registry.d.ts +3 -1
  16. package/dist/composition/session-runtime-registry.js +32 -3
  17. package/dist/contracts/owned-ui/index.d.ts +2 -2
  18. package/dist/contracts/owned-ui/index.js +1 -0
  19. package/dist/contracts/presentation/progress-palette.generated.d.ts +31 -5
  20. package/dist/contracts/presentation/progress-palette.generated.js +1 -1
  21. package/dist/features/launch/session-context.d.ts +9 -0
  22. package/dist/features/launch/session-context.js +7 -1
  23. package/dist/features/owned-ui/settings-app.js +1 -1
  24. package/dist/foundation/lifecycle/index.d.ts +6 -2
  25. package/dist/foundation/lifecycle/index.js +3 -1
  26. package/dist/foundation/lifecycle/session-control.d.ts +26 -0
  27. package/dist/foundation/lifecycle/session-control.js +222 -0
  28. package/dist/foundation/lifecycle/session-repository-context.d.ts +27 -0
  29. package/dist/foundation/lifecycle/session-repository-context.js +106 -7
  30. package/dist/foundation/lifecycle/session-worktree-completion.d.ts +35 -0
  31. package/dist/foundation/lifecycle/session-worktree-completion.js +190 -0
  32. package/dist/foundation/release/early-accent.d.ts +2 -1
  33. package/dist/integrations/pi/components/upstream/theme/theme.js +8 -6
  34. package/dist/integrations/pi/engine/runtime-integration.js +1 -1
  35. package/dist/integrations/pi/engine/worktree-tools.d.ts +5 -2
  36. package/dist/integrations/pi/engine/worktree-tools.js +18 -3
  37. package/dist/ui/settings/declarations.d.ts +18 -2
  38. package/dist/ui/settings/declarations.js +14 -4
  39. package/dist/ui/settings/early-accent.d.ts +16 -0
  40. package/dist/ui/settings/early-accent.js +1 -3
  41. package/dist/ui/settings/manager.d.ts +4 -2
  42. package/dist/ui/settings/manager.js +36 -4
  43. package/dist/ui/settings/resolution.js +3 -2
  44. package/docs/architecture/project-structure.md +1 -1
  45. package/docs/features/session-colors.md +66 -0
  46. package/docs/features/worktrees.md +30 -2
  47. package/docs/local-worktree-cleanup.md +3 -1
  48. package/docs/openspec-archive-automation.md +3 -3
  49. package/docs/worktree-link-recovery.md +2 -0
  50. package/package.json +3 -3
@@ -2,10 +2,14 @@ export { assertLaunchProfileId } from "./model.js";
2
2
  export type { LaunchProfileId, ProcessIdentity } from "./model.js";
3
3
  export { PID_LIVENESS_IDENTITY, inspectLiveProcess } from "./process-liveness.js";
4
4
  export { resolveProductPaths } from "./paths.js";
5
+ export { serveSessionControl, requestSessionControl } from "./session-control.js";
6
+ export type { SessionControlHostOptions, SessionControlIdentity, SessionControlRequestOptions } from "./session-control.js";
5
7
  export type { ProductPaths } from "./paths.js";
6
8
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
7
9
  export type { SessionSelection } from "./session-selection.js";
8
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
10
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseInvalidSessionRepositoryContext, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
9
11
  export { retrySessionCoordination } from "./session-context-retry.js";
10
12
  export { enterSessionRepositoryWorktree } from "./session-worktree-entry.js";
11
- export type { SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionRepositoryObservation, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
13
+ export { describeWorktreeCompletion, requestSessionWorktreeCompletion, settleSessionWorktreeCompletion, } from "./session-worktree-completion.js";
14
+ export type { WorktreeCompletionRequest, WorktreeCompletionResult, WorktreeCompletionSelectors, } from "./session-worktree-completion.js";
15
+ export type { ReleasedSessionAttachment, SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionRepositoryObservation, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
@@ -1,7 +1,9 @@
1
1
  export { assertLaunchProfileId } from "./model.js";
2
2
  export { PID_LIVENESS_IDENTITY, inspectLiveProcess } from "./process-liveness.js";
3
3
  export { resolveProductPaths } from "./paths.js";
4
+ export { serveSessionControl, requestSessionControl } from "./session-control.js";
4
5
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
5
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
6
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseInvalidSessionRepositoryContext, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
6
7
  export { retrySessionCoordination } from "./session-context-retry.js";
7
8
  export { enterSessionRepositoryWorktree } from "./session-worktree-entry.js";
9
+ export { describeWorktreeCompletion, requestSessionWorktreeCompletion, settleSessionWorktreeCompletion, } from "./session-worktree-completion.js";
@@ -0,0 +1,26 @@
1
+ import type { ProcessIdentity } from "./model.js";
2
+ export interface SessionControlIdentity {
3
+ readonly sessionId: string;
4
+ readonly binding: number;
5
+ }
6
+ interface ControlOptions {
7
+ readonly runtimeDir: string;
8
+ readonly inspectProcess?: (pid: number) => Promise<ProcessIdentity | null>;
9
+ }
10
+ export interface SessionControlHostOptions extends ControlOptions {
11
+ readonly runtimeId: string;
12
+ readonly identity: () => SessionControlIdentity;
13
+ readonly handle: (payload: unknown) => Promise<unknown>;
14
+ }
15
+ export interface SessionControlRequestOptions extends ControlOptions {
16
+ readonly sessionId: string;
17
+ readonly runtimeId?: string;
18
+ readonly payload: unknown;
19
+ readonly timeoutMs?: number;
20
+ }
21
+ export declare function serveSessionControl(options: SessionControlHostOptions): Promise<{
22
+ publish(): Promise<void>;
23
+ dispose(): Promise<void>;
24
+ }>;
25
+ export declare function requestSessionControl(options: SessionControlRequestOptions): Promise<unknown>;
26
+ export {};
@@ -0,0 +1,222 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readdir, open, realpath, rename, unlink, writeFile, chmod } from "node:fs/promises";
3
+ import { createConnection, createServer } from "node:net";
4
+ import { join, resolve } from "node:path";
5
+ import { inspectLiveProcess } from "./process-liveness.js";
6
+ const LIMIT = 8192;
7
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iu;
8
+ const SLUG = /^[a-z0-9-]{1,128}$/iu;
9
+ const fail = (code, message) => Object.assign(new Error(message), { code });
10
+ const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 32);
11
+ const address = (directory, id) => process.platform === "win32"
12
+ ? `\\\\.\\pipe\\session-control-${hash(directory)}-${id}` : join(directory, `${id}.sock`);
13
+ async function privateDirectory(runtimeDir, create) {
14
+ const selectedRoot = resolve(runtimeDir);
15
+ if (create)
16
+ await mkdir(selectedRoot, { recursive: true, mode: 0o700 });
17
+ const root = await realpath(selectedRoot);
18
+ const directory = join(root, "session-controls");
19
+ if (create)
20
+ await mkdir(directory, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST")
21
+ throw error; });
22
+ const stat = await lstat(directory);
23
+ if (!stat.isDirectory() || stat.isSymbolicLink() || resolve(await realpath(directory)) !== directory) {
24
+ throw fail("control-boundary", "Session control directory is not a private regular directory.");
25
+ }
26
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0)
27
+ throw fail("control-boundary", "Session control directory permissions are not private.");
28
+ return directory;
29
+ }
30
+ async function readRegistration(file) {
31
+ const stat = await lstat(file);
32
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > LIMIT || (process.platform !== "win32" && (stat.mode & 0o077) !== 0)) {
33
+ throw fail("control-boundary", "Invalid session control record.");
34
+ }
35
+ const handle = await open(file, "r");
36
+ let raw;
37
+ try {
38
+ const opened = await handle.stat();
39
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || !opened.isFile())
40
+ throw fail("control-boundary", "Session control record changed during lookup.");
41
+ const buffer = Buffer.alloc(LIMIT + 1);
42
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
43
+ if (bytesRead > LIMIT)
44
+ throw fail("control-boundary", "Session control record exceeds the size limit.");
45
+ raw = buffer.subarray(0, bytesRead).toString("utf8");
46
+ }
47
+ finally {
48
+ await handle.close();
49
+ }
50
+ let value;
51
+ try {
52
+ value = JSON.parse(raw);
53
+ }
54
+ catch {
55
+ throw fail("control-boundary", "Malformed session control record.");
56
+ }
57
+ if (!value || typeof value !== "object")
58
+ throw fail("control-boundary", "Malformed session control record.");
59
+ if (value.version !== 1 || !UUID.test(value.id) || !UUID.test(value.sessionId) || typeof value.runtimeId !== "string" || !SLUG.test(value.runtimeId)
60
+ || !UUID.test(value.nonce) || !Number.isSafeInteger(value.binding) || value.binding < 0
61
+ || !Number.isSafeInteger(value.owner?.pid) || value.owner.pid <= 0 || typeof value.owner.startIdentity !== "string") {
62
+ throw fail("control-boundary", "Malformed session control record.");
63
+ }
64
+ return value;
65
+ }
66
+ function receive(socket, consume) {
67
+ let data = "", complete = false;
68
+ socket.setEncoding("utf8");
69
+ socket.on("data", chunk => {
70
+ if (complete) {
71
+ socket.destroy();
72
+ return;
73
+ }
74
+ data += chunk;
75
+ if (Buffer.byteLength(data) > LIMIT) {
76
+ socket.destroy();
77
+ return;
78
+ }
79
+ const newline = data.indexOf("\n");
80
+ if (newline < 0)
81
+ return;
82
+ complete = true;
83
+ if (data.slice(newline + 1).trim() !== "") {
84
+ socket.destroy();
85
+ return;
86
+ }
87
+ try {
88
+ consume(JSON.parse(data.slice(0, newline)));
89
+ }
90
+ catch {
91
+ socket.destroy();
92
+ }
93
+ });
94
+ }
95
+ export async function serveSessionControl(options) {
96
+ if (!SLUG.test(options.runtimeId))
97
+ throw fail("session-unavailable", "Runtime identity is invalid.");
98
+ const directory = await privateDirectory(options.runtimeDir, true);
99
+ const inspect = options.inspectProcess ?? inspectLiveProcess;
100
+ const owner = await inspect(process.pid);
101
+ if (!owner || owner.pid !== process.pid || !owner.startIdentity)
102
+ throw fail("session-unavailable", "Runtime identity cannot be verified.");
103
+ const id = randomUUID(), nonce = randomUUID(), file = join(directory, `${id}.json`);
104
+ let disposed = false, queue = Promise.resolve(), publication = Promise.resolve();
105
+ const sockets = new Set();
106
+ const server = createServer(socket => {
107
+ sockets.add(socket);
108
+ socket.setTimeout(5000, () => socket.destroy());
109
+ socket.on("error", () => { });
110
+ socket.on("close", () => sockets.delete(socket));
111
+ receive(socket, input => {
112
+ queue = queue.then(async () => {
113
+ try {
114
+ const request = input;
115
+ const current = options.identity();
116
+ if (disposed || socket.destroyed || request.nonce !== nonce || request.runtimeId !== options.runtimeId
117
+ || request.sessionId !== current.sessionId || request.binding !== current.binding) {
118
+ throw fail("session-unavailable", "The selected session is no longer active.");
119
+ }
120
+ if (!UUID.test(request.requestId) || !Number.isFinite(request.deadline) || request.deadline <= Date.now() || request.deadline > Date.now() + 5000) {
121
+ throw fail("request-expired", "Session request expired before application.");
122
+ }
123
+ // Concurrency: no await separates the identity/deadline check from calling the live handler.
124
+ const result = await options.handle(request.payload);
125
+ if (!socket.destroyed)
126
+ socket.end(`${JSON.stringify({ requestId: request.requestId, result })}\n`);
127
+ }
128
+ catch (error) {
129
+ const failure = error;
130
+ if (!socket.destroyed)
131
+ socket.end(`${JSON.stringify({ error: { code: failure.code ?? "control-failed", message: failure.message } })}\n`);
132
+ }
133
+ }).catch(() => { socket.destroy(); });
134
+ });
135
+ });
136
+ await new Promise((done, reject) => { server.once("error", reject); server.listen(address(directory, id), () => { server.off("error", reject); done(); }); });
137
+ server.on("error", () => { });
138
+ server.maxConnections = 64;
139
+ const publish = () => {
140
+ publication = publication.catch(() => { }).then(async () => {
141
+ if (disposed)
142
+ return;
143
+ const identity = options.identity();
144
+ if (!UUID.test(identity.sessionId) || !Number.isSafeInteger(identity.binding) || identity.binding < 0)
145
+ throw fail("session-unavailable", "Session identity is not available.");
146
+ const temporary = join(directory, `${id}.${randomUUID()}.tmp`);
147
+ try {
148
+ await writeFile(temporary, JSON.stringify({ version: 1, id, nonce, runtimeId: options.runtimeId, owner, ...identity }), { mode: 0o600, flag: "wx" });
149
+ await rename(temporary, file);
150
+ }
151
+ finally {
152
+ await unlink(temporary).catch(() => { });
153
+ }
154
+ });
155
+ return publication;
156
+ };
157
+ const dispose = async () => {
158
+ if (disposed)
159
+ return;
160
+ disposed = true;
161
+ for (const socket of sockets)
162
+ socket.destroy();
163
+ await new Promise(done => server.close(() => done()));
164
+ await queue;
165
+ await publication.catch(() => { });
166
+ await unlink(file).catch(() => { });
167
+ };
168
+ try {
169
+ if (process.platform !== "win32")
170
+ await chmod(address(directory, id), 0o600);
171
+ await publish();
172
+ }
173
+ catch (error) {
174
+ await dispose();
175
+ throw error;
176
+ }
177
+ return { publish, dispose };
178
+ }
179
+ export async function requestSessionControl(options) {
180
+ if (!UUID.test(options.sessionId))
181
+ throw fail("invalid-session", "Supply a complete session UUID.");
182
+ const budget = Math.min(5000, Math.max(1, options.timeoutMs ?? 3000));
183
+ const deadline = Date.now() + budget;
184
+ const directory = await privateDirectory(options.runtimeDir, false).catch(() => { throw fail("session-unavailable", "No live session control is available."); });
185
+ const names = (await readdir(directory)).filter(name => UUID.test(name.slice(0, -5)) && name.endsWith(".json"));
186
+ if (names.length > 256)
187
+ throw fail("control-limit", "Too many session records to resolve safely.");
188
+ const matches = [];
189
+ for (const name of names) {
190
+ const record = await readRegistration(join(directory, name)).catch(error => { if (error.code === "ENOENT")
191
+ return null; throw error; });
192
+ if (record === null || `${record.id}.json` !== name || record.sessionId !== options.sessionId || (options.runtimeId !== undefined && record.runtimeId !== options.runtimeId))
193
+ continue;
194
+ const owner = await (options.inspectProcess ?? inspectLiveProcess)(record.owner.pid);
195
+ if (owner?.startIdentity === record.owner.startIdentity)
196
+ matches.push(record);
197
+ if (Date.now() >= deadline)
198
+ throw fail("request-expired", "Session lookup exceeded its deadline.");
199
+ }
200
+ if (matches.length !== 1)
201
+ throw fail(matches.length > 1 ? "ambiguous-session" : "session-unavailable", "Select one running bare session by its complete session id.");
202
+ const record = matches[0];
203
+ return await new Promise((done, reject) => {
204
+ const socket = createConnection(address(directory, record.id));
205
+ const requestId = randomUUID();
206
+ const timer = setTimeout(() => { socket.destroy(); reject(fail("request-expired", "Session request timed out; application was not acknowledged.")); }, Math.max(1, deadline - Date.now()));
207
+ const finish = () => { clearTimeout(timer); socket.destroy(); };
208
+ socket.on("error", () => { finish(); reject(fail("session-unavailable", "The selected session could not be reached.")); });
209
+ socket.on("close", () => { clearTimeout(timer); reject(fail("session-unavailable", "The selected session closed before acknowledgement.")); });
210
+ socket.on("connect", () => socket.write(`${JSON.stringify({ ...record, requestId, deadline, payload: options.payload })}\n`));
211
+ receive(socket, input => {
212
+ const response = input;
213
+ finish();
214
+ if (response.error)
215
+ reject(fail(response.error.code, response.error.message));
216
+ else if (response.requestId !== requestId)
217
+ reject(fail("control-failed", "Session acknowledgement did not match the request."));
218
+ else
219
+ done(response.result);
220
+ });
221
+ });
222
+ }
@@ -57,3 +57,30 @@ export declare function readSessionStartupRoot(identity: SessionRepositoryIdenti
57
57
  export declare function readActiveSessionRepositoryContext(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): Promise<SessionRepositoryObservation | null>;
58
58
  /** List same-repository worktrees without acquiring, releasing, or repairing an editing claim. */
59
59
  export declare function listSessionRepositoryWorktrees(identity: SessionRepositoryIdentity, options?: SessionRepositoryContextOptions): Promise<readonly SessionWorktreeInventoryEntry[]>;
60
+ /** The released checkout and the verified startup root the session continues from. */
61
+ export interface ReleasedSessionAttachment {
62
+ readonly worktreeRoot: string;
63
+ readonly startupRoot: string;
64
+ }
65
+ /**
66
+ * Release this session's own association and claim when its checkout is definitively gone.
67
+ * Uncertain validation keeps the attachment; files, branches, and other owners' claims are never touched.
68
+ */
69
+ export declare function releaseInvalidSessionRepositoryContext(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): Promise<ReleasedSessionAttachment | null>;
70
+ /** The checkout a session left for completion, with the verified startup root it continues from. */
71
+ export interface DetachedSessionWorktree {
72
+ readonly startupRoot: string;
73
+ readonly commonDir: string;
74
+ readonly worktreeRoot: string;
75
+ readonly worktreeGitDir: string;
76
+ readonly branch: string;
77
+ }
78
+ /**
79
+ * Release a valid attachment this session owns, after `record` durably notes what was left.
80
+ * A failed check or record keeps the attachment; nothing on disk is deleted.
81
+ */
82
+ export declare function detachSessionRepositoryContextForCompletion(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions, record: (detached: DetachedSessionWorktree) => Promise<void>): Promise<DetachedSessionWorktree>;
83
+ /** Whether any live runtime holds the editing claim of one checkout; an unreadable claim is never treated as absent. */
84
+ export declare function sessionWorktreeClaimState(commonDir: string, gitDir: string, options?: SessionRepositoryContextOptions): Promise<"absent" | "live" | "unverifiable">;
85
+ /** Private per-agent store for a completion requested by a shell and settled by its runtime. */
86
+ export declare function sessionWorktreeCompletionPath(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): string;
@@ -16,6 +16,8 @@ const GIT_TIMEOUT_MS = 5_000;
16
16
  /** Records without an agent id were written by a single-agent release and belong to its one agent. */
17
17
  export const PRIMARY_SESSION_AGENT_ID = "primary";
18
18
  const RECORD_FORMAT_VERSION = 2;
19
+ const NOT_A_WORKTREE_ROOT = "associated path must be a Git worktree root";
20
+ const INVALID_ATTACHMENT = "worktree-attachment-invalid";
19
21
  /** Publish the exact live runtime that may acquire a delivery worktree. */
20
22
  export async function registerSessionRepositoryRuntime(identity, runtimeId, runtimeIdentity, options = {}) {
21
23
  const header = await readSessionHeader(identity);
@@ -417,16 +419,113 @@ async function validateContextRecord(header, record, signal) {
417
419
  if (record.sessionId !== header.id || record.sessionFile !== header.file)
418
420
  throw new Error("associated session identity changed");
419
421
  const startup = await readGitWorktreeIdentity(header.cwd, false, signal);
420
- const selected = await readGitWorktreeIdentity(record.worktreeRoot, true, signal);
421
- if (!samePath(startup.root, record.startupRoot)
422
- || !samePath(startup.commonDir, record.startupCommonDir)
423
- || !samePath(selected.root, record.worktreeRoot)
424
- || !samePath(selected.commonDir, record.worktreeCommonDir)
425
- || !samePath(selected.gitDir, record.worktreeGitDir)
422
+ if (!samePath(startup.root, record.startupRoot) || !samePath(startup.commonDir, record.startupCommonDir)) {
423
+ throw new Error("associated worktree identity changed");
424
+ }
425
+ // Invariant: only a verified startup checkout makes the attached checkout's absence definitive.
426
+ const invalid = (detail) => Object.assign(contextError(`attached worktree ${record.worktreeRoot} ${detail}`, INVALID_ATTACHMENT), { worktreeRoot: record.worktreeRoot, startupRoot: startup.root });
427
+ const missing = (error) => { if (isMissingPathError(error))
428
+ return null; throw error; };
429
+ const root = await realpath(record.worktreeRoot).catch(missing);
430
+ if (root === null)
431
+ throw invalid("no longer exists");
432
+ if (await lstat(join(root, ".git")).catch(missing) === null)
433
+ throw invalid("no longer has its Git pointer");
434
+ let selected;
435
+ try {
436
+ selected = await readGitWorktreeIdentity(root, true, signal);
437
+ }
438
+ catch (error) {
439
+ if (error instanceof Error && (error.message === NOT_A_WORKTREE_ROOT
440
+ || ("stderr" in error && String(error.stderr).includes("not a git repository"))))
441
+ throw invalid("is no longer a Git worktree");
442
+ throw error;
443
+ }
444
+ if (!samePath(selected.commonDir, record.worktreeCommonDir) || !samePath(selected.gitDir, record.worktreeGitDir)
426
445
  || !samePath(startup.commonDir, selected.commonDir))
446
+ throw invalid("was replaced by a different Git worktree");
447
+ if (!samePath(selected.root, record.worktreeRoot))
427
448
  throw new Error("associated worktree identity changed");
428
449
  return selected;
429
450
  }
451
+ /**
452
+ * Release this session's own association and claim when its checkout is definitively gone.
453
+ * Uncertain validation keeps the attachment; files, branches, and other owners' claims are never touched.
454
+ */
455
+ export async function releaseInvalidSessionRepositoryContext(identity, options) {
456
+ const header = await readSessionHeader(identity);
457
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
458
+ return await withClaimLock(options, async () => {
459
+ await requireLiveRuntime(header, owner, options);
460
+ const record = await readContextRecord(header, options);
461
+ if (record === null)
462
+ return null;
463
+ const failure = await validateContextRecord(header, record, options.signal).then(() => null, error => error);
464
+ if (!isInvalidAttachment(failure))
465
+ return null;
466
+ await rm(contextRecordPath(header, options), { force: true });
467
+ await releaseClaimsOwnedBy(owner, null, options);
468
+ return { worktreeRoot: failure.worktreeRoot, startupRoot: failure.startupRoot };
469
+ });
470
+ }
471
+ /**
472
+ * Release a valid attachment this session owns, after `record` durably notes what was left.
473
+ * A failed check or record keeps the attachment; nothing on disk is deleted.
474
+ */
475
+ export async function detachSessionRepositoryContextForCompletion(identity, options, record) {
476
+ const header = await readSessionHeader(identity);
477
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
478
+ return await withClaimLock(options, async () => {
479
+ await requireLiveRuntime(header, owner, options);
480
+ const stored = await readContextRecord(header, options);
481
+ if (stored === null)
482
+ throw contextError("no task worktree is attached", "worktree-not-attached");
483
+ const selected = await validateContextRecord(header, stored, options.signal);
484
+ const claim = await readClaim(selected, options);
485
+ if (claim === null || claim === undefined || !claimMatchesWorktree(claim, selected) || !ownedBy(claim, owner)) {
486
+ throw contextError("the session no longer owns its attached worktree", "worktree-owner-unverifiable");
487
+ }
488
+ if (await lstat(join(selected.gitDir, "locked")).then(() => true, error => { if (isMissingPathError(error))
489
+ return false; throw error; })) {
490
+ throw contextError("selected worktree is locked in Git", "worktree-git-locked");
491
+ }
492
+ const detached = {
493
+ startupRoot: stored.startupRoot, commonDir: selected.commonDir, worktreeRoot: selected.root,
494
+ worktreeGitDir: selected.gitDir, branch: requireBranch(selected),
495
+ };
496
+ await record(detached);
497
+ await rm(contextRecordPath(header, options), { force: true });
498
+ await releaseClaimsOwnedBy(owner, null, options);
499
+ return detached;
500
+ });
501
+ }
502
+ /** Whether any live runtime holds the editing claim of one checkout; an unreadable claim is never treated as absent. */
503
+ export async function sessionWorktreeClaimState(commonDir, gitDir, options = {}) {
504
+ const path = claimRecordPath(commonDir, gitDir, options);
505
+ if (await lstat(path).then(() => false, error => { if (isMissingPathError(error))
506
+ return true; throw error; }))
507
+ return "absent";
508
+ const claim = await readClaimRecordAt(path);
509
+ if (claim === null)
510
+ return "unverifiable";
511
+ try {
512
+ const observed = await requireInspector(options)(claim.pid);
513
+ return observed !== null && observed.pid === claim.pid && observed.startIdentity === claim.startIdentity ? "live" : "absent";
514
+ }
515
+ catch {
516
+ return "unverifiable";
517
+ }
518
+ }
519
+ /** Private per-agent store for a completion requested by a shell and settled by its runtime. */
520
+ export function sessionWorktreeCompletionPath(identity, options) {
521
+ const key = `${requireRuntimeId(options)}\0${requireAgentId(options)}\0${identity.sessionId}`;
522
+ return join(claimStoreRoot(options), "completions", `${digest(key)}.json`);
523
+ }
524
+ function isInvalidAttachment(error) {
525
+ return error instanceof Error && "code" in error && error.code === INVALID_ATTACHMENT
526
+ && "worktreeRoot" in error && typeof error.worktreeRoot === "string"
527
+ && "startupRoot" in error && typeof error.startupRoot === "string";
528
+ }
430
529
  /** Release one agent's claims except the one it keeps; sibling agents of the runtime are untouched. */
431
530
  async function releaseClaimsOwnedBy(owner, keepGitDir, options) {
432
531
  await releaseClaimsWhere(claim => ownedBy(claim, owner) && (keepGitDir === null || !samePath(claim.worktreeGitDir, keepGitDir)), options);
@@ -492,7 +591,7 @@ async function readGitWorktreeIdentity(path, requireWorktreeRoot, signal) {
492
591
  throw new Error("associated worktree identity is incomplete");
493
592
  const root = await realpath(rootValue);
494
593
  if (requireWorktreeRoot && !samePath(root, cwd))
495
- throw new Error("associated path must be a Git worktree root");
594
+ throw new Error(NOT_A_WORKTREE_ROOT);
496
595
  const commonDir = await realpath(commonValue);
497
596
  const gitDir = await realpath(gitValue);
498
597
  let branch = null;
@@ -0,0 +1,35 @@
1
+ import { type SessionRepositoryContextOptions, type SessionRepositoryIdentity } from "./session-repository-context.js";
2
+ export interface WorktreeCompletionSelectors {
3
+ readonly change?: string;
4
+ readonly pr?: number;
5
+ }
6
+ export interface WorktreeCompletionRequest {
7
+ readonly startupRoot: string;
8
+ readonly cwd: string;
9
+ readonly branch: string;
10
+ }
11
+ /**
12
+ * `cleanup-finished` relays the repository command's own report, which stays authoritative:
13
+ * A1 never claims a removal it did not perform.
14
+ */
15
+ export interface WorktreeCompletionResult extends WorktreeCompletionRequest {
16
+ readonly kind: "cleanup-finished" | "cleanup-failed" | "cleanup-unavailable" | "cleanup-skipped";
17
+ readonly detail: string;
18
+ }
19
+ export interface WorktreeCompletionSettleOptions extends SessionRepositoryContextOptions {
20
+ readonly now?: () => number;
21
+ /** Test seam for the repository cleanup command; production runs it without a shell. */
22
+ readonly run?: (command: string, arguments_: readonly string[], cwd: string, signal?: AbortSignal) => Promise<string>;
23
+ }
24
+ /**
25
+ * Leave the attached checkout so its cleanup can run after the issuing shell has exited.
26
+ * Unpublished content, a Git lock, or uncertain ownership keeps the session attached.
27
+ */
28
+ export declare function requestSessionWorktreeCompletion(identity: SessionRepositoryIdentity, selectors: WorktreeCompletionSelectors, options: SessionRepositoryContextOptions): Promise<WorktreeCompletionRequest>;
29
+ /**
30
+ * Run a pending completion's repository cleanup from the startup checkout. The request is consumed
31
+ * before anything runs, so it executes at most once; null means nothing was pending.
32
+ */
33
+ export declare function settleSessionWorktreeCompletion(identity: SessionRepositoryIdentity, options: WorktreeCompletionSettleOptions): Promise<WorktreeCompletionResult | null>;
34
+ /** The agent-facing account of a settled completion; the session is on the startup checkout in every case. */
35
+ export declare function describeWorktreeCompletion(result: WorktreeCompletionResult): string;