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.
- package/README.md +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- 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
|
+
};
|