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,566 @@
1
+ 'use strict';
2
+ // Per-checkout warp sessions (customize-command S17/T11).
3
+ //
4
+ // At most ONE forked session per checkout: the first warp to a checkout
5
+ // forks and records the session file here, keyed by the checkout's
6
+ // realpath; later warps to the same checkout resume that same file with
7
+ // a plain `switchSession` instead of forking again. The principal
8
+ // checkout is keyed exactly like every other checkout.
9
+ //
10
+ // The file lives at <git-common-dir>/pi-warp-sessions.json: beside the
11
+ // pi-worktrees.json registry, never inside it (the registry shape is a
12
+ // consumed contract) and never in the plugin state dir. Values carry
13
+ // {sessionFile, leafId, entryCount, updatedAt}; nothing secret is ever stored and no
14
+ // path or file content is echoed in errors.
15
+ //
16
+ // Reads tolerate damage: a missing file reads as empty, and a malformed
17
+ // file is treated as empty (never thrown), so a corrupt record fails
18
+ // safe by forking again and overwriting. Writes are atomic (a sibling
19
+ // temp file created exclusively with an unpredictable suffix plus
20
+ // rename, retrying a new name a bounded number of times) and refuse a
21
+ // symlinked or non-regular destination instead of following it. A failed
22
+ // record write never fails the warp: recording is best-effort, the warp
23
+ // itself already switched. Concurrent warps may each fork with the last
24
+ // writer winning; that orphans at most one extra session file and never
25
+ // corrupts the record.
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const crypto = require('node:crypto');
30
+ const { commonDirFor } = require('./worktree-git.js');
31
+
32
+ const WARP_SESSIONS_VERSION = 1;
33
+ const WARP_SESSIONS_NAME = 'pi-warp-sessions.json';
34
+
35
+ function warpSessionsPath(repoRoot) {
36
+ // S22 (round 3): the shared memoized resolver — this file is read on warp
37
+ // preflight, where two spawnSync identity probes were pure overhead.
38
+ return path.join(commonDirFor(repoRoot), WARP_SESSIONS_NAME);
39
+ }
40
+
41
+ function emptySessions() {
42
+ return { ok: true, version: WARP_SESSIONS_VERSION, sessions: {}, error: null };
43
+ }
44
+
45
+ function isRecordShape(entry) {
46
+ return typeof entry === 'object' && entry !== null && !Array.isArray(entry)
47
+ && typeof entry.sessionFile === 'string' && entry.sessionFile.length > 0;
48
+ }
49
+
50
+ // S23/S43 filtered session identity: the POINT of a session is its ACTIVE
51
+ // branch (the getBranch(leaf) path, never all append-only entries)
52
+ // filtered to entries that are NOT our own bookkeeping. Our shapes are
53
+ // exactly these four, proven against the installed SessionManager
54
+ // (dist/core/session-manager.js) and the live warp flow:
55
+ // - `custom` + `worktree-warp`: the transition record the fork appends
56
+ // through appendCustomEntry;
57
+ // - `custom_message` + `worktree-warp`: the visible confirmation the
58
+ // switched-to session sends through sendMessage({customType}), which
59
+ // the runtime persists through appendCustomMessageEntry — the shape
60
+ // the old filter missed, so every warp back forked again;
61
+ // - `custom` + `gentle-pi.session-worktree/v1`: the registration entry
62
+ // the session_worktree_register host tool appends at startup;
63
+ // - every `session_info`: the checkout rename (appendSessionInfo).
64
+ // Anything else — user/assistant/tool entries, model/thinking changes,
65
+ // unknown custom shapes — is session content and COUNTS: no broad
66
+ // exclusion, nothing silently ignored. Because SessionManager.forkFrom
67
+ // copies source entries VERBATIM with their ids, a fresh fork's filtered
68
+ // point equals its source's: the fork is identical. {tipId, count} where
69
+ // tipId is the last remaining entry's id (null when no real entry exists)
70
+ // and count is the number of remaining entries.
71
+ const WARP_BOOKKEEPING_CUSTOM_TYPE = 'worktree-warp';
72
+ const SESSION_WORKTREE_CUSTOM_TYPE = 'gentle-pi.session-worktree/v1';
73
+
74
+ function isWarpBookkeepingEntry(entry) {
75
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
76
+ if (entry.type === 'session_info') return true;
77
+ if (entry.type === 'custom' && (entry.customType === WARP_BOOKKEEPING_CUSTOM_TYPE
78
+ || entry.customType === SESSION_WORKTREE_CUSTOM_TYPE)) return true;
79
+ return entry.type === 'custom_message' && entry.customType === WARP_BOOKKEEPING_CUSTOM_TYPE;
80
+ }
81
+
82
+ // The leaf of an entry list under installed SessionManager semantics:
83
+ // _buildIndex walks the file in order and keeps the LAST non-session
84
+ // entry's id (upstream packages/coding-agent/src/core/session-manager.ts
85
+ // _buildIndex; installed dist/core/session-manager.js _buildIndex). No
86
+ // separate leaf pointer is persisted, so the last line IS the leaf.
87
+ // Returns the id string, or null when there is no such entry.
88
+ function leafIdOfEntries(entries) {
89
+ try {
90
+ const list = Array.isArray(entries) ? entries : [];
91
+ for (let index = list.length - 1; index >= 0; index -= 1) {
92
+ const entry = list[index];
93
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)
94
+ && entry.type !== 'session' && typeof entry.id === 'string') {
95
+ return entry.id;
96
+ }
97
+ }
98
+ return null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // The ACTIVE branch of an entry list: walk parentId from the leaf back to
105
+ // the root, like SessionManager.getBranch does. `leafId` selects the
106
+ // start; an unknown or missing leaf falls back to the last entry, like
107
+ // the manager's own `leaf ??= entries[entries.length - 1]` rule in
108
+ // buildSessionPath. Off-branch entries (an abandoned leg after branch())
109
+ // are NOT on this path. Cycles and missing parents stop the walk
110
+ // conservatively instead of throwing. Returns the path in root-to-leaf
111
+ // order (possibly empty).
112
+ function activeBranch(entries, leafId) {
113
+ try {
114
+ const list = Array.isArray(entries) ? entries : [];
115
+ const byId = new Map();
116
+ for (const entry of list) {
117
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)
118
+ && typeof entry.id === 'string') {
119
+ byId.set(entry.id, entry);
120
+ }
121
+ }
122
+ let start = null;
123
+ if (typeof leafId === 'string' && byId.has(leafId)) start = byId.get(leafId);
124
+ else {
125
+ const fallback = leafIdOfEntries(list);
126
+ if (fallback !== null) start = byId.get(fallback) || null;
127
+ }
128
+ if (!start) return [];
129
+ const path = [];
130
+ const visited = new Set();
131
+ let current = start;
132
+ while (current && typeof current.id === 'string' && !visited.has(current.id)) {
133
+ visited.add(current.id);
134
+ path.push(current);
135
+ const parentId = current.parentId;
136
+ if (typeof parentId !== 'string' || parentId.length === 0) break;
137
+ const parent = byId.get(parentId);
138
+ if (!parent) break;
139
+ current = parent;
140
+ }
141
+ path.reverse();
142
+ return path;
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ function sessionPoint(entries, leafId) {
149
+ const branch = activeBranch(entries, leafId);
150
+ let tipId = null;
151
+ let count = 0;
152
+ for (const entry of branch) {
153
+ if (isWarpBookkeepingEntry(entry)) continue;
154
+ count += 1;
155
+ if (entry && typeof entry.id === 'string') tipId = entry.id;
156
+ else tipId = null;
157
+ }
158
+ return { tipId, count };
159
+ }
160
+
161
+ // Point evidence for the R3 fork-vs-resume rule: the source session's
162
+ // FILTERED position (S23 sessionPoint) when the record was written. Older
163
+ // records carry {leafId, entryCount} from the raw-leaf comparison; they
164
+ // read through the same shape, so a record written before the fix still
165
+ // resolves. Both names are stored on write for the same reason.
166
+ function asPoint(value) {
167
+ const tipId = value && typeof value.tipId === 'string' ? value.tipId
168
+ : value && typeof value.leafId === 'string' ? value.leafId : null;
169
+ const rawCount = value && typeof value.count === 'number' ? value.count
170
+ : value && typeof value.entryCount === 'number' ? value.entryCount : null;
171
+ const count = rawCount !== null && Number.isFinite(rawCount) && rawCount >= 0
172
+ ? Math.floor(rawCount) : null;
173
+ return { tipId, count, leafId: tipId, entryCount: count };
174
+ }
175
+
176
+ // True when two points name the same filtered position. An unreadable
177
+ // count on either side leaves the tip to decide, so legacy records
178
+ // without a count keep their resume behaviour.
179
+ function samePoint(a, b) {
180
+ try {
181
+ const first = asPoint(a);
182
+ const second = asPoint(b);
183
+ if (first.tipId !== second.tipId) return false;
184
+ if (first.count === null || second.count === null) return true;
185
+ return first.count === second.count;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ // S43 parent priority: every session header carries `parentSession` (a
192
+ // session-file path), so a fork can climb to the HIGHEST ancestor whose
193
+ // filtered point still equals its own and fork from there — an untouched
194
+ // fork then yields a SIBLING (both children of the original) while an
195
+ // advanced session yields its own child. The climb is bounded by a small
196
+ // documented constant and fails safe to the LAST GOOD file: a missing or
197
+ // empty parent link, an unreadable ancestor, a point mismatch, a cycle
198
+ // (visited set) or the depth cap all stop the climb without throwing.
199
+ const WARP_ANCESTOR_MAX_DEPTH = 25;
200
+
201
+ // Read a session file from disk into {entries, parentSession}. The first
202
+ // line is the header (carrying `parentSession`, like the live reader in
203
+ // extensions/gentle-herdr-state.ts parses it); the remaining lines are
204
+ // the entries. Any unreadable file or unparseable line reads as null:
205
+ // the climb then keeps the last good file. Never throws.
206
+ function readSessionFileNode(sessionFile) {
207
+ try {
208
+ if (typeof sessionFile !== 'string' || sessionFile.length === 0) return null;
209
+ let raw;
210
+ try {
211
+ raw = fs.readFileSync(sessionFile, 'utf8');
212
+ } catch {
213
+ return null;
214
+ }
215
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
216
+ const lines = raw.split('\n').filter((line) => line.length > 0);
217
+ if (lines.length === 0) return { entries: [], parentSession: null };
218
+ let header = null;
219
+ try {
220
+ header = JSON.parse(lines[0]);
221
+ } catch {
222
+ return null;
223
+ }
224
+ const rawParent = header && typeof header === 'object'
225
+ ? (header.parentSession !== undefined ? header.parentSession : header.parentSessionId)
226
+ : null;
227
+ const parentSession = typeof rawParent === 'string' && rawParent.length > 0 ? rawParent : null;
228
+ const firstEntry = header && typeof header === 'object' && header.type === 'session' ? 1 : 0;
229
+ const entries = [];
230
+ for (let index = firstEntry; index < lines.length; index += 1) {
231
+ try {
232
+ entries.push(JSON.parse(lines[index]));
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ return { entries, parentSession };
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ // Normalize what an injected `open` returns: either the entries array
244
+ // itself or {entries, parentSession} shaped like the real format. Anything
245
+ // else reads as unreadable. Never throws.
246
+ function normalizeOpenResult(value) {
247
+ try {
248
+ if (Array.isArray(value)) return { entries: value, parentSession: null };
249
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
250
+ const entries = Array.isArray(value.entries) ? value.entries : null;
251
+ const rawParent = value.parentSession !== undefined ? value.parentSession : value.parent;
252
+ const parentSession = typeof rawParent === 'string' && rawParent.length > 0 ? rawParent : null;
253
+ return { entries, parentSession };
254
+ }
255
+ return { entries: null, parentSession: null };
256
+ } catch {
257
+ return { entries: null, parentSession: null };
258
+ }
259
+ }
260
+
261
+ // Read one session node through the injectable `open` seam when it knows
262
+ // the file, else through the file itself. The seam models the live
263
+ // runtime's view of a session (used by tests and by the warp's ancestor
264
+ // climb); when it yields nothing for a file, the file's own lines are the
265
+ // actual destination state (the live runtime reopens the manager and
266
+ // keeps appending there), so they are read instead of inventing an empty
267
+ // session. A file that is unknown to both reads as unreadable (entries
268
+ // null): callers fail safe to the last good value. Never throws.
269
+ function readSessionNode(sessionFile, open) {
270
+ if (typeof open === 'function') {
271
+ let value = null;
272
+ try {
273
+ value = open(sessionFile);
274
+ } catch {
275
+ value = null;
276
+ }
277
+ const shaped = normalizeOpenResult(value);
278
+ if (shaped && Array.isArray(shaped.entries)) return shaped;
279
+ }
280
+ return readSessionFileNode(sessionFile);
281
+ }
282
+
283
+ // The filtered {tipId, count} point of a session FILE, or null when the
284
+ // file cannot be read. `open` is the injectable seam (a function of the
285
+ // file path, for tests); without it the file is read from disk. Never
286
+ // throws.
287
+ function pointOfSessionFile(sessionFile, { open } = {}) {
288
+ try {
289
+ const node = readSessionNode(sessionFile, open);
290
+ if (!node || !Array.isArray(node.entries)) return null;
291
+ return sessionPoint(node.entries);
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ // The highest ancestor of a session file whose filtered point still
298
+ // equals `point` — the fork source under S43. Starts at the file itself
299
+ // (returned as-is when it has no readable identical parent) and climbs
300
+ // the `parentSession` chain, keeping the last good file at every stop:
301
+ // missing/empty/unreadable parent, point mismatch, cycle or depth cap.
302
+ // `open` is the same injectable seam as pointOfSessionFile; `maxDepth`
303
+ // overrides the bound (the default is WARP_ANCESTOR_MAX_DEPTH). Never
304
+ // throws: any failure returns the last good file.
305
+ function topmostIdenticalAncestor(sessionFile, point, { open, maxDepth } = {}) {
306
+ try {
307
+ if (typeof sessionFile !== 'string' || sessionFile.length === 0) return sessionFile;
308
+ const cap = Number.isFinite(maxDepth) && maxDepth >= 0 ? Math.floor(maxDepth) : WARP_ANCESTOR_MAX_DEPTH;
309
+ const me = asPoint(point);
310
+ let best = sessionFile;
311
+ let current = sessionFile;
312
+ const visited = new Set([sessionFile]);
313
+ for (let depth = 0; depth < cap; depth += 1) {
314
+ let parent = null;
315
+ try {
316
+ const node = readSessionNode(current, open);
317
+ parent = node ? node.parentSession : null;
318
+ } catch {
319
+ parent = null;
320
+ }
321
+ if (typeof parent !== 'string' || parent.length === 0) break;
322
+ if (visited.has(parent)) break;
323
+ visited.add(parent);
324
+ const parentPoint = pointOfSessionFile(parent, open === undefined ? {} : { open });
325
+ if (parentPoint === null) break;
326
+ if (!samePoint(me, parentPoint)) break;
327
+ best = parent;
328
+ current = parent;
329
+ }
330
+ return best;
331
+ } catch {
332
+ return sessionFile;
333
+ }
334
+ }
335
+
336
+ // The canonical key for a checkout: its realpath, falling back to the
337
+ // resolved absolute path when the filesystem cannot resolve it. A
338
+ // non-absolute input (or anything unresolvable to absolute) keys nothing.
339
+ function normalizeKey(checkoutPath) {
340
+ try {
341
+ if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) return null;
342
+ try {
343
+ return fs.realpathSync(checkoutPath);
344
+ } catch {
345
+ // Unresolvable (a checkout removed between listing and lookup):
346
+ // the absolute spelling still keys the record lookup below.
347
+ }
348
+ if (path.isAbsolute(checkoutPath)) return path.resolve(checkoutPath);
349
+ return null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ // Read the record file. Missing reads as empty; malformed content is
356
+ // treated as empty too (never thrown, never reported as an error):
357
+ // the warp then forks again and overwrites. Never throws.
358
+ function readWarpSessions(repoRoot) {
359
+ try {
360
+ let file;
361
+ try {
362
+ file = warpSessionsPath(repoRoot);
363
+ } catch {
364
+ return emptySessions();
365
+ }
366
+ let raw;
367
+ try {
368
+ raw = fs.readFileSync(file, 'utf8');
369
+ } catch (err) {
370
+ if (err && err.code === 'ENOENT') return emptySessions();
371
+ return emptySessions();
372
+ }
373
+ let doc;
374
+ try {
375
+ doc = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
376
+ } catch {
377
+ return emptySessions();
378
+ }
379
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)
380
+ || typeof doc.sessions !== 'object' || doc.sessions === null || Array.isArray(doc.sessions)) {
381
+ return emptySessions();
382
+ }
383
+ const sessions = {};
384
+ for (const [key, entry] of Object.entries(doc.sessions)) {
385
+ if (isRecordShape(entry)) {
386
+ const point = asPoint(entry);
387
+ sessions[key] = {
388
+ sessionFile: entry.sessionFile,
389
+ tipId: point.tipId,
390
+ count: point.count,
391
+ leafId: point.leafId,
392
+ entryCount: point.entryCount,
393
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : null,
394
+ };
395
+ }
396
+ }
397
+ const version = doc.version === undefined ? WARP_SESSIONS_VERSION : doc.version;
398
+ return { ok: true, version, sessions, error: null };
399
+ } catch {
400
+ return emptySessions();
401
+ }
402
+ }
403
+
404
+ // The recorded session for a checkout, or null when there is none, the
405
+ // record is corrupt, or the entry names no session file. Never throws:
406
+ // any failure reads as "no record", so the warp forks again.
407
+ function getWarpSession(repoRoot, checkoutPath) {
408
+ try {
409
+ const read = readWarpSessions(repoRoot);
410
+ if (!read.ok) return null;
411
+ const candidates = [];
412
+ try {
413
+ candidates.push(fs.realpathSync(checkoutPath));
414
+ } catch {
415
+ // Fall through to the absolute spelling below.
416
+ }
417
+ if (typeof checkoutPath === 'string' && path.isAbsolute(checkoutPath)) {
418
+ try {
419
+ candidates.push(path.resolve(checkoutPath));
420
+ } catch {
421
+ // No candidate: returns null below.
422
+ }
423
+ }
424
+ for (const key of candidates) {
425
+ const entry = read.sessions[key];
426
+ if (isRecordShape(entry)) {
427
+ const point = asPoint(entry);
428
+ return {
429
+ sessionFile: entry.sessionFile,
430
+ tipId: point.tipId,
431
+ count: point.count,
432
+ leafId: point.leafId,
433
+ entryCount: point.entryCount,
434
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : null,
435
+ };
436
+ }
437
+ }
438
+ return null;
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ // Atomically replace the record file: write a sibling temp file
445
+ // exclusively (`wx`) with an unpredictable suffix, then rename over the
446
+ // target. A pre-existing name is never followed or reused: on collision
447
+ // a new name is retried a bounded number of times, then the write is
448
+ // refused with a clear error. The temp file never survives the call.
449
+ function writeWarpSessionsAtomic(file, doc) {
450
+ const payload = `${JSON.stringify(doc, null, 2)}\n`;
451
+ let tmp = null;
452
+ let wrote = false;
453
+ for (let attempt = 0; attempt < 10; attempt += 1) {
454
+ let suffix;
455
+ try {
456
+ suffix = crypto.randomBytes(8).toString('hex');
457
+ } catch (err) {
458
+ throw new Error(`cannot write warp sessions: cannot generate a temporary file name: ${(err && err.message) || err}`);
459
+ }
460
+ tmp = `${file}.tmp.${process.pid}.${Date.now()}.${suffix}`;
461
+ try {
462
+ fs.writeFileSync(tmp, payload, { flag: 'wx' });
463
+ wrote = true;
464
+ break;
465
+ } catch (err) {
466
+ const failedTmp = tmp;
467
+ tmp = null;
468
+ if (err && err.code === 'EEXIST') continue;
469
+ if (failedTmp) {
470
+ try {
471
+ fs.unlinkSync(failedTmp);
472
+ } catch {
473
+ // Best effort: the rethrow below is what matters.
474
+ }
475
+ }
476
+ throw err;
477
+ }
478
+ }
479
+ if (!wrote || !tmp) {
480
+ throw new Error('cannot write warp sessions: could not create a unique temporary file after 10 attempts; refusing to follow or reuse an existing path');
481
+ }
482
+ try {
483
+ fs.renameSync(tmp, file);
484
+ } finally {
485
+ try {
486
+ fs.unlinkSync(tmp);
487
+ } catch {
488
+ // Either renamed (gone) or never created; nothing to clean.
489
+ }
490
+ }
491
+ }
492
+
493
+ // Record (or overwrite) the session for a checkout. Never throws: every
494
+ // failure returns {ok: false} with a generic error naming no path, and
495
+ // the warp that already switched stays successful.
496
+ function recordWarpSession(repoRoot, checkoutPath, record) {
497
+ try {
498
+ const key = normalizeKey(checkoutPath);
499
+ const sessionFile = record && typeof record.sessionFile === 'string' ? record.sessionFile : null;
500
+ if (key === null || sessionFile === null || sessionFile.length === 0) {
501
+ return { ok: false, error: 'cannot record warp session: checkout or session file is missing' };
502
+ }
503
+ const point = asPoint(record);
504
+ const leafId = point.tipId;
505
+ const entryCount = point.count;
506
+ let file;
507
+ try {
508
+ file = warpSessionsPath(repoRoot);
509
+ } catch {
510
+ return { ok: false, error: 'cannot record warp session: repository identity unavailable' };
511
+ }
512
+ // Inspect the destination BEFORE writing: writeFileSync follows a
513
+ // symlink, so a symlinked record file would overwrite its target. A
514
+ // symlinked or non-regular destination is refused and nothing is
515
+ // touched; the warp itself already succeeded.
516
+ let stat = null;
517
+ try {
518
+ stat = fs.lstatSync(file);
519
+ } catch (err) {
520
+ if (!err || err.code !== 'ENOENT') {
521
+ return { ok: false, error: 'cannot record warp session: destination unreadable' };
522
+ }
523
+ }
524
+ if (stat) {
525
+ if (stat.isSymbolicLink()) {
526
+ return { ok: false, error: 'cannot record warp session: destination is a symlink; remove it first' };
527
+ }
528
+ if (!stat.isFile()) {
529
+ return { ok: false, error: 'cannot record warp session: destination is not a regular file' };
530
+ }
531
+ }
532
+ // Read-modify-write against the tolerant read: a malformed file is
533
+ // replaced, never merged — the incoming record is the only
534
+ // trustworthy data.
535
+ const read = readWarpSessions(repoRoot);
536
+ const sessions = read.ok ? { ...read.sessions } : {};
537
+ sessions[key] = {
538
+ sessionFile, tipId: leafId, count: entryCount,
539
+ leafId, entryCount, updatedAt: new Date().toISOString(),
540
+ };
541
+ writeWarpSessionsAtomic(file, { version: WARP_SESSIONS_VERSION, sessions });
542
+ return { ok: true, entry: sessions[key] };
543
+ } catch (err) {
544
+ return { ok: false, error: `cannot record warp session: ${(err && err.message) || err}` };
545
+ }
546
+ }
547
+
548
+ module.exports = {
549
+ WARP_SESSIONS_VERSION,
550
+ WARP_SESSIONS_NAME,
551
+ WARP_BOOKKEEPING_CUSTOM_TYPE,
552
+ SESSION_WORKTREE_CUSTOM_TYPE,
553
+ WARP_ANCESTOR_MAX_DEPTH,
554
+ leafIdOfEntries,
555
+ activeBranch,
556
+ warpSessionsPath,
557
+ readWarpSessions,
558
+ getWarpSession,
559
+ recordWarpSession,
560
+ isWarpBookkeepingEntry,
561
+ sessionPoint,
562
+ samePoint,
563
+ asPoint,
564
+ pointOfSessionFile,
565
+ topmostIdenticalAncestor,
566
+ };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "pi-odd-worktree",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Pi package for odd-worktree: the Agent Worktree tools (create, list, close, warp), the /warp worktree panel, the /odd-list ODD viewer, and the /issues GitHub issue reader. Works in Pi alone; Herdr is optional.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "worktree",
9
+ "odd"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/jonasotoaguilar/odd-worktree.git"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-ai": "*",
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "files": [
25
+ "extensions",
26
+ "lib",
27
+ "tools",
28
+ "README.md"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/odd-worktree-tools.ts"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "test": "node --test tools/*.test.js"
37
+ }
38
+ }