pi-odd-worktree 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ 'use strict';
2
+ // Session-scoped CodeGraph freshness for the worktree tools (S1-S6/S8/S9).
3
+ //
4
+ // Two questions, one gate. `hasCodegraph` answers "is the CLI present" once per
5
+ // process and caches it (S3), with `CODEGRAPH_FORCE=1` as the only escape (S4).
6
+ // `resolveProjectRoot` answers "which checkout does this file belong to" from
7
+ // `git -C <dir> rev-parse --show-toplevel`, cached per directory (S1).
8
+ //
9
+ // `scheduleSync` is the fire-and-forget entry the extension calls from
10
+ // `tool_result`. It never throws and never blocks: the decision object is the
11
+ // only synchronous return, and the sync itself is detached, unref'd and never
12
+ // awaited (S1). Per resolved root there is ONE debounce timer and one
13
+ // `{running, pending}` pair, so a burst of edits collapses and an edit during a
14
+ // run starts exactly ONE more afterwards (S2). Across processes an exclusive
15
+ // `<root>/.codegraph/pi-sync.lock` guards the run: a held foreign lock is
16
+ // skipped (never queued) with one bounded deferred retry, and a lock whose pid
17
+ // is dead or whose age passes the TTL is taken over (S2/S8). A root without
18
+ // `.codegraph/` is skipped (S6) and a root whose CodeGraph daemon is alive is
19
+ // skipped too, because that daemon's watcher already syncs the tree (S5). The
20
+ // process-wide switch `/codegraph-hook` flips is `setHookEnabled` below: off
21
+ // answers `hook-off` before any probe, read or lock, so nothing is spawned (S9).
22
+ //
23
+ // Every external effect is injectable through `deps` — `execFile`, `spawn`,
24
+ // `exists`, `readFile`, `now`, `setTimeout`/`clearTimeout`, `isAlive`,
25
+ // `binaryExists` — so tests never start a real child process or touch the
26
+ // network.
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const { spawn, execFileSync } = require('node:child_process');
31
+
32
+ const DEBOUNCE_MS = 2000;
33
+ const RETRY_DELAY_MS = 5000;
34
+ const LOCK_TTL_MS = 5 * 60 * 1000;
35
+ const RUN_TIMEOUT_MS = 2 * 60 * 1000;
36
+ const GIT_TIMEOUT_MS = 10000;
37
+ const LOCK_FILE = 'pi-sync.lock';
38
+ const DAEMON_PID_FILE = 'daemon.pid';
39
+ const CODEGRAPH_BIN = 'codegraph';
40
+
41
+ // One answer per process (S3): never re-probe, never re-read PATH.
42
+ let gateAnswer = null;
43
+ // The process-wide sync switch (S9). `null` follows the environment: enabled
44
+ // unless `CODEGRAPH_HOOK=0`, which is the persistent way to start it disabled.
45
+ let hookEnabled = null;
46
+ // One cache per directory (S1), including the null answers.
47
+ const rootCache = new Map();
48
+ // One scheduler state per resolved root (S2).
49
+ const syncStates = new Map();
50
+ let idleWaiters = [];
51
+
52
+ // --- injectable defaults -------------------------------------------------
53
+
54
+ function defaultBinaryExists(file) {
55
+ try {
56
+ fs.accessSync(file, fs.constants.X_OK);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function defaultExecFile(bin, args, opts = {}) {
64
+ return execFileSync(bin, args, {
65
+ encoding: 'utf8',
66
+ timeout: opts.timeoutMs,
67
+ windowsHide: true,
68
+ });
69
+ }
70
+
71
+ function defaultSpawn(bin, args, opts) {
72
+ return spawn(bin, args, opts);
73
+ }
74
+
75
+ function defaultReadFile(file) {
76
+ return fs.readFileSync(file, 'utf8');
77
+ }
78
+
79
+ function defaultIsAlive(pid) {
80
+ if (!Number.isInteger(pid) || pid <= 0) return false;
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true;
84
+ } catch (err) {
85
+ // A pid owned by someone else is alive but not signalable.
86
+ return Boolean(err && err.code === 'EPERM');
87
+ }
88
+ }
89
+
90
+ function timers(deps) {
91
+ return {
92
+ setTimeout: typeof deps.setTimeout === 'function' ? deps.setTimeout : setTimeout,
93
+ clearTimeout: typeof deps.clearTimeout === 'function' ? deps.clearTimeout : clearTimeout,
94
+ };
95
+ }
96
+
97
+ function pick(deps, name, fallback) {
98
+ return typeof deps[name] === 'function' ? deps[name] : fallback;
99
+ }
100
+
101
+ // --- session gate (S3/S4) ------------------------------------------------
102
+
103
+ function probeOnPath(bin, env, deps) {
104
+ const exists = pick(deps, 'binaryExists', defaultBinaryExists);
105
+ const rawPath = (env && (env.PATH || env.Path)) || '';
106
+ for (const dir of String(rawPath).split(path.delimiter)) {
107
+ if (dir.length === 0) continue;
108
+ if (exists(path.join(dir, bin))) return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ // The session gate. `CODEGRAPH_FORCE=1` answers present without probing, so a
114
+ // CLI installed mid-session does not need a restart; every other answer is
115
+ // probed once and then frozen for the life of the process.
116
+ function hasCodegraph(env, deps = {}) {
117
+ const effectiveEnv = env || process.env;
118
+ if (effectiveEnv.CODEGRAPH_FORCE === '1') return true;
119
+ if (gateAnswer === null) gateAnswer = probeOnPath(CODEGRAPH_BIN, effectiveEnv, deps || {});
120
+ return gateAnswer;
121
+ }
122
+
123
+ function resetCodegraphGate() {
124
+ gateAnswer = null;
125
+ }
126
+
127
+ // --- sync hook switch (S9) ----------------------------------------------
128
+
129
+ // The switch is process-wide and in memory only: nothing is written to user
130
+ // configuration. `on`/`off` from the command set it explicitly; no argument
131
+ // toggles whatever it currently reads.
132
+ function isHookEnabled(env) {
133
+ if (hookEnabled !== null) return hookEnabled;
134
+ return (env || process.env).CODEGRAPH_HOOK !== '0';
135
+ }
136
+
137
+ function setHookEnabled(enabled) {
138
+ hookEnabled = enabled !== false;
139
+ }
140
+
141
+ // --- project root (S1) --------------------------------------------------
142
+
143
+ function resolveProjectRoot(dir, deps = {}) {
144
+ if (typeof dir !== 'string' || dir.length === 0) return null;
145
+ const key = path.resolve(dir);
146
+ if (rootCache.has(key)) return rootCache.get(key);
147
+ const execFile = pick(deps, 'execFile', defaultExecFile);
148
+ let root = null;
149
+ try {
150
+ const out = execFile('git', ['-C', key, 'rev-parse', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS });
151
+ const text = typeof out === 'string' ? out : out && typeof out.stdout === 'string' ? out.stdout : '';
152
+ const trimmed = text.trim();
153
+ if (trimmed.length > 0) root = trimmed;
154
+ } catch {
155
+ root = null;
156
+ }
157
+ rootCache.set(key, root);
158
+ return root;
159
+ }
160
+
161
+ // --- lock (S2/S8) -------------------------------------------------------
162
+
163
+ function writeLock(lockPath, pid, now) {
164
+ let fd;
165
+ try {
166
+ fd = fs.openSync(lockPath, 'wx');
167
+ } catch (err) {
168
+ if (err && err.code === 'EEXIST') return false;
169
+ throw err;
170
+ }
171
+ try {
172
+ fs.writeSync(fd, JSON.stringify({ pid, startedAt: now }));
173
+ } finally {
174
+ fs.closeSync(fd);
175
+ }
176
+ return true;
177
+ }
178
+
179
+ // `{ok:true}` when this process owns the lock; `{ok:false}` when a live foreign
180
+ // lock holds it. A lock whose content is unreadable, whose pid is dead or whose
181
+ // age passes the TTL is stale and is taken over. Two processes racing on the
182
+ // takeover cannot both win: the second `wx` create loses.
183
+ function acquireLock(lockPath, deps) {
184
+ const now = typeof deps.now === 'function' ? deps.now() : Date.now();
185
+ const pid = Number.isInteger(deps.pid) ? deps.pid : process.pid;
186
+ const ttl = typeof deps.lockTtlMs === 'number' ? deps.lockTtlMs : LOCK_TTL_MS;
187
+ const readFile = pick(deps, 'readFile', defaultReadFile);
188
+ const isAlive = pick(deps, 'isAlive', defaultIsAlive);
189
+
190
+ if (writeLock(lockPath, pid, now)) return { ok: true };
191
+
192
+ let held = null;
193
+ try {
194
+ held = JSON.parse(readFile(lockPath));
195
+ } catch {
196
+ held = null;
197
+ }
198
+ const heldPid = held && Number.parseInt(held.pid, 10);
199
+ const heldAt = held && Number(held.startedAt);
200
+ const stale = !held
201
+ || !Number.isInteger(heldPid) || heldPid <= 0
202
+ || !Number.isFinite(heldAt) || (now - heldAt) > ttl
203
+ || !isAlive(heldPid);
204
+ if (!stale) return { ok: false };
205
+
206
+ try {
207
+ fs.unlinkSync(lockPath);
208
+ } catch {
209
+ // Another process already took the stale lock over; the create below
210
+ // decides who wins.
211
+ }
212
+ return writeLock(lockPath, pid, now) ? { ok: true } : { ok: false };
213
+ }
214
+
215
+ function releaseLock(lockPath) {
216
+ try {
217
+ fs.unlinkSync(lockPath);
218
+ } catch {
219
+ // Already gone (a takeover) or never created: nothing to release.
220
+ }
221
+ }
222
+
223
+ // --- one background run -------------------------------------------------
224
+
225
+ // Resolves when the child is launched AND finished (its `close`/`error`), so the
226
+ // in-process `running` flag covers the whole sync while the caller never waits
227
+ // for it. The lock is held for the same span: a parallel Pi session skips
228
+ // instead of racing another sync on the same checkout.
229
+ function runSync(root, deps) {
230
+ const lockPath = path.join(root, '.codegraph', LOCK_FILE);
231
+ let lock;
232
+ try {
233
+ lock = acquireLock(lockPath, deps);
234
+ } catch {
235
+ return Promise.resolve('lock-error');
236
+ }
237
+ if (!lock.ok) return Promise.resolve('locked');
238
+
239
+ return new Promise((resolve) => {
240
+ let child;
241
+ try {
242
+ child = pick(deps, 'spawn', defaultSpawn)(CODEGRAPH_BIN, ['sync', root], {
243
+ cwd: root,
244
+ detached: true,
245
+ stdio: 'ignore',
246
+ env: deps.env || process.env,
247
+ });
248
+ } catch {
249
+ releaseLock(lockPath);
250
+ resolve('spawn-failed');
251
+ return;
252
+ }
253
+ if (child && typeof child.unref === 'function') {
254
+ try {
255
+ child.unref();
256
+ } catch {
257
+ // An unref failure never changes the outcome.
258
+ }
259
+ }
260
+ let settled = false;
261
+ const timeoutMs = typeof deps.runTimeoutMs === 'number' ? deps.runTimeoutMs : RUN_TIMEOUT_MS;
262
+ let watchdog = null;
263
+ const done = () => {
264
+ if (settled) return;
265
+ settled = true;
266
+ if (watchdog !== null) clearTimeout(watchdog);
267
+ releaseLock(lockPath);
268
+ resolve('spawned');
269
+ };
270
+ // A sync that hangs keeps the lock and the root's `running` flag, which would
271
+ // wedge that checkout for the rest of the session. Bound it: one SIGTERM, then
272
+ // settle regardless, so the state and the lock come back.
273
+ const t = timers(deps);
274
+ watchdog = t.setTimeout(() => {
275
+ watchdog = null;
276
+ if (child && typeof child.kill === 'function') {
277
+ try { child.kill('SIGTERM'); } catch { /* best effort */ }
278
+ }
279
+ done();
280
+ }, timeoutMs);
281
+ if (watchdog && typeof watchdog.unref === 'function') watchdog.unref();
282
+ if (!child || typeof child.once !== 'function') {
283
+ // No event surface (a minimal double): the launch itself is the end.
284
+ done();
285
+ return;
286
+ }
287
+ child.once('close', done);
288
+ child.once('error', done);
289
+ });
290
+ }
291
+
292
+ function isQuiescent() {
293
+ for (const state of syncStates.values()) {
294
+ if (state.running || state.timer !== null || state.retryTimer !== null) return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ function settleIdleWaiters() {
300
+ if (idleWaiters.length === 0 || !isQuiescent()) return;
301
+ const waiters = idleWaiters;
302
+ idleWaiters = [];
303
+ for (const resolve of waiters) resolve();
304
+ }
305
+
306
+ function beginRun(root, state, deps) {
307
+ state.running = true;
308
+ void runSync(root, deps).then((outcome) => {
309
+ state.running = false;
310
+ if (outcome === 'locked' && !state.retried) {
311
+ // One bounded retry: the last edit before a foreign sync still lands,
312
+ // and a lock that stays held never becomes a busy loop.
313
+ state.retried = true;
314
+ const delay = typeof deps.retryDelayMs === 'number' ? deps.retryDelayMs : RETRY_DELAY_MS;
315
+ state.retryTimer = timers(deps).setTimeout(() => {
316
+ state.retryTimer = null;
317
+ beginRun(root, state, deps);
318
+ }, delay);
319
+ return;
320
+ }
321
+ if (outcome !== 'locked') state.retried = false;
322
+ if (state.pending) {
323
+ state.pending = false;
324
+ state.retried = false;
325
+ beginRun(root, state, deps);
326
+ return;
327
+ }
328
+ settleIdleWaiters();
329
+ }).catch(() => {
330
+ state.running = false;
331
+ state.pending = false;
332
+ settleIdleWaiters();
333
+ });
334
+ }
335
+
336
+ // CodeGraph 1.6 writes `{pid, version, socketPath, startedAt}` to daemon.pid and
337
+ // tolerates a bare decimal from older versions, so read both shapes.
338
+ function daemonPidOf(text) {
339
+ const raw = String(text || '').trim();
340
+ if (raw.startsWith('{')) {
341
+ try {
342
+ const info = JSON.parse(raw);
343
+ const pid = Number(info && info.pid);
344
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ const pid = Number.parseInt(raw, 10);
350
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
351
+ }
352
+
353
+ function daemonAlive(codegraphDir, deps) {
354
+ const readFile = pick(deps, 'readFile', defaultReadFile);
355
+ const isAlive = pick(deps, 'isAlive', defaultIsAlive);
356
+ let text;
357
+ try {
358
+ text = readFile(path.join(codegraphDir, DAEMON_PID_FILE));
359
+ } catch {
360
+ return false;
361
+ }
362
+ const pid = daemonPidOf(text);
363
+ return pid !== null && isAlive(pid);
364
+ }
365
+
366
+ // --- entry point --------------------------------------------------------
367
+
368
+ // The extension's fire-and-forget entry (S1). Never throws, never waits:
369
+ // `{scheduled, reason}` describes what was decided, and settled work is
370
+ // observable through `whenIdle()`.
371
+ function scheduleSync(filePath, deps = {}) {
372
+ const effectiveDeps = deps || {};
373
+ try {
374
+ if (typeof filePath !== 'string' || filePath.length === 0) return { scheduled: false, reason: 'no-path' };
375
+ const env = effectiveDeps.env || process.env;
376
+ // The user's switch wins over everything below: off means no root read, no
377
+ // daemon read, no lock and no child.
378
+ if (!isHookEnabled(env)) return { scheduled: false, reason: 'hook-off' };
379
+ if (!hasCodegraph(env, effectiveDeps)) return { scheduled: false, reason: 'no-codegraph' };
380
+ const root = resolveProjectRoot(path.dirname(path.resolve(filePath)), effectiveDeps);
381
+ if (root === null) return { scheduled: false, reason: 'no-root' };
382
+
383
+ const codegraphDir = path.join(root, '.codegraph');
384
+ const exists = pick(effectiveDeps, 'exists', fs.existsSync);
385
+ if (!exists(codegraphDir)) return { scheduled: false, reason: 'no-index' };
386
+ if (daemonAlive(codegraphDir, effectiveDeps)) return { scheduled: false, reason: 'daemon' };
387
+
388
+ let state = syncStates.get(root);
389
+ if (state === undefined) {
390
+ state = { timer: null, retryTimer: null, running: false, pending: false, retried: false };
391
+ syncStates.set(root, state);
392
+ }
393
+ if (state.running) {
394
+ state.pending = true;
395
+ return { scheduled: true, reason: 'queued' };
396
+ }
397
+ const t = timers(effectiveDeps);
398
+ if (state.timer !== null) t.clearTimeout(state.timer);
399
+ state.retried = false;
400
+ const delay = typeof effectiveDeps.debounceMs === 'number' ? effectiveDeps.debounceMs : DEBOUNCE_MS;
401
+ state.timer = t.setTimeout(() => {
402
+ state.timer = null;
403
+ beginRun(root, state, effectiveDeps);
404
+ }, delay);
405
+ return { scheduled: true, reason: 'scheduled' };
406
+ } catch {
407
+ return { scheduled: false, reason: 'error' };
408
+ }
409
+ }
410
+
411
+ // Resolves when no root has a timer, a retry or a run in flight, so a test can
412
+ // await settled work without sleeping.
413
+ function whenIdle() {
414
+ if (isQuiescent()) return Promise.resolve();
415
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
416
+ }
417
+
418
+ // Test-only full reset: the gate, the hook switch, both caches and the waiters.
419
+ function resetCodegraphSession() {
420
+ gateAnswer = null;
421
+ hookEnabled = null;
422
+ rootCache.clear();
423
+ syncStates.clear();
424
+ const waiters = idleWaiters;
425
+ idleWaiters = [];
426
+ for (const resolve of waiters) resolve();
427
+ }
428
+
429
+ module.exports = {
430
+ hasCodegraph,
431
+ isHookEnabled,
432
+ setHookEnabled,
433
+ resetCodegraphGate,
434
+ resetCodegraphSession,
435
+ resolveProjectRoot,
436
+ scheduleSync,
437
+ whenIdle,
438
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ // The `## ODD worktrees` section of the primary session's system prompt (S7).
3
+ //
4
+ // It is appended to `systemPromptOptions.appendSystemPrompt` and nowhere else:
5
+ // a handler-returned `systemPrompt` is dropped by pi-claude-bridge, and
6
+ // replacing the option would erase the harness's own text. The block is only
7
+ // ever added after the existing text, is skipped when it is already there, and
8
+ // reaches a primary session only — a named agent or a `GENTLE_PI_AGENTS_CHILD`
9
+ // worker is left untouched. `ODD_WORKTREE_PROMPT=0` turns it off.
10
+ //
11
+ // The section has two variants: the CodeGraph sentences are present only when
12
+ // the session gate says the CLI exists, so the prompt never promises a sync
13
+ // that cannot happen.
14
+
15
+ const { hasCodegraph } = require('./codegraph-session.js');
16
+
17
+ // The heading is the identity of the block, so an already-injected section is
18
+ // never added twice — not even when the gate answer changes mid-session and a
19
+ // different variant would otherwise be appended.
20
+ const SECTION_MARKER = '## ODD worktrees';
21
+
22
+ const WITH_CODEGRAPH = [
23
+ '## ODD worktrees',
24
+ '',
25
+ 'Each ODD feature has its own worktree. Before starting implementation, check',
26
+ 'whether that ODD already has an assigned worktree: if it does, resume it there;',
27
+ 'if it does not, create one before starting.',
28
+ '',
29
+ '`worktree_create` places the checkout at `<repo-parent>/<repo-name>-worktrees/<name>`',
30
+ 'and runs `codegraph init` there, so the new checkout has its own index from the',
31
+ 'start. Edits and writes trigger a background `codegraph sync` for that checkout.',
32
+ 'Every worktree keeps its own `.codegraph/`; never copy, symlink or reuse another',
33
+ "checkout's index.",
34
+ '',
35
+ 'Keep all work for one ODD in its assigned worktree. Do not create a second one',
36
+ 'to continue it.',
37
+ ].join('\n');
38
+
39
+ const WITHOUT_CODEGRAPH = [
40
+ '## ODD worktrees',
41
+ '',
42
+ 'Each ODD feature has its own worktree. Before starting implementation, check',
43
+ 'whether that ODD already has an assigned worktree: if it does, resume it there;',
44
+ 'if it does not, create one before starting.',
45
+ '',
46
+ '`worktree_create` places the checkout at `<repo-parent>/<repo-name>-worktrees/<name>`.',
47
+ '',
48
+ 'Keep all work for one ODD in its assigned worktree. Do not create a second one',
49
+ 'to continue it.',
50
+ ].join('\n');
51
+
52
+ // The names a named-agent event may carry. `agent` itself is listed because a
53
+ // plain string there is a named agent too; an object is read through `.name`.
54
+ function namedAgentOf(event) {
55
+ if (!event || typeof event !== 'object') return null;
56
+ const candidates = [
57
+ event.agentName,
58
+ event.agent,
59
+ event.name,
60
+ event.agent && typeof event.agent === 'object' ? event.agent.name : undefined,
61
+ event.subagent && typeof event.subagent === 'object' ? event.subagent.name : undefined,
62
+ ];
63
+ for (const candidate of candidates) {
64
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function separatorFor(existing) {
70
+ if (existing.endsWith('\n\n')) return '';
71
+ if (existing.endsWith('\n')) return '\n';
72
+ return '\n\n';
73
+ }
74
+
75
+ // Append the section to `event.systemPromptOptions.appendSystemPrompt`.
76
+ // Returns nothing: the mutation is the whole contract, so a host that awaits
77
+ // the handler receives no replacement prompt.
78
+ function appendOddWorktreesSection(event, deps = {}) {
79
+ try {
80
+ const effectiveDeps = deps || {};
81
+ const env = effectiveDeps.env || process.env;
82
+ if (env.ODD_WORKTREE_PROMPT === '0') return;
83
+ if (env.GENTLE_PI_AGENTS_CHILD === '1') return;
84
+ if (namedAgentOf(event) !== null) return;
85
+
86
+ const options = event && event.systemPromptOptions;
87
+ if (!options || typeof options !== 'object') return;
88
+
89
+ const available = typeof effectiveDeps.hasCodegraph === 'function'
90
+ ? effectiveDeps.hasCodegraph(env)
91
+ : hasCodegraph(env);
92
+ const section = available ? WITH_CODEGRAPH : WITHOUT_CODEGRAPH;
93
+
94
+ const existing = typeof options.appendSystemPrompt === 'string' ? options.appendSystemPrompt : '';
95
+ if (existing.includes(SECTION_MARKER)) return;
96
+ // Append-only: every existing byte stays the prefix of the new value.
97
+ options.appendSystemPrompt = existing.length === 0
98
+ ? section
99
+ : `${existing}${separatorFor(existing)}${section}`;
100
+ } catch {
101
+ // The prompt block must never break session start.
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ appendOddWorktreesSection,
107
+ WITH_CODEGRAPH,
108
+ WITHOUT_CODEGRAPH,
109
+ };
@@ -28,6 +28,8 @@
28
28
  // never need real package managers. Defaults match S8 exactly. The `run`
29
29
  // and `hasBinary` hooks are injectable for the same reason.
30
30
 
31
+ const codegraphSession = require('./codegraph-session.js');
32
+
31
33
  const { spawnSync } = require('node:child_process');
32
34
  const fs = require('node:fs');
33
35
  const path = require('node:path');
@@ -154,6 +156,21 @@ function isTrackedByGit(run, principalRoot, candidate, timeoutMs, env) {
154
156
  return false;
155
157
  }
156
158
 
159
+ // The `codegraph init` decision (S3): the session gate answers once per
160
+ // process, so creation no longer re-probes. An injected `hasBinary` stays
161
+ // authoritative for callers that already supply one (tests, embedders), and
162
+ // only the default path consults the gate.
163
+ function codegraphDecision(opts, env, hasBinary) {
164
+ if (typeof opts.hasBinary === 'function') {
165
+ return hasBinary('codegraph')
166
+ ? { available: true }
167
+ : { available: false, detail: 'codegraph not on PATH' };
168
+ }
169
+ return codegraphSession.hasCodegraph(env)
170
+ ? { available: true }
171
+ : { available: false, detail: 'codegraph session gate: CLI not detected in this session' };
172
+ }
173
+
157
174
  // --- Shared pure pipeline (S63): sync + async runners use these ---
158
175
  // Detection: which install entries fire for the worktree (markers OR).
159
176
  // Sync lstat only (constant-time); never a subprocess, never the loop blocker.
@@ -473,8 +490,9 @@ async function runCreationHooksAsync(opts = {}) {
473
490
  }
474
491
 
475
492
  {
476
- if (!hasBinary('codegraph')) {
477
- addSetupStep('codegraph-init', 'skipped', 'codegraph not on PATH');
493
+ const codegraph = codegraphDecision(opts, env, hasBinary);
494
+ if (!codegraph.available) {
495
+ addSetupStep('codegraph-init', 'skipped', codegraph.detail);
478
496
  } else {
479
497
  let res;
480
498
  try {
@@ -704,8 +722,9 @@ function runCreationHooks(opts = {}) {
704
722
  // (c) codegraph init when the CLI is available. Skipped with a reason
705
723
  // when absent; a non-zero exit is failed plus a warning. Never fatal.
706
724
  {
707
- if (!hasBinary('codegraph')) {
708
- addStep('codegraph-init', 'skipped', 'codegraph not on PATH');
725
+ const codegraph = codegraphDecision(opts, env, hasBinary);
726
+ if (!codegraph.available) {
727
+ addStep('codegraph-init', 'skipped', codegraph.detail);
709
728
  } else {
710
729
  let res;
711
730
  try {
@@ -158,6 +158,33 @@ function sessionPoint(entries, leafId) {
158
158
  return { tipId, count };
159
159
  }
160
160
 
161
+ // Does the ACTIVE branch carry a CONVERSATION? The host's own notion of
162
+ // content is a `message` entry whose role is user or assistant
163
+ // (SessionManager._persist only flushes a session file once an assistant
164
+ // message exists), and everything the warp appends — the transition marker,
165
+ // the confirmation notice, the session-worktree registration, a rename — is
166
+ // bookkeeping, so a session that holds only those is still an empty one: it
167
+ // has nothing to carry and therefore changes PATH instead of forking. The
168
+ // branch is the same filtered walk `sessionPoint` uses, so an abandoned leg
169
+ // never counts. The role is read from the persisted `entry.message.role` and
170
+ // from a bare `entry.role` alike, because reading an entry as content must
171
+ // not depend on one spelling. A read that throws answers `true`: an
172
+ // unreadable session keeps the ordinary move (fork) instead of silently
173
+ // dropping context it may hold.
174
+ function hasConversation(entries, leafId) {
175
+ try {
176
+ for (const entry of activeBranch(entries, leafId)) {
177
+ if (!entry || isWarpBookkeepingEntry(entry) || entry.type !== 'message') continue;
178
+ const role = typeof entry.role === 'string' ? entry.role
179
+ : entry.message && typeof entry.message === 'object' ? entry.message.role : null;
180
+ if (role === 'user' || role === 'assistant') return true;
181
+ }
182
+ return false;
183
+ } catch {
184
+ return true;
185
+ }
186
+ }
187
+
161
188
  // Point evidence for the R3 fork-vs-resume rule: the source session's
162
189
  // FILTERED position (S23 sessionPoint) when the record was written. Older
163
190
  // records carry {leafId, entryCount} from the raw-leaf comparison; they
@@ -559,6 +586,7 @@ module.exports = {
559
586
  recordWarpSession,
560
587
  isWarpBookkeepingEntry,
561
588
  sessionPoint,
589
+ hasConversation,
562
590
  samePoint,
563
591
  asPoint,
564
592
  pointOfSessionFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-odd-worktree",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
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
5
  "keywords": [
6
6
  "pi-package",