convene-cli 1.6.0 → 1.8.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/dist/api.js CHANGED
@@ -117,6 +117,15 @@ class ConveneApi {
117
117
  const qs = params.toString();
118
118
  return this.request('GET', `/help${qs ? `?${qs}` : ''}`, { timeoutMs });
119
119
  }
120
+ /**
121
+ * POST /presence — UPSERT this session's activity beat. PURELY for observability
122
+ * (the "Active now" surface); ALWAYS best-effort / fail-open. The body carries
123
+ * only a COARSE area + edit count — never filenames (the bus is cross-member).
124
+ * Bounded by a short timeout so the PostToolUse hook never slows an edit.
125
+ */
126
+ presence(slug, body, timeoutMs) {
127
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/presence`, { body, timeoutMs });
128
+ }
120
129
  post(slug, body, idempotencyKey, timeoutMs) {
121
130
  return this.request('POST', `/projects/${encodeURIComponent(slug)}/messages`, {
122
131
  body,
package/dist/cache.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.OVERRIDE_TTL_MS = exports.writeWatchHighWater = exports.LIVE_SESSION_WINDOW_SEC = void 0;
6
+ exports.OVERRIDE_TTL_MS = exports.writeWatchHighWater = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
7
7
  exports.readCache = readCache;
8
8
  exports.writeCache = writeCache;
9
9
  exports.ageSeconds = ageSeconds;
@@ -13,8 +13,12 @@ exports.readSessionInstance = readSessionInstance;
13
13
  exports.mintSessionInstance = mintSessionInstance;
14
14
  exports.ensureSessionInstance = ensureSessionInstance;
15
15
  exports.liveSessionCount = liveSessionCount;
16
+ exports.readBeatState = readBeatState;
17
+ exports.writeBeatState = writeBeatState;
16
18
  exports.markCatchupSurfaced = markCatchupSurfaced;
17
19
  exports.catchupAlreadySurfaced = catchupAlreadySurfaced;
20
+ exports.markAutoIsolated = markAutoIsolated;
21
+ exports.autoIsolatedAlready = autoIsolatedAlready;
18
22
  exports.readWatchHighWater = readWatchHighWater;
19
23
  exports.persistHighWater = persistHighWater;
20
24
  exports.appendWatchEntry = appendWatchEntry;
@@ -22,6 +26,11 @@ exports.appendWatch = appendWatch;
22
26
  exports.readWatchSince = readWatchSince;
23
27
  exports.touchWatchHeartbeat = touchWatchHeartbeat;
24
28
  exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
29
+ exports.writeWatchPid = writeWatchPid;
30
+ exports.readWatchPid = readWatchPid;
31
+ exports.clearWatchPidIfOwner = clearWatchPidIfOwner;
32
+ exports.readAllWatchPids = readAllWatchPids;
33
+ exports.isPidAlive = isPidAlive;
25
34
  exports.writeOverrideToken = writeOverrideToken;
26
35
  exports.readLiveOverrideToken = readLiveOverrideToken;
27
36
  /**
@@ -203,6 +212,33 @@ function liveSessionCount(slug, maxAgeSec) {
203
212
  return 0;
204
213
  }
205
214
  }
215
+ function beatFile(slug) {
216
+ return slugFile(scoped(slug), 'beat');
217
+ }
218
+ /** Read this session's beat state, or a zeroed default. Best-effort. */
219
+ function readBeatState(slug) {
220
+ try {
221
+ const s = JSON.parse(node_fs_1.default.readFileSync(beatFile(slug), 'utf8'));
222
+ return {
223
+ lastPostMs: Number.isFinite(s.lastPostMs) ? s.lastPostMs : 0,
224
+ pendingEdits: Number.isFinite(s.pendingEdits) ? s.pendingEdits : 0,
225
+ area: typeof s.area === 'string' ? s.area : null,
226
+ };
227
+ }
228
+ catch {
229
+ return { lastPostMs: 0, pendingEdits: 0, area: null };
230
+ }
231
+ }
232
+ /** Persist this session's beat state. Best-effort; never throws. */
233
+ function writeBeatState(slug, state) {
234
+ try {
235
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
236
+ node_fs_1.default.writeFileSync(beatFile(slug), JSON.stringify(state), { mode: 0o600 });
237
+ }
238
+ catch {
239
+ /* best-effort */
240
+ }
241
+ }
206
242
  // ── per-boot catch-up dedup sentinel (WP2) ───────────────────────────────────
207
243
  // SessionStart writes a sentinel keyed by the session-instance once it has
208
244
  // surfaced a catch-up; the first UserPromptSubmit `fetch` of that boot reads it
@@ -230,6 +266,45 @@ function catchupAlreadySurfaced(slug, instance) {
230
266
  return false;
231
267
  }
232
268
  }
269
+ // ── per-instance auto-isolate sentinel (auto-isolate) ─────────────────────────
270
+ // SessionStart relocates a session that boots into an OCCUPIED checkout into a
271
+ // fresh isolated worktree (a SOFT, best-effort move). It records this sentinel
272
+ // keyed by the session-instance so a resume/clear SessionStart of an ALREADY-
273
+ // relocated session does NOT re-provision yet another tree. Keyed by instance
274
+ // (like the catch-up sentinel) so a genuinely NEW boot can still relocate; scoped
275
+ // per-session via `scoped()` so co-tenant sessions don't clobber each other's mark.
276
+ function autoIsolateFile(slug) {
277
+ return slugFile(scoped(slug), 'auto-isolated');
278
+ }
279
+ /** Mark that this session-instance has already been auto-isolated (relocated). */
280
+ function markAutoIsolated(slug, instance) {
281
+ try {
282
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
283
+ node_fs_1.default.writeFileSync(autoIsolateFile(slug), instance + '\n', { mode: 0o600 });
284
+ }
285
+ catch {
286
+ /* best-effort */
287
+ }
288
+ }
289
+ /** True iff this exact session-instance has already been auto-isolated. */
290
+ function autoIsolatedAlready(slug, instance) {
291
+ try {
292
+ return node_fs_1.default.readFileSync(autoIsolateFile(slug), 'utf8').trim() === instance;
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ }
298
+ /**
299
+ * Tight recency window (sec) for the auto-isolate trigger's relaunch-ghost guard.
300
+ * The wider LIVE_SESSION_WINDOW_SEC (10 min) deliberately keeps a just-closed
301
+ * session counted (so a concurrent agent mid-long-turn isn't missed). For the
302
+ * ACTIVE relocation decision that is too loose: a session that was closed and
303
+ * relaunched a few minutes ago would otherwise trigger a needless move. We require
304
+ * a sibling to have pulsed within this tighter window so only a TRULY concurrent
305
+ * incumbent forces a relocation.
306
+ */
307
+ exports.LIVE_SESSION_RECENT_SEC = 3 * 60;
233
308
  function watchFile(slug) {
234
309
  return slugFile(slug, 'watch.jsonl');
235
310
  }
@@ -356,6 +431,103 @@ function watchHeartbeatAgeSec(slug) {
356
431
  return null;
357
432
  }
358
433
  }
434
+ function watchPidFile(slug) {
435
+ return slugFile(scoped(slug), 'watch.pid');
436
+ }
437
+ /** Record (overwrite) THIS watcher as the owner of the session scope. Best-effort. */
438
+ function writeWatchPid(slug, pid = process.pid, startedAt = Date.now()) {
439
+ try {
440
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
441
+ node_fs_1.default.writeFileSync(watchPidFile(slug), JSON.stringify({ pid, startedAt }), { mode: 0o600 });
442
+ }
443
+ catch {
444
+ /* best-effort */
445
+ }
446
+ }
447
+ /** The recorded owning watcher {pid, startedAt} for this scope, or null. */
448
+ function readWatchPid(slug) {
449
+ try {
450
+ const o = JSON.parse(node_fs_1.default.readFileSync(watchPidFile(slug), 'utf8'));
451
+ if (!o || typeof o.pid !== 'number' || !Number.isFinite(o.pid))
452
+ return null;
453
+ if (typeof o.startedAt !== 'number' || !Number.isFinite(o.startedAt))
454
+ return null;
455
+ return o;
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ }
461
+ /**
462
+ * Remove the pidfile ONLY if it still names `pid` (default: this process). A
463
+ * newer watcher may have overwritten it with its own pid, in which case we must
464
+ * NOT delete it — that would orphan the new owner's record. Best-effort.
465
+ */
466
+ function clearWatchPidIfOwner(slug, pid = process.pid) {
467
+ try {
468
+ const cur = readWatchPid(slug);
469
+ if (cur && cur.pid === pid)
470
+ node_fs_1.default.unlinkSync(watchPidFile(slug));
471
+ }
472
+ catch {
473
+ /* best-effort */
474
+ }
475
+ }
476
+ /**
477
+ * Every pid recorded in a `*.watch.pid` file across ALL scopes/sessions in
478
+ * CACHE_DIR. The reaper unions these (filtered to live) to SPARE watchers a
479
+ * current session still owns — a post-fix watcher always writes its pidfile, so a
480
+ * live, pidfile-owned watcher is by definition NOT a dead-session orphan even
481
+ * though it (like every detached watcher) shows ppid 1. Best-effort: a missing
482
+ * dir, an unreadable file, or a garbage entry is skipped, never thrown.
483
+ */
484
+ function readAllWatchPids() {
485
+ const out = [];
486
+ let entries;
487
+ try {
488
+ entries = node_fs_1.default.readdirSync(config_1.CACHE_DIR);
489
+ }
490
+ catch {
491
+ return out;
492
+ }
493
+ for (const f of entries) {
494
+ if (!f.endsWith('.watch.pid'))
495
+ continue;
496
+ try {
497
+ const o = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(config_1.CACHE_DIR, f), 'utf8'));
498
+ const pid = typeof o?.pid === 'number' ? o.pid : NaN;
499
+ if (Number.isFinite(pid) && pid > 0)
500
+ out.push(pid);
501
+ }
502
+ catch {
503
+ /* skip unreadable/garbage pidfiles */
504
+ }
505
+ }
506
+ return out;
507
+ }
508
+ /**
509
+ * Is `pid` a live process? `process.kill(pid, 0)` sends no signal — it only
510
+ * probes existence/permission. EPERM ⇒ the process exists but we can't signal it
511
+ * (still ALIVE); ESRCH ⇒ no such process (DEAD). On win32 signal 0 is unreliable,
512
+ * so a non-ESRCH error is treated as alive (best-effort). An invalid pid or any
513
+ * other error ⇒ false. Shared by the spawn-dedup guards (session-start/doctor)
514
+ * and the reaper's spare-the-living gate.
515
+ */
516
+ function isPidAlive(pid) {
517
+ if (!Number.isFinite(pid) || pid <= 0)
518
+ return false;
519
+ try {
520
+ process.kill(pid, 0);
521
+ return true;
522
+ }
523
+ catch (e) {
524
+ if (e && e.code === 'EPERM')
525
+ return true; // exists, not ours to signal
526
+ if (process.platform === 'win32' && e && e.code !== 'ESRCH')
527
+ return true;
528
+ return false; // ESRCH (and the win32 fallthrough) ⇒ dead
529
+ }
530
+ }
359
531
  // ── best-practice override token (Phase 3) ───────────────────────────────────
360
532
  // `convene override <id> --reason …` writes a short-TTL, expiry-based token that
361
533
  // `convene practice-guard <id>` honors → ALLOW. The token is purely LOCAL state
@@ -20,12 +20,23 @@ const manifest_1 = require("../catalog/manifest");
20
20
  const git_1 = require("../git");
21
21
  const hook_1 = require("../hook");
22
22
  const cache_1 = require("../cache");
23
+ const watch_reap_1 = require("./watch-reap");
23
24
  const ctx_1 = require("../ctx");
24
25
  /** A watch heartbeat older than this (or absent) means the watcher is down. */
25
26
  const WATCH_STALE_SEC = 90;
26
- /** (Re)launch `convene watch` detached so it outlives this doctor process. */
27
- function relaunchWatch() {
27
+ /**
28
+ * (Re)launch `convene watch` detached so it outlives this doctor process — but
29
+ * SKIP if a live watcher already owns this scope (the daemon-leak dedup guard),
30
+ * so `doctor --fix` never piles up a duplicate. Returns true iff a watcher is
31
+ * now (re)launched or already running.
32
+ */
33
+ function relaunchWatch(slug) {
28
34
  try {
35
+ if (slug) {
36
+ const owner = (0, cache_1.readWatchPid)(slug);
37
+ if (owner && (0, cache_1.isPidAlive)(owner.pid))
38
+ return true; // already watching
39
+ }
29
40
  const bin = process.argv[1] || '';
30
41
  if (!bin)
31
42
  return false;
@@ -330,7 +341,7 @@ async function doctor(opts) {
330
341
  let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
331
342
  let watchOk = age != null && age <= WATCH_STALE_SEC;
332
343
  if (!watchOk && opts.fix) {
333
- if (relaunchWatch()) {
344
+ if (relaunchWatch(proj.slug)) {
334
345
  // Give the freshly-launched daemon a beat to stamp its first heartbeat.
335
346
  const until = Date.now() + 2500;
336
347
  while (Date.now() < until) {
@@ -350,6 +361,25 @@ async function doctor(opts) {
350
361
  ? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
351
362
  : `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
352
363
  });
