pi-odd-worktree 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,595 @@
1
+ 'use strict';
2
+ // S7/S8/S9: the ONE active-session guard for checkout deletion.
3
+ //
4
+ // `worktree_close` and the warp panel's Ctrl+D both ask this module before they
5
+ // remove a checkout, so the two surfaces can never disagree about whether a
6
+ // checkout is in use.
7
+ //
8
+ // WHAT IT CONSUMES. Gentle Shell (an optional host, present on this machine)
9
+ // publishes a bounded presence store at
10
+ // `<agent home>/gentle-agents/presence` (upstream
11
+ // lib/orchestrator-presence.ts): one `<sessionHash>.<incarnation>.header.json`
12
+ // heartbeat per live Pi session, plus an optional `.discovery.json` sidecar
13
+ // whose `metadata.workspace` is that session's own cwd
14
+ // (lib/orchestrator-discovery.ts). Both are deliberately public metadata — a
15
+ // label, a hash, a workspace path, counters — never a transcript, a prompt or a
16
+ // credential. This module reads exactly those two files, validates them against
17
+ // the published shapes, and never reads the `.activity.json` payload, the
18
+ // catalog, or a session file.
19
+ //
20
+ // WHAT IT DELIBERATELY DOES NOT CONSUME. Gentle Shell's scratch ledger
21
+ // (lib/session-scratch.ts) records the clones and worktrees a session CREATED,
22
+ // keyed by that session's id, with its pid and process start token. Creation is
23
+ // not occupancy: the creating session may have finished or warped away, so
24
+ // blocking on it would refuse legitimate removals and invent authority the
25
+ // ledger does not carry. Presence (heartbeat + workspace) is the mechanism that
26
+ // answers "is a live session working here", so it is the only blocking
27
+ // evidence.
28
+ //
29
+ // WHAT "LIVE" MEANS HERE. A heartbeat counts only when it is not in the future
30
+ // and is at most `PRESENCE_FRESH_MS` old — the same recency window upstream
31
+ // computes for a peer. Everything else is STALE: Gentle Shell never deletes a
32
+ // record a crashed process left behind, so a stale record cannot block a
33
+ // removal forever. It is reported instead (see `notice`), because the honest
34
+ // statement is "no live session found, and N records were too old to prove
35
+ // anything".
36
+ //
37
+ // BOUNDS AND HONESTY. The scan reads at most `MAX_PRESENCE_ENTRIES` records,
38
+ // each under `HEADER_LIMIT` bytes, following no symbolic link (a linked
39
+ // presence directory or record is refused, never followed). Malformed records
40
+ // are counted, not thrown. When the store is absent, the guard says
41
+ // "unavailable" and blocks nothing: this check is BEST EFFORT and is not
42
+ // claimed to be exhaustive. A record whose discovery sidecar is missing or
43
+ // names no usable workspace is UNKNOWN — it can never be attributed to a
44
+ // checkout, so it never blocks and is reported in the evidence the caller
45
+ // displays.
46
+ //
47
+ // NAMING A SESSION. The presence store publishes only the sha256 of a session
48
+ // id, never the id itself. The raw id lives in Gentle Shell's transport presence
49
+ // registry (`<agent home>/gentle-agents/transport/presence/<sessionId>.<token>
50
+ // .json`, upstream lib/agents-session-transport.ts), which is published for
51
+ // other sessions to address this one. This module joins the two: a validated
52
+ // transport record whose `sessionId` hashes to the presence header's
53
+ // `sessionHash` names that session, and nothing else can. The registry is read
54
+ // with upstream's own rules — private 0700 directories, a regular same-user
55
+ // file under 8 KiB, no symbolic link, a filename whose id and token match the
56
+ // body, exactly four keys, a version-1 record — and only `sessionId` is
57
+ // consumed: the endpoint is never opened, never connected to, and never used to
58
+ // read a session. When no record joins (a peer that has not published one, an
59
+ // over-cap scan, an unreadable registry), the refusal falls back to the
60
+ // published presence hash and SAYS SO; it never guesses an id.
61
+ //
62
+ // No static package imports (node builtins only), so the extension's
63
+ // copy-to-.mjs test loaders keep working. Nothing here writes, moves, links or
64
+ // deletes anything.
65
+
66
+ const crypto = require('node:crypto');
67
+ const fs = require('node:fs');
68
+ const fsPromises = require('node:fs/promises');
69
+ const os = require('node:os');
70
+ const path = require('node:path');
71
+ const { stripVTControlCharacters } = require('node:util');
72
+
73
+ // Upstream's `recent` window for a presence header (orchestrator-presence.ts
74
+ // publishes every 5 s; orchestrator-discovery.ts treats 15 s as recent).
75
+ const PRESENCE_FRESH_MS = 15000;
76
+ // Upstream's HEADER_LIMIT: a header or discovery sidecar is a small document.
77
+ const HEADER_LIMIT = 16 * 1024;
78
+ // A machine with more concurrent Pi sessions than this is pathological; the
79
+ // scan stops and says so rather than reading an unbounded directory.
80
+ const MAX_PRESENCE_ENTRIES = 128;
81
+ // How many blocked sessions one refusal names before it summarizes the rest.
82
+ const MAX_NAMED_SESSIONS = 3;
83
+
84
+ const HASH = /^[a-f0-9]{64}$/;
85
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
86
+ const RECORD = /^([a-f0-9]{64})\.([a-f0-9-]{36})\.(header|discovery)\.json$/;
87
+ const HEADER_KEYS = ['schema', 'sessionHash', 'incarnation', 'label', 'heartbeat', 'generation', 'counts', 'digest', 'unavailable'];
88
+ const DISCOVERY_KEYS = ['schema', 'sessionHash', 'incarnation', 'generation', 'metadata'];
89
+ const DISCOVERY_OPTIONAL_KEYS = ['scope', 'state', 'aliases'];
90
+ const COUNT_KEYS = ['running', 'queued', 'waiting', 'finished'];
91
+
92
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
93
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
94
+ const hasExactKeys = (value, keys) => isObject(value)
95
+ && Object.keys(value).length === keys.length
96
+ && keys.every((key) => Object.hasOwn(value, key));
97
+
98
+ function agentHomeFor(env) {
99
+ const source = isObject(env) ? env : {};
100
+ for (const key of ['GENTLE_PI_AGENT_HOME', 'PI_CODING_AGENT_DIR']) {
101
+ const value = source[key];
102
+ if (typeof value === 'string' && value.length > 0) return value;
103
+ }
104
+ return path.join(os.homedir(), '.pi', 'agent');
105
+ }
106
+
107
+ // The presence directory Gentle Shell publishes to, derived with ITS resolution
108
+ // rule (lib/agent-home.ts) so both extensions name the same store.
109
+ function presenceDirectoryFor(env) {
110
+ return path.join(agentHomeFor(env), 'gentle-agents', 'presence');
111
+ }
112
+
113
+ function hashSessionId(sessionId) {
114
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
115
+ return crypto.createHash('sha256').update(sessionId).digest('hex');
116
+ }
117
+
118
+ // Upstream's display-label normalization, used only to validate that a
119
+ // published workspace is a literal path and not something a control character
120
+ // or a collapsed whitespace run could disguise as another checkout.
121
+ function normalizedLabel(text) {
122
+ const clean = stripVTControlCharacters(text).replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
123
+ return Array.from(clean).slice(0, 120).join('').trimEnd();
124
+ }
125
+
126
+ function usableWorkspace(value) {
127
+ if (typeof value !== 'string' || value.length === 0) return null;
128
+ // Upstream publishes "" for a workspace it will not advertise (too long, or
129
+ // not identical to its normalized form). Anything that is not a literal
130
+ // absolute path is unusable evidence, never a guess.
131
+ if (value !== normalizedLabel(value)) return null;
132
+ if (!path.isAbsolute(value)) return null;
133
+ return value;
134
+ }
135
+
136
+ function validHeader(value) {
137
+ if (!hasExactKeys(value, HEADER_KEYS)) return null;
138
+ if (value.schema !== 1) return null;
139
+ if (typeof value.sessionHash !== 'string' || !HASH.test(value.sessionHash)) return null;
140
+ if (typeof value.incarnation !== 'string' || !UUID.test(value.incarnation)) return null;
141
+ if (typeof value.label !== 'string' || value.label !== normalizedLabel(value.label)) return null;
142
+ if (!isCount(value.heartbeat) || !isCount(value.generation) || value.generation === 0) return null;
143
+ if (!hasExactKeys(value.counts, COUNT_KEYS) || !COUNT_KEYS.every((key) => isCount(value.counts[key]))) return null;
144
+ const digestOk = value.unavailable === null && typeof value.digest === 'string' && HASH.test(value.digest);
145
+ const oversized = value.unavailable === 'activity-too-large' && value.digest === null;
146
+ if (!digestOk && !oversized) return null;
147
+ return value;
148
+ }
149
+
150
+ // The discovery sidecar carries the session's workspace. Only the identity and
151
+ // the workspace are consumed; the curated state, aliases and scope are not read
152
+ // into anything this module returns.
153
+ function discoveryWorkspace(value, header) {
154
+ if (!isObject(value)) return null;
155
+ const allowed = new Set([...DISCOVERY_KEYS, ...DISCOVERY_OPTIONAL_KEYS]);
156
+ if (!Object.keys(value).every((key) => allowed.has(key))) return null;
157
+ if (!DISCOVERY_KEYS.every((key) => Object.hasOwn(value, key))) return null;
158
+ if (value.schema !== 1) return null;
159
+ if (value.sessionHash !== header.sessionHash || value.incarnation !== header.incarnation) return null;
160
+ if (value.generation !== header.generation) return null;
161
+ if (!isObject(value.metadata)) return null;
162
+ return usableWorkspace(value.metadata.workspace);
163
+ }
164
+
165
+ // A bounded read of ONE regular file: no symbolic link, a single link, the size
166
+ // checked before and after opening, and the same inode read as the one
167
+ // inspected (upstream `boundedRead`). Anything else throws and is counted as a
168
+ // rejected record by the caller.
169
+ async function readBoundedFile(file, limit) {
170
+ const before = await fsPromises.lstat(file);
171
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error('unsafe-file');
172
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && before.uid !== process.getuid()) {
173
+ throw new Error('unsafe-file');
174
+ }
175
+ if (before.size > limit) throw new Error('oversized-file');
176
+ const flags = fs.constants.O_RDONLY
177
+ | (fs.constants.O_NOFOLLOW ?? 0)
178
+ | (fs.constants.O_NONBLOCK ?? 0);
179
+ const handle = await fsPromises.open(file, flags);
180
+ try {
181
+ const stat = await handle.stat();
182
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('unsafe-file');
183
+ if (stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('changed-file');
184
+ if (stat.size > limit) throw new Error('oversized-file');
185
+ const buffer = Buffer.alloc(stat.size + 1);
186
+ let used = 0;
187
+ while (used < buffer.length) {
188
+ const { bytesRead } = await handle.read(buffer, used, buffer.length - used, null);
189
+ if (bytesRead === 0) break;
190
+ used += bytesRead;
191
+ }
192
+ if (used !== stat.size) throw new Error('changed-file');
193
+ return buffer.subarray(0, used).toString('utf8');
194
+ } finally {
195
+ try { await handle.close(); } catch { /* a dead handle never fails the read */ }
196
+ }
197
+ }
198
+
199
+ function reasonOf(error) {
200
+ if (typeof error === 'string' && error.length > 0) return error;
201
+ const code = error && error.code;
202
+ if (code === 'ENOENT') return 'missing';
203
+ if (code === 'EACCES' || code === 'EPERM') return 'not readable';
204
+ if (code === 'ENOTDIR') return 'not a directory';
205
+ if (error && error.message === 'unsafe-directory') {
206
+ return 'the presence directory is a symbolic link, or not a private directory this session owns';
207
+ }
208
+ if (error && error.message === 'unsafe-file') return 'a presence record is a symbolic link, or not a private regular file';
209
+ if (error && typeof error.message === 'string' && error.message.length > 0) return error.message;
210
+ return 'unreadable';
211
+ }
212
+
213
+ // Upstream's private-directory rule: a real directory owned by this user with no
214
+ // group or other permission bits at all.
215
+ async function checkedDirectory(dir) {
216
+ const stat = await fsPromises.lstat(dir);
217
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe-directory');
218
+ if (process.platform !== 'win32' && typeof process.getuid === 'function') {
219
+ if (stat.uid !== process.getuid()) throw new Error('unsafe-directory');
220
+ if ((stat.mode & 0o077) !== 0) throw new Error('unsafe-directory');
221
+ }
222
+ return dir;
223
+ }
224
+
225
+ // Upstream's transport bounds (agents-session-transport.ts): a record is 8 KiB,
226
+ // the registry scan stops at 64 schema-matching files.
227
+ const TRANSPORT_MAX_BYTES = 8192;
228
+ const TRANSPORT_MAX_ENTRIES = 64;
229
+ // Upstream's session-id and record-name shapes. The 22-character token belongs
230
+ // to the endpoint socket and is part of the filename; this module never uses it.
231
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
232
+ const TRANSPORT_FILE = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/;
233
+
234
+ function transportDirectoryFor(env) {
235
+ return path.join(agentHomeFor(env), 'gentle-agents', 'transport', 'presence');
236
+ }
237
+
238
+ // A transport directory is EXACTLY 0700 and owned by this user (upstream's
239
+ // `privateDirectory`). A linked or loosened directory is refused, never read.
240
+ async function privateDirectory700(dir) {
241
+ const stat = await fsPromises.lstat(dir);
242
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe-directory');
243
+ if (process.platform !== 'win32' && typeof process.getuid === 'function') {
244
+ if (stat.uid !== process.getuid()) throw new Error('unsafe-directory');
245
+ if ((stat.mode & 0o777) !== 0o700) throw new Error('unsafe-directory');
246
+ }
247
+ return dir;
248
+ }
249
+
250
+ // The record's raw session id, only when the body is the exact published shape
251
+ // and names the same id as the filename. Anything else is no identity at all.
252
+ function transportSessionId(value, expectedId) {
253
+ if (!isObject(value)) return null;
254
+ const keys = ['version', 'sessionId', 'endpoint', 'createdAt'];
255
+ if (Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) return null;
256
+ if (value.version !== 1 || typeof value.endpoint !== 'string' || !isCount(value.createdAt)) return null;
257
+ if (typeof value.sessionId !== 'string' || !SESSION_ID.test(value.sessionId)) return null;
258
+ return value.sessionId === expectedId ? value.sessionId : null;
259
+ }
260
+
261
+ // The published transport ids, read with upstream's bounds and refusals.
262
+ // Resolves; never rejects. `unavailable`/`truncated`/`rejected` are disclosure,
263
+ // not authority: a missing join can never name a session and never blocks.
264
+ async function readTransportSessions(options = {}) {
265
+ const env = options && isObject(options.env) ? options.env : process.env;
266
+ const maxEntries = isCount(options.maxEntries) && options.maxEntries > 0
267
+ ? Math.min(options.maxEntries, TRANSPORT_MAX_ENTRIES)
268
+ : TRANSPORT_MAX_ENTRIES;
269
+ const base = path.join(agentHomeFor(env), 'gentle-agents');
270
+ const dir = transportDirectoryFor(env);
271
+ const unavailable = (reason) => ({
272
+ status: 'unavailable', reason, sessionIds: [], scanned: 0, rejected: 0, truncated: false,
273
+ });
274
+ try {
275
+ const shared = await fsPromises.lstat(base);
276
+ if (!shared.isDirectory() || shared.isSymbolicLink()) throw new Error('unsafe-directory');
277
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && shared.uid !== process.getuid()) {
278
+ throw new Error('unsafe-directory');
279
+ }
280
+ await privateDirectory700(path.join(base, 'transport'));
281
+ await privateDirectory700(dir);
282
+ } catch (error) {
283
+ return unavailable(reasonOf(error));
284
+ }
285
+ let names;
286
+ try {
287
+ names = await fsPromises.readdir(dir);
288
+ } catch (error) {
289
+ return unavailable(reasonOf(error));
290
+ }
291
+ const matches = names.filter((name) => !name.startsWith('.') && TRANSPORT_FILE.test(name)).sort();
292
+ const selected = matches.slice(0, maxEntries);
293
+ const result = {
294
+ status: 'ok', reason: null, sessionIds: [], scanned: 0, rejected: 0,
295
+ truncated: matches.length > selected.length,
296
+ };
297
+ for (const name of selected) {
298
+ result.scanned += 1;
299
+ const match = TRANSPORT_FILE.exec(name);
300
+ try {
301
+ const text = await readBoundedFile(path.join(dir, name), TRANSPORT_MAX_BYTES);
302
+ const id = transportSessionId(JSON.parse(text), match[1]);
303
+ if (id === null) throw new Error('malformed');
304
+ result.sessionIds.push(id);
305
+ } catch {
306
+ result.rejected += 1;
307
+ }
308
+ }
309
+ return result;
310
+ }
311
+
312
+ function emptyRead() {
313
+ return { status: 'ok', entries: [], scanned: 0, rejected: 0, truncated: false };
314
+ }
315
+
316
+ // One bounded read of the whole published store. Resolves; never rejects.
317
+ async function readGentlePresence(options = {}) {
318
+ const env = options && isObject(options.env) ? options.env : process.env;
319
+ const now = Number.isSafeInteger(options.now) ? options.now : Date.now();
320
+ const maxEntries = isCount(options.maxEntries) && options.maxEntries > 0
321
+ ? options.maxEntries
322
+ : MAX_PRESENCE_ENTRIES;
323
+ const dir = presenceDirectoryFor(env);
324
+ try {
325
+ await checkedDirectory(dir);
326
+ } catch (error) {
327
+ return { ...emptyRead(), status: 'unavailable', reason: reasonOf(error) };
328
+ }
329
+ let names;
330
+ try {
331
+ names = await fsPromises.readdir(dir);
332
+ } catch (error) {
333
+ return { ...emptyRead(), status: 'unavailable', reason: reasonOf(error) };
334
+ }
335
+ const records = names
336
+ .filter((name) => !name.startsWith('.') && RECORD.test(name))
337
+ .sort();
338
+ const headerNames = records.filter((name) => name.endsWith('.header.json'));
339
+ const selected = headerNames.slice(0, maxEntries);
340
+ const result = emptyRead();
341
+ result.truncated = headerNames.length > selected.length;
342
+ for (const name of selected) {
343
+ result.scanned += 1;
344
+ const match = RECORD.exec(name);
345
+ try {
346
+ const text = await readBoundedFile(path.join(dir, name), HEADER_LIMIT);
347
+ const header = validHeader(JSON.parse(text));
348
+ if (!header || header.sessionHash !== match[1] || header.incarnation !== match[2]) throw new Error('malformed');
349
+ let workspace = null;
350
+ try {
351
+ const sidecar = await readBoundedFile(
352
+ path.join(dir, `${header.sessionHash}.${header.incarnation}.discovery.json`),
353
+ HEADER_LIMIT,
354
+ );
355
+ workspace = discoveryWorkspace(JSON.parse(sidecar), header);
356
+ } catch {
357
+ // A legacy publisher has no sidecar; an unreadable one is no evidence.
358
+ workspace = null;
359
+ }
360
+ result.entries.push({
361
+ sessionHash: header.sessionHash,
362
+ incarnation: header.incarnation,
363
+ label: header.label,
364
+ heartbeat: header.heartbeat,
365
+ recent: now >= header.heartbeat && now - header.heartbeat <= PRESENCE_FRESH_MS,
366
+ workspace,
367
+ unknown: workspace === null,
368
+ });
369
+ } catch {
370
+ result.rejected += 1;
371
+ }
372
+ }
373
+ return result;
374
+ }
375
+
376
+ // Every identity a path can present: its resolved spelling plus, when the
377
+ // filesystem still resolves it, its realpath.
378
+ function pathIdentities(absolutePath) {
379
+ const identities = new Set();
380
+ if (typeof absolutePath !== 'string' || !path.isAbsolute(absolutePath)) return identities;
381
+ identities.add(path.resolve(absolutePath));
382
+ try {
383
+ identities.add(fs.realpathSync(absolutePath));
384
+ } catch {
385
+ // A directory that is gone contributes only its spelling.
386
+ }
387
+ return identities;
388
+ }
389
+
390
+ // True when `workspacePath` is the checkout or lives inside it. Comparison is by
391
+ // identity, so a symlink spelling of the same checkout still matches, and a
392
+ // sibling that merely shares a name prefix does not.
393
+ function withinCheckout(workspacePath, targetPath) {
394
+ if (typeof workspacePath !== 'string' || !path.isAbsolute(workspacePath)) return false;
395
+ const targets = pathIdentities(targetPath);
396
+ if (targets.size === 0) return false;
397
+ for (const id of pathIdentities(workspacePath)) {
398
+ for (const target of targets) {
399
+ if (id === target || id.startsWith(target + path.sep)) return true;
400
+ }
401
+ }
402
+ return false;
403
+ }
404
+
405
+ // How one blocked session is named. The RAW id when a validated transport
406
+ // record joins the presence hash, otherwise the published hash with an explicit
407
+ // "could not be resolved" clause at the refusal — never a label alone, which
408
+ // would not identify the session the human has to close or warp.
409
+ function sessionName(blocker) {
410
+ if (typeof blocker.rawSessionId === 'string' && blocker.rawSessionId.length > 0) {
411
+ return blocker.label ? `'${blocker.label}' (id ${blocker.rawSessionId})` : `id ${blocker.rawSessionId}`;
412
+ }
413
+ const id = typeof blocker.sessionHash === 'string' ? `presence ${blocker.sessionHash.slice(0, 12)}` : 'an unpublished identity';
414
+ return blocker.label ? `'${blocker.label}' (${id})` : id;
415
+ }
416
+
417
+ function sessionNames(blockers, maxNamed) {
418
+ const named = blockers.slice(0, maxNamed).map(sessionName).join(', ');
419
+ const rest = blockers.length - Math.min(blockers.length, maxNamed);
420
+ return rest > 0 ? `${named}, and ${rest} more` : named;
421
+ }
422
+
423
+ // Why no raw id joined, in words that are true of the evidence actually read: a
424
+ // missing registry means nothing was published, a broken one means nothing could
425
+ // be read, and a truncated scan means only that this record was not in it. The
426
+ // refusal never claims a peer failed to publish when the scan simply stopped.
427
+ function unresolvedReason(transport) {
428
+ if (!isObject(transport) || transport.status !== 'ok') {
429
+ const reason = isObject(transport) && typeof transport.reason === 'string' ? transport.reason : null;
430
+ if (reason === null || reason === 'missing') return 'Gentle Shell published no transport record for that session';
431
+ return `Gentle Shell's transport registry could not be read (${reason})`;
432
+ }
433
+ if (transport.truncated === true) return 'no matching transport record was in the bounded transport scan';
434
+ return 'Gentle Shell published no transport record for that session';
435
+ }
436
+
437
+ // The refusal wording. Both callers show it verbatim, so it names the next step
438
+ // for the human and states that nothing was mutated. A blocker whose raw id did
439
+ // not join says so instead of presenting a hash as an id.
440
+ function refusalText(verdict, targetPath, blockers, maxNamed, transport) {
441
+ if (verdict === 'current') {
442
+ return `cannot close the worktree at ${targetPath}: this session is working in that checkout; `
443
+ + 'move this session to another checkout first (run /warp, for example /warp principal), then close it; '
444
+ + 'the checkout and the registry were left untouched';
445
+ }
446
+ const unresolved = blockers.filter((blocker) => typeof blocker.rawSessionId !== 'string').length;
447
+ const unresolvedClause = unresolved === 0
448
+ ? ''
449
+ : `; ${unresolved === 1 ? unresolvedReason(transport) : 'no transport record joined those sessions'}, `
450
+ + `so ${unresolved === 1 ? 'its session id could not be resolved' : 'their session ids could not be resolved'}`
451
+ + ` (${unresolved === 1 ? 'its presence hash is' : 'their presence hashes are'} shown instead)`;
452
+ return `cannot close the worktree at ${targetPath}: the Pi session ${sessionNames(blockers, maxNamed)} is working in that checkout`
453
+ + unresolvedClause
454
+ + '; make sure it has finished there, then close the checkout, or run /warp in that session to move it away; '
455
+ + 'the checkout and the registry were left untouched';
456
+ }
457
+
458
+ // Decide, for ONE checkout, whether a live session is working in it.
459
+ //
460
+ // `presence` is the value `readGentlePresence` returned and `transport` the
461
+ // value `readTransportSessions` returned (both optional: a missing store is no
462
+ // evidence, never an invented one). `currentSessionId` is this session's id
463
+ // (hashed here for comparison with the published hash), and `currentCwd` is the
464
+ // checkout this session stands on — the deterministic fallback that works with
465
+ // no presence store at all. The evaluation itself is pure: same inputs, same
466
+ // verdict.
467
+ function evaluateCheckoutSessions(options = {}) {
468
+ const now = Number.isSafeInteger(options.now) ? options.now : Date.now();
469
+ const maxNamed = isCount(options.maxNamed) && options.maxNamed > 0 ? options.maxNamed : MAX_NAMED_SESSIONS;
470
+ const targetPath = typeof options.targetPath === 'string' ? options.targetPath : '';
471
+ const presence = isObject(options.presence) ? options.presence : { status: 'unavailable', entries: [] };
472
+ const transport = isObject(options.transport)
473
+ ? options.transport
474
+ : { status: 'unavailable', reason: 'missing', sessionIds: [], scanned: 0, rejected: 0, truncated: false };
475
+ const entries = Array.isArray(presence.entries) ? presence.entries : [];
476
+ const currentHash = typeof options.currentSessionId === 'string'
477
+ ? hashSessionId(options.currentSessionId)
478
+ : null;
479
+ // hash(sessionId) -> sessionId, from the published transport registry only.
480
+ const idsByHash = new Map();
481
+ for (const id of Array.isArray(transport.sessionIds) ? transport.sessionIds : []) {
482
+ const digest = typeof id === 'string' ? hashSessionId(id) : null;
483
+ if (digest !== null) idsByHash.set(digest, id);
484
+ }
485
+
486
+ const blockers = [];
487
+ const stale = [];
488
+ let liveWithoutWorkspace = 0;
489
+ for (const entry of entries) {
490
+ if (!isObject(entry) || typeof entry.sessionHash !== 'string') continue;
491
+ if (entry.recent === true && entry.unknown === true) liveWithoutWorkspace += 1;
492
+ if (typeof entry.workspace !== 'string' || !withinCheckout(entry.workspace, targetPath)) continue;
493
+ const age = Number.isSafeInteger(entry.heartbeat) && now >= entry.heartbeat
494
+ ? now - entry.heartbeat
495
+ : null;
496
+ const record = {
497
+ kind: currentHash !== null && entry.sessionHash === currentHash ? 'current' : 'foreign',
498
+ source: 'presence',
499
+ label: typeof entry.label === 'string' ? entry.label : '',
500
+ sessionHash: entry.sessionHash,
501
+ rawSessionId: idsByHash.get(entry.sessionHash) ?? null,
502
+ workspace: entry.workspace,
503
+ heartbeatAgeMs: age,
504
+ };
505
+ if (entry.recent === true) {
506
+ blockers.push(record);
507
+ } else {
508
+ stale.push(record);
509
+ }
510
+ }
511
+ // The session standing in the checkout is caught even when Gentle Shell is
512
+ // absent, has not published yet, or published a stale record.
513
+ if (blockers.every((blocker) => blocker.kind !== 'current')
514
+ && typeof options.currentCwd === 'string'
515
+ && options.currentCwd.length > 0
516
+ && withinCheckout(options.currentCwd, targetPath)) {
517
+ blockers.unshift({
518
+ kind: 'current',
519
+ source: 'cwd',
520
+ label: '',
521
+ sessionHash: currentHash,
522
+ rawSessionId: null,
523
+ workspace: options.currentCwd,
524
+ heartbeatAgeMs: 0,
525
+ });
526
+ }
527
+
528
+ const verdict = blockers.some((blocker) => blocker.kind === 'current')
529
+ ? 'current'
530
+ : (blockers.length > 0 ? 'foreign' : 'clear');
531
+ const unavailable = presence.status !== 'ok';
532
+ const unknown = unavailable ? 0 : entries.filter((entry) => isObject(entry) && entry.unknown === true).length;
533
+ const resolved = blockers.filter((blocker) => typeof blocker.rawSessionId === 'string').length;
534
+ const evidence = {
535
+ status: unavailable ? 'unavailable' : 'ok',
536
+ reason: typeof presence.reason === 'string' ? presence.reason : null,
537
+ scanned: isCount(presence.scanned) ? presence.scanned : 0,
538
+ rejected: isCount(presence.rejected) ? presence.rejected : 0,
539
+ truncated: presence.truncated === true,
540
+ matched: blockers.length + stale.length,
541
+ live: blockers.length,
542
+ stale: stale.length,
543
+ unknown,
544
+ resolved,
545
+ unresolved: blockers.length - resolved,
546
+ transport: {
547
+ status: transport.status === 'ok' ? 'ok' : 'unavailable',
548
+ reason: typeof transport.reason === 'string' ? transport.reason : null,
549
+ scanned: isCount(transport.scanned) ? transport.scanned : 0,
550
+ rejected: isCount(transport.rejected) ? transport.rejected : 0,
551
+ truncated: transport.truncated === true,
552
+ },
553
+ };
554
+ // Best-effort disclosure for the CLEAR path: every line states something the
555
+ // check could not prove. None of them blocks a removal — they travel to the
556
+ // human-readable close output instead (S7: bounded, stale and unknown
557
+ // evidence must be visible, never silently trusted or silently dropped).
558
+ const notices = [];
559
+ if (verdict === 'clear' && stale.length > 0) {
560
+ const files = stale.length === 1 ? 'record' : 'records';
561
+ notices.push(`session check: no live session found in the checkout (${stale.length} stale Gentle Shell presence ${files} ignored; best effort)`);
562
+ }
563
+ if (verdict === 'clear' && liveWithoutWorkspace > 0) {
564
+ const peers = liveWithoutWorkspace === 1 ? 'record' : 'records';
565
+ notices.push(`${liveWithoutWorkspace} live Gentle Shell presence ${peers} published no workspace, so the active-session check could not attribute ${liveWithoutWorkspace === 1 ? 'it' : 'them'} to any checkout (best effort)`);
566
+ }
567
+ if (verdict === 'clear' && evidence.truncated) {
568
+ notices.push(`the Gentle Shell presence scan read only its first ${evidence.scanned} records, so the active-session check is partial (best effort)`);
569
+ }
570
+ if (verdict === 'clear' && evidence.transport.truncated) {
571
+ notices.push('Gentle Shell\'s transport registry exceeded its bounded scan, so some session ids could not be resolved (best effort)');
572
+ }
573
+ if (verdict === 'clear' && unavailable && evidence.reason !== null && evidence.reason !== 'missing') {
574
+ notices.push(`session check: Gentle Shell presence could not be read (${evidence.reason}); the active-session check is best effort`);
575
+ }
576
+ return {
577
+ verdict,
578
+ blockers,
579
+ stale,
580
+ evidence,
581
+ notices,
582
+ notice: notices.length > 0 ? notices.join(' ') : null,
583
+ text: verdict === 'clear' ? null : refusalText(verdict, targetPath, blockers, maxNamed, transport),
584
+ };
585
+ }
586
+
587
+ module.exports = {
588
+ PRESENCE_FRESH_MS,
589
+ presenceDirectoryFor,
590
+ transportDirectoryFor,
591
+ hashSessionId,
592
+ readGentlePresence,
593
+ readTransportSessions,
594
+ evaluateCheckoutSessions,
595
+ };