364
+ // 7a. reap orphaned watchers — the cleanup half of the daemon-leak fix. Only
365
+ // under --fix (running `ps` on every doctor would slow the fast path). Runs
366
+ // AFTER the relaunch + heartbeat-wait above so the freshly-relaunched watcher
367
+ // has already written its pidfile and is SPARED (a live, pidfile-owned watcher
368
+ // is never reaped — only true dead-session orphans are). Reports the kill +
369
+ // spare counts. Fail-open: a reap fault is informational, never a doctor failure.
370
+ if (opts.fix) {
371
+ const reaped = (0, watch_reap_1.reapWatchers)({});
372
+ const sparedTail = reaped.spared > 0 ? ` (spared ${reaped.spared} live-owned)` : '';
373
+ checks.push({
374
+ name: 'reap',
375
+ ok: true,
376
+ detail: reaped.note
377
+ ? `watcher reap skipped (${reaped.note})`
378
+ : reaped.found === 0
379
+ ? `no orphaned watchers to reap${sparedTail}`
380
+ : `reaped ${reaped.killed}/${reaped.found} orphaned watcher(s)${sparedTail}`,
381
+ });
382
+ }
353
383
  }
354
384
  // 7b. parallel sessions sharing ONE checkout. Several agents in the same
355
385
  // working tree clobber each other's uncommitted files and (absent the
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.coarseArea = coarseArea;
7
+ exports.filePathFromPayload = filePathFromPayload;
8
+ exports.beat = beat;
9
+ /**
10
+ * `convene beat` — the session activity-beat emitter (a PostToolUse hook on
11
+ * Edit|Write|MultiEdit). It closes the dark-session gap: a session heads-down
12
+ * editing for many minutes before it pushes still pulses on the bus, so siblings
13
+ * (and humans on the dashboard) can see it is alive and roughly where it works.
14
+ *
15
+ * Posture (mirrors session-start): FAIL-OPEN and FAST. Not a git repo / not on the
16
+ * bus / not authenticated ⇒ silent exit 0. A hard watchdog guarantees it never
17
+ * holds a tool call. It is DEBOUNCED client-side: every edit bumps a per-session
18
+ * pending counter, but it only actually POSTs presence once the window elapses
19
+ * (≤1 network call per window per session). The first edit of a session posts
20
+ * immediately (lastPostMs=0), giving a prompt "started working" signal.
21
+ *
22
+ * PRIVACY: the beat carries only a COARSE area — the first path segment of the
23
+ * edited file relative to the repo (e.g. 'web', 'cli', 'server') — never a
24
+ * filename. The bus is cross-member, so a full path would leak repo structure.
25
+ */
26
+ const node_path_1 = __importDefault(require("node:path"));
27
+ const git_1 = require("../git");
28
+ const config_1 = require("../config");
29
+ const api_1 = require("../api");
30
+ const cache_1 = require("../cache");
31
+ /** ≤1 presence POST per this window per session; edits between are coalesced. */
32
+ const DEBOUNCE_MS = 90_000;
33
+ /** Short, bounded post — a PostToolUse hook must never slow an edit. */
34
+ const POST_TIMEOUT_MS = 2500;
35
+ /** Absolute backstop: never hold the tool call past this. */
36
+ const WATCHDOG_MS = 3500;
37
+ /** First path segment of `file` relative to `top` — a coarse, privacy-safe area. */
38
+ function coarseArea(file, top) {
39
+ if (!file)
40
+ return null;
41
+ let rel = file;
42
+ try {
43
+ if (node_path_1.default.isAbsolute(file))
44
+ rel = node_path_1.default.relative(top, file);
45
+ }
46
+ catch {
47
+ rel = file;
48
+ }
49
+ if (!rel || rel.startsWith('..'))
50
+ return null; // outside the repo — don't broadcast
51
+ const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
52
+ const seg = norm.split('/')[0] || '';
53
+ if (!seg || seg === norm)
54
+ return '(root)'; // a top-level file → no directory to name
55
+ const clean = seg.replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32);
56
+ return clean || null;
57
+ }
58
+ /** Pull the edited file path out of a PostToolUse stdin payload. */
59
+ function filePathFromPayload(raw) {
60
+ if (!raw)
61
+ return null;
62
+ try {
63
+ const j = JSON.parse(raw);
64
+ const ti = j?.tool_input ?? {};
65
+ const p = ti.file_path ?? ti.notebook_path ?? ti.path ?? null;
66
+ return typeof p === 'string' && p ? p : null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function readStdin(timeoutMs) {
73
+ if (process.stdin.isTTY)
74
+ return Promise.resolve(null);
75
+ return new Promise((resolve) => {
76
+ let data = '';
77
+ let settled = false;
78
+ const finish = (v) => {
79
+ if (settled)
80
+ return;
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ process.stdin.removeAllListeners();
84
+ resolve(v);
85
+ };
86
+ const timer = setTimeout(() => finish(null), timeoutMs);
87
+ process.stdin.setEncoding('utf8');
88
+ process.stdin.on('data', (c) => (data += c));
89
+ process.stdin.on('end', () => finish(data));
90
+ process.stdin.on('error', () => finish(null));
91
+ process.stdin.resume();
92
+ });
93
+ }
94
+ async function run(opts) {
95
+ const top = (0, git_1.gitToplevel)();
96
+ if (!top)
97
+ return; // not a git repo → silent no-op
98
+ const proj = (0, config_1.loadProjectConfig)(top);
99
+ if (!proj?.slug)
100
+ return; // repo not on the bus → silent no-op
101
+ const slug = proj.slug;
102
+ const cfg = (0, config_1.resolveConfig)();
103
+ if (!cfg.apiKey || !cfg.member)
104
+ return; // not authenticated → silent
105
+ const session = (0, git_1.sessionId)(cfg.member, top);
106
+ const raw = opts.stdin ? await readStdin(800) : null;
107
+ const area = coarseArea(filePathFromPayload(raw), top);
108
+ // Coalesce: bump the pending counter / freshest area every edit.
109
+ const state = (0, cache_1.readBeatState)(slug);
110
+ state.pendingEdits += 1;
111
+ if (area)
112
+ state.area = area;
113
+ const now = Date.now();
114
+ if (state.lastPostMs && now - state.lastPostMs < DEBOUNCE_MS) {
115
+ // Inside the window — accumulate locally, no network.
116
+ (0, cache_1.writeBeatState)(slug, state);
117
+ return;
118
+ }
119
+ // Window elapsed (or first edit): POST presence, then reset the window.
120
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
121
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
122
+ const res = await api.presence(slug, { area: state.area, edits: state.pendingEdits }, POST_TIMEOUT_MS);
123
+ // Always advance the window so a failed beat can't hammer the network on every
124
+ // edit; only clear the pending count on a confirmed post (so a transient blip
125
+ // doesn't drop the magnitude).
126
+ (0, cache_1.writeBeatState)(slug, {
127
+ lastPostMs: now,
128
+ pendingEdits: res.ok ? 0 : state.pendingEdits,
129
+ area: state.area,
130
+ });
131
+ }
132
+ async function beat(opts = {}) {
133
+ const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
134
+ watchdog.unref();
135
+ try {
136
+ await run(opts);
137
+ }
138
+ catch {
139
+ /* fail-open: a presence beat must never disrupt the session */
140
+ }
141
+ finally {
142
+ clearTimeout(watchdog);
143
+ process.exit(0);
144
+ }
145
+ }
@@ -287,7 +287,21 @@ async function runFetch(opts = {}) {
287
287
  lookbackMin: ctx.lookback,
288
288
  openItems: toRenderMessages(data?.inbox ?? []),
289
289
  recent: toRenderMessages(data?.messages ?? []),
290
+ presence: toRenderPresence(data?.presence ?? [], data?.server_time),
290
291
  health: { state: 'ok', syncedAgoSec: ctx.syncedAgoSec, openCount: Number(data?.open_for_you ?? (data?.inbox?.length ?? 0)) },
291
292
  }), opts.codexHook);
292
293
  }
293
294
  }
295
+ /** Map feed presence rows → RenderPresence, deriving age from the server clock. */
296
+ function toRenderPresence(rows, serverTime) {
297
+ if (!Array.isArray(rows))
298
+ return [];
299
+ const nowMs = serverTime ? Date.parse(serverTime) : Date.now();
300
+ return rows
301
+ .filter((r) => r && typeof r.session === 'string')
302
+ .map((r) => {
303
+ const seen = r.last_seen_at ? Date.parse(r.last_seen_at) : NaN;
304
+ const ageSec = Number.isFinite(seen) && Number.isFinite(nowMs) ? Math.max(0, (nowMs - seen) / 1000) : null;
305
+ return { session: r.session, area: r.area ?? null, edits: Number(r.edits) || 0, ageSec };
306
+ });
307
+ }
@@ -268,6 +268,13 @@ const COORD_HOOKS = [
268
268
  verb: 'gate-push',
269
269
  note: 'release the deploy lane after a push (idempotent)',
270
270
  },
271
+ {
272
+ event: 'PostToolUse',
273
+ matcher: 'Edit|Write|MultiEdit',
274
+ command: 'convene beat --stdin',
275
+ verb: 'beat',
276
+ note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
277
+ },
271
278
  ];
272
279
  /**
273
280
  * Wire the WP13 coordination hooks into a settings file (global or committed
@@ -23,6 +23,7 @@ const node_child_process_1 = require("node:child_process");
23
23
  const git_1 = require("../git");
24
24
  const config_1 = require("../config");
25
25
  const cache_1 = require("../cache");
26
+ const worktree_1 = require("./worktree");
26
27
  const api_1 = require("../api");
27
28
  const render_1 = require("../render");
28
29
  const catchup_1 = require("./catchup");
@@ -39,14 +40,23 @@ const WATCH_FRESH_SEC = 60;
39
40
  * Launch `convene watch` as a DETACHED background daemon (§4.4): the watch runs
40
41
  * for the life of the session surfacing mid-task halts, so it must NOT be a
41
42
  * blocking hook entry. Best-effort + fail-open: any error is swallowed; a launch
42
- * failure never wedges the boot. Skipped if a recent heartbeat shows a watcher is
43
- * already alive for this slug.
43
+ * failure never wedges the boot.
44
+ *
45
+ * Two dedup guards (the daemon-leak fix) prevent piling up duplicate watchers:
46
+ * 1. authoritative — the scope's pidfile names a process that is STILL ALIVE
47
+ * (survives even a long quiet gap where the heartbeat would have gone stale);
48
+ * 2. cheap fast-path — a heartbeat stamped within WATCH_FRESH_SEC.
49
+ * The detached spawn inherits this process's env (so CLAUDE_CODE_SESSION_ID flows
50
+ * to the child, scoping its pidfile/liveness to the owning session).
44
51
  */
45
52
  function launchWatch(slug) {
46
53
  try {
54
+ const owner = (0, cache_1.readWatchPid)(slug);
55
+ if (owner && (0, cache_1.isPidAlive)(owner.pid))
56
+ return; // a live watcher already owns this scope
47
57
  const age = (0, cache_1.watchHeartbeatAgeSec)(slug);
48
58
  if (age !== null && age < WATCH_FRESH_SEC)
49
- return; // already watching
59
+ return; // recently heartbeating
50
60
  const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'watch'], {
51
61
  detached: true,
52
62
  stdio: 'ignore',
@@ -60,6 +70,51 @@ function launchWatch(slug) {
60
70
  function emit(s) {
61
71
  process.stdout.write(s + '\n');
62
72
  }
73
+ /**
74
+ * SOFT auto-isolate: if this session booted INTO a checkout that already has a
75
+ * live sibling, provision a fresh isolated worktree and return a relocate block
76
+ * to emit (best-effort, never throws). The deterministic TIEBREAK that prevents a
77
+ * relocation storm: at SessionStart this session has not yet written its own feed
78
+ * `.json` (that only happens on the first `convene fetch`), so liveSessionCount is
79
+ * purely the INCUMBENT count. Only the session booting into an occupied checkout
80
+ * sees count >= 1 and moves; the incumbents, having no live sibling pulse newer
81
+ * than their own at their boot, never moved — exactly one side relocates.
82
+ *
83
+ * The full gate (ALL must hold):
84
+ * - a session discriminator exists (we are a disambiguable concurrent session);
85
+ * - >= 1 live sibling within the wide window (a co-tenant exists at all);
86
+ * - >= 1 live sibling within the TIGHT recency window (relaunch-ghost guard — a
87
+ * stale just-closed sibling's `.json` aged past this window won't trigger);
88
+ * - not already auto-isolated for THIS instance (the per-instance sentinel, so a
89
+ * resume/clear of an already-relocated session does not re-provision).
90
+ *
91
+ * Returns the relocate block string on a successful provision, else null. Fail-OPEN
92
+ * on every branch — any failure means "no relocation", and the boot proceeds.
93
+ */
94
+ function maybeAutoIsolate(top, slug, instance) {
95
+ try {
96
+ // Gate 1: we must be a disambiguable concurrent session (have a discriminator).
97
+ if (!(0, git_1.sessionDiscriminator)())
98
+ return null;
99
+ // Gate 2: at least one live INCUMBENT sibling exists in the wide window.
100
+ if ((0, cache_1.liveSessionCount)(slug, cache_1.LIVE_SESSION_WINDOW_SEC) < 1)
101
+ return null;
102
+ // Gate 3: recency — a sibling pulsed within the tight window (relaunch-ghost guard).
103
+ if ((0, cache_1.liveSessionCount)(slug, cache_1.LIVE_SESSION_RECENT_SEC) < 1)
104
+ return null;
105
+ // Gate 4: idempotency — already relocated this exact instance? do nothing.
106
+ if ((0, cache_1.autoIsolatedAlready)(slug, instance))
107
+ return null;
108
+ const res = (0, worktree_1.provisionAutoWorktree)(top, slug);
109
+ if (!res)
110
+ return null; // provisioning failed → fail-open, no relocation
111
+ (0, cache_1.markAutoIsolated)(slug, instance);
112
+ return (0, render_1.renderRelocateBlock)(res);
113
+ }
114
+ catch {
115
+ return null; // fail-open: never let auto-isolate wedge a boot
116
+ }
117
+ }
63
118
  async function run(opts) {
64
119
  const top = (0, git_1.gitToplevel)();
65
120
  if (!top)
@@ -75,15 +130,24 @@ async function run(opts) {
75
130
  const session = (0, git_1.sessionId)(member, top);
76
131
  // Mint a fresh instance for THIS boot (a fresh boot = a fresh instance).
77
132
  const instance = (0, cache_1.mintSessionInstance)(slug);
133
+ // SOFT auto-isolate (evaluated NOW, before this session writes its own feed
134
+ // .json): if a live sibling already occupies this checkout, provision a fresh
135
+ // isolated worktree and stage a relocate block. Best-effort; null = no move.
136
+ const relocateBlock = maybeAutoIsolate(top, slug, instance);
78
137
  // Launch the detached watch daemon from the SessionStart path (not a Bash hook).
79
138
  launchWatch(slug);
80
139
  const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
81
140
  const since = opts.since != null ? Number(opts.since) : undefined;
82
141
  const res = await api.sessionOpen(slug, { since: Number.isFinite(since) ? since : undefined, advance: true, maxItems: MAX_ITEMS }, FETCH_TIMEOUT_MS);
83
- // DEGRADED / failure → emit NOTHING (structural suppression). Still record the
84
- // sentinel so the first fetch doesn't double-surface from its own cache path.
142
+ // DEGRADED / failure → emit NOTHING from the catch-up digest (structural
143
+ // suppression). Still record the sentinel so the first fetch doesn't
144
+ // double-surface from its own cache path. The relocate block is INDEPENDENT of
145
+ // the network digest (it is purely local filesystem signal), so it is still
146
+ // surfaced — moving off an occupied checkout shouldn't depend on bus liveness.
85
147
  if (!res.ok || !res.json || res.json.degraded) {
86
148
  (0, cache_1.markCatchupSurfaced)(slug, instance);
149
+ if (relocateBlock)
150
+ emit(relocateBlock);
87
151
  return;
88
152
  }
89
153
  if (opts.json) {
@@ -92,6 +156,9 @@ async function run(opts) {
92
156
  else {
93
157
  emit((0, render_1.renderSessionOpenBlock)({ slug, member, session, digest: (0, catchup_1.toDigest)(res.json) }));
94
158
  }
159
+ // Emit the relocate block AFTER the digest (both are surfaced — digest then move).
160
+ if (relocateBlock)
161
+ emit(relocateBlock);
95
162
  (0, cache_1.markCatchupSurfaced)(slug, instance);
96
163
  }
97
164
  async function sessionStart(opts = {}) {