convene-cli 1.6.0 → 1.7.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.
@@ -0,0 +1,212 @@
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.parseReapableWatchers = parseReapableWatchers;
7
+ exports.excludeSpared = excludeSpared;
8
+ exports.reapWatchers = reapWatchers;
9
+ exports.watchReap = watchReap;
10
+ /**
11
+ * `convene watch-reap` — the cleanup half of the daemon-leak fix.
12
+ *
13
+ * Detached `convene watch` daemons spawned at SessionStart outlive their session
14
+ * and reparent to PID 1; before the self-termination ceilings (see watch.ts) they
15
+ * leaked forever (145 observed). New watchers now self-exit, but this verb mops up
16
+ * the ALREADY-ORPHANED backlog and is also invoked by `convene doctor --fix`.
17
+ *
18
+ * THE PPID-1 SUBTLETY (load-bearing): the SessionStart hook spawns the watcher
19
+ * detached+unref and exits IMMEDIATELY, so a watcher reparents to PID 1 within
20
+ * seconds EVEN WHILE its session is alive. ppid===1 therefore does NOT distinguish
21
+ * a dead-session orphan from a live session's watcher. Selection is two-gated:
22
+ * 1. parseReapableWatchers — ppid===1 + a tight argv match (the candidate set);
23
+ * 2. SPARE the living — drop any candidate whose pid is named by a `*.watch.pid`
24
+ * file AND is still alive. A post-fix watcher always writes its scoped
25
+ * pidfile, so a live, pidfile-owned watcher is by definition a current owner,
26
+ * never a dead-session orphan.
27
+ * Caveat: the FIRST reap after a user upgrades may kill their currently-running
28
+ * OLD-cli watcher — a pre-fix watcher wrote NO pidfile, so it is indistinguishable
29
+ * from a true orphan. That is acceptable: it self-heals on the next SessionStart,
30
+ * and from then on every live new-cli watcher carries a pidfile and is spared.
31
+ *
32
+ * Fail-open + POSIX-only: any error returns a benign empty result, never throws.
33
+ */
34
+ const node_child_process_1 = require("node:child_process");
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ const cache_1 = require("../cache");
37
+ /**
38
+ * PURE candidate selector (unit-tested): from `ps -eo pid,ppid,args` output,
39
+ * return the pids of processes that LOOK like detached convene watchers orphaned
40
+ * to init. This is the FIRST of two gates — the live-pidfile spare filter
41
+ * (see reapWatchers) is the second.
42
+ *
43
+ * Match TIGHTLY to avoid collateral damage:
44
+ * - the args must reference the convene bin (its basename — `index.js` for the
45
+ * installed CLI, or the bin name like `convene`) AND end with `watch` as the
46
+ * FINAL token (the detached watcher's exact shape: `node <bin> watch`);
47
+ * - never a bare `watch`, an `npm run watch`, or any process that merely
48
+ * contains the word watch elsewhere;
49
+ * - exclude `selfPid` (the reaper itself, were it ever named similarly);
50
+ * - select ONLY ppid === 1 (orphaned to init). NOTE this also catches live
51
+ * sessions' watchers (see the header) — the spare gate, not this, protects them.
52
+ */
53
+ function parseReapableWatchers(psOutput, conveneBin, selfPid) {
54
+ const binBase = conveneBin ? node_path_1.default.basename(conveneBin) : '';
55
+ const out = [];
56
+ for (const line of (psOutput || '').split('\n')) {
57
+ const s = line.trim();
58
+ if (!s)
59
+ continue;
60
+ // " PID PPID ARGS..." — split off the first two numeric columns; the rest
61
+ // (which may contain spaces) is the command line.
62
+ const m = s.match(/^(\d+)\s+(\d+)\s+(.*)$/);
63
+ if (!m)
64
+ continue;
65
+ const pid = parseInt(m[1], 10);
66
+ const ppid = parseInt(m[2], 10);
67
+ const args = m[3];
68
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
69
+ continue;
70
+ if (pid === selfPid)
71
+ continue; // never reap ourselves
72
+ if (ppid !== 1)
73
+ continue; // only orphans
74
+ const tokens = args.split(/\s+/).filter(Boolean);
75
+ if (tokens.length < 2)
76
+ continue;
77
+ // `watch` must be the FINAL token (the watcher takes no positional args after
78
+ // the verb; --notify/--project flags would precede nothing meaningful, but to
79
+ // stay strict we require it dead-last as the spawn shape guarantees).
80
+ if (tokens[tokens.length - 1] !== 'watch')
81
+ continue;
82
+ // The bin must appear among the args (basename match handles absolute paths).
83
+ const looksConvene = (binBase && tokens.some((t) => node_path_1.default.basename(t) === binBase)) ||
84
+ tokens.some((t) => node_path_1.default.basename(t) === 'convene' || node_path_1.default.basename(t) === 'index.js');
85
+ if (!looksConvene)
86
+ continue;
87
+ out.push(pid);
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * PURE second gate (unit-tested): the candidates NOT in `sparePids`. A spared pid
93
+ * is a live, pidfile-owned watcher (a current session's owner) — never reaped.
94
+ */
95
+ function excludeSpared(candidatePids, sparePids) {
96
+ const spare = new Set(sparePids);
97
+ return candidatePids.filter((p) => !spare.has(p));
98
+ }
99
+ /**
100
+ * Synchronous bounded sleep for the SIGTERM→SIGKILL grace. Uses `sleep(1)` (POSIX
101
+ * — and we're already past the win32 guard) so it does NOT peg a core like a busy
102
+ * spin would; reapWatchers stays synchronous so doctor can call it inline. Falls
103
+ * back to a brief busy-wait only if `sleep` is somehow unavailable.
104
+ */
105
+ const sleepSync = (sec) => {
106
+ try {
107
+ const r = (0, node_child_process_1.spawnSync)('sleep', [String(sec)], { timeout: Math.ceil(sec * 1000) + 1000 });
108
+ if (r.status === 0)
109
+ return;
110
+ }
111
+ catch {
112
+ /* fall through to the busy-wait */
113
+ }
114
+ const until = Date.now() + sec * 1000;
115
+ while (Date.now() < until) {
116
+ /* fallback only */
117
+ }
118
+ };
119
+ /**
120
+ * Find + (unless dryRun) kill orphaned convene watchers, SPARING any watcher a
121
+ * live session still owns (named by a live `*.watch.pid`). SIGTERM each, give a
122
+ * short grace, then SIGKILL any survivor. Never throws — every failure path
123
+ * returns a benign result. POSIX-only (win32 returns an empty noted result).
124
+ */
125
+ function reapWatchers({ dryRun = false } = {}) {
126
+ if (process.platform === 'win32') {
127
+ return { found: 0, killed: 0, pids: [], spared: 0, note: 'reap is POSIX-only' };
128
+ }
129
+ let psOut = '';
130
+ try {
131
+ const r = (0, node_child_process_1.spawnSync)('ps', ['-eo', 'pid,ppid,args'], { encoding: 'utf8', timeout: 5000 });
132
+ if (r.status !== 0 || !r.stdout)
133
+ return { found: 0, killed: 0, pids: [], spared: 0, note: 'ps unavailable' };
134
+ psOut = r.stdout;
135
+ }
136
+ catch {
137
+ return { found: 0, killed: 0, pids: [], spared: 0, note: 'ps failed' };
138
+ }
139
+ const bin = process.argv[1] || '';
140
+ const candidates = parseReapableWatchers(psOut, bin, process.pid);
141
+ // SPARE live-owned watchers: a current session's watcher is ppid 1 too (see the
142
+ // header), so the only safe discriminator is its live pidfile. Union all scopes'
143
+ // recorded pids, keep the live ones, and exclude them from the kill-set.
144
+ const sparePids = (0, cache_1.readAllWatchPids)().filter(cache_1.isPidAlive);
145
+ const pids = excludeSpared(candidates, sparePids);
146
+ const spared = candidates.length - pids.length;
147
+ if (dryRun || pids.length === 0) {
148
+ return { found: pids.length, killed: 0, pids, spared };
149
+ }
150
+ // SIGTERM each (best-effort, per-pid try so one EPERM doesn't abort the sweep).
151
+ for (const pid of pids) {
152
+ try {
153
+ process.kill(pid, 'SIGTERM');
154
+ }
155
+ catch {
156
+ /* already gone / not ours */
157
+ }
158
+ }
159
+ // Brief grace, then SIGKILL any survivor.
160
+ sleepSync(1.5);
161
+ let killed = 0;
162
+ for (const pid of pids) {
163
+ let alive = false;
164
+ try {
165
+ process.kill(pid, 0);
166
+ alive = true;
167
+ }
168
+ catch {
169
+ alive = false; // ESRCH ⇒ the SIGTERM took
170
+ }
171
+ if (alive) {
172
+ try {
173
+ process.kill(pid, 'SIGKILL');
174
+ }
175
+ catch {
176
+ /* lost the race / not ours */
177
+ }
178
+ }
179
+ // Count it killed if it is now gone.
180
+ try {
181
+ process.kill(pid, 0);
182
+ }
183
+ catch {
184
+ killed++;
185
+ }
186
+ }
187
+ return { found: pids.length, killed, pids, spared };
188
+ }
189
+ /** CLI action for `convene watch-reap`. Prints a one-line summary. Never throws. */
190
+ async function watchReap(opts = {}) {
191
+ try {
192
+ const res = reapWatchers({ dryRun: opts.dryRun });
193
+ if (res.note) {
194
+ process.stdout.write(`watch-reap: ${res.note}\n`);
195
+ return;
196
+ }
197
+ const sparedTail = res.spared > 0 ? `; spared ${res.spared} live-owned` : '';
198
+ if (opts.dryRun) {
199
+ process.stdout.write(res.found === 0
200
+ ? `watch-reap: no orphaned watchers to reap${sparedTail}\n`
201
+ : `watch-reap: would reap ${res.found} orphaned watcher(s): ${res.pids.join(', ')}${sparedTail}\n`);
202
+ }
203
+ else {
204
+ process.stdout.write(res.found === 0
205
+ ? `watch-reap: no orphaned watchers to reap${sparedTail}\n`
206
+ : `watch-reap: reaped ${res.killed}/${res.found} orphaned watcher(s)${sparedTail}\n`);
207
+ }
208
+ }
209
+ catch {
210
+ /* fail-open: cleanup must never crash */
211
+ }
212
+ }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.watchShouldExit = watchShouldExit;
3
4
  exports.watch = watch;
4
5
  /**
5
6
  * `convene watch` (WP12) — a DETACHED long-poll on the existing /poll stream,
@@ -41,6 +42,42 @@ const POLL_WAIT_SEC = 25; // server holds up to ~25s; capped at 50 server-side
41
42
  const POLL_TIMEOUT_MS = POLL_WAIT_SEC * 1000 + 5000; // MUST exceed wait*1000
42
43
  const BACKOFF_BASE_MS = 1000;
43
44
  const BACKOFF_MAX_MS = 30_000;
45
+ /**
46
+ * SELF-TERMINATION (the daemon-leak fix). A `convene watch` is spawned
47
+ * detached+unref at SessionStart, so it OUTLIVES its session and reparents to
48
+ * PID 1 — and the original loop had NO exit path, leaking ~one orphan per boot
49
+ * (145 observed, ~1 day old). Two independent ceilings now guarantee it dies:
50
+ *
51
+ * - MAX_RUNTIME_MS — an absolute lifetime cap (12h prod; tests set it tiny). A
52
+ * watcher can never outlive this no matter what.
53
+ * - IDLE_EXIT_MS — exit once the OWNING session has gone quiet. The watcher
54
+ * inherits CLAUDE_CODE_SESSION_ID, so liveSessionCount(slug, window) reports
55
+ * whether the owner is still actively fetching (it rewrites its feed .json
56
+ * each prompt). Zero live sessions ⇒ start an idle clock; 20 min idle ⇒ exit.
57
+ *
58
+ * Total time-to-die for a genuinely-closed session ≈ LIVE_SESSION_WINDOW_SEC
59
+ * (10m, for the owner's last .json to age out) + IDLE_EXIT_MS (20m) ≈ 30m. That
60
+ * is deliberately longer than any plausible heads-down turn, and is FAIL-OPEN:
61
+ * if we kill a watcher whose session is merely mid-long-turn, the next
62
+ * SessionStart relaunches one — the only cost is a brief mid-turn-halt blind
63
+ * spot, never a crash. A newer watcher taking over the same scope (pidfile
64
+ * newest-wins) also retires the older one immediately.
65
+ */
66
+ const MAX_RUNTIME_MS = (0, config_1.resolveWatchMaxMs)();
67
+ const IDLE_EXIT_MS = 20 * 60 * 1000;
68
+ /**
69
+ * PURE termination decision (unit-tested). Exit when the absolute lifetime cap is
70
+ * hit, OR the owner has been idle past the idle ceiling. `idleSince == null`
71
+ * means the owner is currently live → never an idle exit.
72
+ */
73
+ function watchShouldExit(args) {
74
+ const { startedAt, now, maxRuntimeMs, idleSince, idleExitMs } = args;
75
+ if (now - startedAt >= maxRuntimeMs)
76
+ return true;
77
+ if (idleSince != null && now - idleSince >= idleExitMs)
78
+ return true;
79
+ return false;
80
+ }
44
81
  const HALT_TYPES = new Set(['halt', 'interrupt']);
45
82
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
83
  /** Map a server feed/poll message to an inert WatchEntry. Returns null for non-halts. */
@@ -102,36 +139,82 @@ async function loop(opts) {
102
139
  let backoff = BACKOFF_BASE_MS;
103
140
  let iterations = 0;
104
141
  const limit = typeof opts.maxIterations === 'number' ? opts.maxIterations : Infinity;
142
+ // Claim ownership of this session's scope + record our birth time. The pidfile
143
+ // makes us discoverable to the spawn-dedup guards and enables newest-wins
144
+ // handover (a later watcher overwrites it, and we notice + exit).
145
+ const startedAt = Date.now();
146
+ (0, cache_1.writeWatchPid)(slug);
147
+ let idleSince = null;
105
148
  // Heartbeat up-front so a just-launched watch reads as healthy immediately.
106
149
  (0, cache_1.touchWatchHeartbeat)(slug);
107
- while (iterations < limit) {
108
- const res = await api.poll(slug, { since: cursor, wait: POLL_WAIT_SEC }, POLL_TIMEOUT_MS).catch(() => null);
109
- // Every loop iteration stamps liveness even an empty/failed poll proves the
110
- // daemon is alive (the health line distinguishes "down" from "quiet").
111
- (0, cache_1.touchWatchHeartbeat)(slug);
112
- if (!res || !res.ok || !res.json) {
113
- // Transport failure / timeout / parse error → self-heal with backoff.
114
- await sleep(backoff);
115
- backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
116
- continue;
117
- }
118
- backoff = BACKOFF_BASE_MS; // recovered
119
- const msgs = Array.isArray(res.json.messages) ? res.json.messages : [];
120
- for (const m of msgs) {
121
- const entry = toEntry(m);
122
- if (!entry)
150
+ try {
151
+ while (iterations < limit) {
152
+ // Termination checks run at the TOP of every iteration so they fire
153
+ // regardless of poll success/backoff. Wrapped fail-open: any error here
154
+ // SKIPS the check and continues — a termination-check fault must never crash
155
+ // the daemon (and a wrongly-killed watcher would only be relaunched).
156
+ try {
157
+ // Newest-wins: a DIFFERENT pid in our scope's pidfile means a fresher
158
+ // watcher took over retire ourselves immediately.
159
+ const owner = (0, cache_1.readWatchPid)(slug);
160
+ if (owner && owner.pid !== process.pid)
161
+ break;
162
+ // Owner liveness: zero live sessions the owning session has gone quiet,
163
+ // so start (or keep) the idle clock; any live session resets it.
164
+ const live = (0, cache_1.liveSessionCount)(slug, cache_1.LIVE_SESSION_WINDOW_SEC);
165
+ if (live === 0) {
166
+ if (idleSince == null)
167
+ idleSince = Date.now();
168
+ }
169
+ else {
170
+ idleSince = null;
171
+ }
172
+ if (watchShouldExit({
173
+ startedAt,
174
+ now: Date.now(),
175
+ maxRuntimeMs: MAX_RUNTIME_MS,
176
+ idleSince,
177
+ idleExitMs: IDLE_EXIT_MS,
178
+ })) {
179
+ break;
180
+ }
181
+ }
182
+ catch {
183
+ /* fail-open: a faulty termination check never crashes the daemon */
184
+ }
185
+ const res = await api.poll(slug, { since: cursor, wait: POLL_WAIT_SEC }, POLL_TIMEOUT_MS).catch(() => null);
186
+ // Every loop iteration stamps liveness — even an empty/failed poll proves the
187
+ // daemon is alive (the health line distinguishes "down" from "quiet").
188
+ (0, cache_1.touchWatchHeartbeat)(slug);
189
+ if (!res || !res.ok || !res.json) {
190
+ // Transport failure / timeout / parse error → self-heal with backoff.
191
+ await sleep(backoff);
192
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
123
193
  continue;
124
- (0, cache_1.appendWatchEntry)(slug, entry);
125
- if (opts.notify)
126
- notifyBestEffort(entry);
194
+ }
195
+ backoff = BACKOFF_BASE_MS; // recovered
196
+ const msgs = Array.isArray(res.json.messages) ? res.json.messages : [];
197
+ for (const m of msgs) {
198
+ const entry = toEntry(m);
199
+ if (!entry)
200
+ continue;
201
+ (0, cache_1.appendWatchEntry)(slug, entry);
202
+ if (opts.notify)
203
+ notifyBestEffort(entry);
204
+ }
205
+ // Advance the resume cursor to the server's reported cursor (monotonic). This
206
+ // is the long-poll resume seq, NOT the reader's high-water — the daemon must
207
+ // move past EVERY message it saw (incl. non-halts) or it would re-fetch them
208
+ // forever. The reader's high-water only advances over rendered halts.
209
+ if (typeof res.json.cursor === 'number' && res.json.cursor > cursor)
210
+ cursor = res.json.cursor;
211
+ iterations++;
127
212
  }
128
- // Advance the resume cursor to the server's reported cursor (monotonic). This
129
- // is the long-poll resume seq, NOT the reader's high-water — the daemon must
130
- // move past EVERY message it saw (incl. non-halts) or it would re-fetch them
131
- // forever. The reader's high-water only advances over rendered halts.
132
- if (typeof res.json.cursor === 'number' && res.json.cursor > cursor)
133
- cursor = res.json.cursor;
134
- iterations++;
213
+ }
214
+ finally {
215
+ // Release our scope's pidfile so the next SessionStart spawns freely but
216
+ // ONLY if it still names us (a newer watcher may already own it).
217
+ (0, cache_1.clearWatchPidIfOwner)(slug);
135
218
  }
136
219
  return 0;
137
220
  }
@@ -3,7 +3,10 @@ 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.copyWorktreeIncludes = copyWorktreeIncludes;
7
+ exports.provisionWorktree = provisionWorktree;
6
8
  exports.worktree = worktree;
9
+ exports.provisionAutoWorktree = provisionAutoWorktree;
7
10
  /**
8
11
  * `convene worktree <branch>` — create an isolated git worktree for a parallel
9
12
  * session. This is Convene's recommended default for running several coding agents
@@ -14,6 +17,10 @@ exports.worktree = worktree;
14
17
  *
15
18
  * DIE-LOUD like the other interactive verbs (stderr + non-zero exit on failure).
16
19
  * Pure git plumbing — does NOT require the repo to be on the Convene bus.
20
+ *
21
+ * The provisioning CORE (`provisionWorktree`) is factored out of the command so
22
+ * the auto-isolate SessionStart path (`provisionAutoWorktree`) can reuse the exact
23
+ * same git plumbing + `.worktreeinclude` copy — fail-OPEN there, die-LOUD here.
17
24
  */
18
25
  const node_child_process_1 = require("node:child_process");
19
26
  const node_path_1 = __importDefault(require("node:path"));
@@ -24,6 +31,88 @@ function refExists(ref, cwd) {
24
31
  const r = (0, node_child_process_1.spawnSync)('git', ['rev-parse', '--verify', '--quiet', ref], { cwd, encoding: 'utf8' });
25
32
  return r.status === 0;
26
33
  }
34
+ /**
35
+ * The `.worktreeinclude` format (read from `<top>/.worktreeinclude`):
36
+ * - one relative path per line, resolved against the SOURCE worktree's toplevel;
37
+ * - lines beginning with `#` are comments; blank/whitespace-only lines are ignored;
38
+ * - a leading `!` or absolute path or `..` traversal is rejected (never escapes <top>);
39
+ * - each entry may be a file OR a directory (copied recursively);
40
+ * - a missing entry is silently skipped (gitignored config that just isn't present).
41
+ *
42
+ * Purpose: carry gitignored local config (e.g. `.env`, `.envrc`, `.claude/settings.local.json`)
43
+ * from the source checkout into a freshly-provisioned worktree, since git itself
44
+ * only materializes TRACKED files. Best-effort: any per-entry error is swallowed so
45
+ * a copy failure can never wedge worktree creation (critical on the auto-isolate path).
46
+ */
47
+ function copyWorktreeIncludes(top, dest) {
48
+ let raw;
49
+ try {
50
+ raw = node_fs_1.default.readFileSync(node_path_1.default.join(top, '.worktreeinclude'), 'utf8');
51
+ }
52
+ catch {
53
+ return; // no include file → nothing to carry
54
+ }
55
+ for (const line of raw.split('\n')) {
56
+ const entry = line.trim();
57
+ if (!entry || entry.startsWith('#'))
58
+ continue;
59
+ // Reject anything that could escape <top>: leading '!', absolute, or any '..' segment.
60
+ if (entry.startsWith('!') || node_path_1.default.isAbsolute(entry))
61
+ continue;
62
+ const rel = node_path_1.default.normalize(entry);
63
+ if (rel === '..' || rel.startsWith('..' + node_path_1.default.sep) || rel.split(node_path_1.default.sep).includes('..'))
64
+ continue;
65
+ const src = node_path_1.default.join(top, rel);
66
+ const dst = node_path_1.default.join(dest, rel);
67
+ try {
68
+ if (!node_fs_1.default.existsSync(src))
69
+ continue; // skip missing entries
70
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(dst), { recursive: true });
71
+ node_fs_1.default.cpSync(src, dst, { recursive: true });
72
+ }
73
+ catch {
74
+ /* best-effort per entry — never let a copy failure wedge provisioning */
75
+ }
76
+ }
77
+ }
78
+ /**
79
+ * Hard ceiling on `git worktree add` (a full working-tree checkout). On the AUTO
80
+ * path this runs SYNCHRONOUSLY inside SessionStart, and the boot's async 6s
81
+ * watchdog cannot interrupt synchronous work — so a large/slow repo could stall the
82
+ * boot. A bounded spawnSync caps that: a timeout kills git and we fail-open.
83
+ */
84
+ const WORKTREE_ADD_TIMEOUT_MS = 20_000;
85
+ /**
86
+ * The reusable provisioning core: resolve the branch (existing local / existing
87
+ * remote-only / new), run `git worktree add`, then copy `.worktreeinclude` config
88
+ * into the new tree. Returns {dest, branch} on success or null on ANY failure —
89
+ * the caller decides whether to die-loud (interactive) or fail-open (auto).
90
+ */
91
+ function provisionWorktree(args) {
92
+ const { top, branch, dest, fromRef, quiet } = args;
93
+ if (node_fs_1.default.existsSync(dest))
94
+ return null;
95
+ const localExists = refExists(`refs/heads/${branch}`, top);
96
+ const remoteExists = !localExists && refExists(`refs/remotes/origin/${branch}`, top);
97
+ // Existing local branch → check it out; existing remote-only branch → create a
98
+ // local tracking branch; otherwise → new branch from fromRef (or HEAD).
99
+ const gitArgs = localExists
100
+ ? ['worktree', 'add', dest, branch]
101
+ : remoteExists
102
+ ? ['worktree', 'add', '-b', branch, dest, `origin/${branch}`]
103
+ : ['worktree', 'add', '-b', branch, dest, fromRef || 'HEAD'];
104
+ const r = (0, node_child_process_1.spawnSync)('git', gitArgs, {
105
+ cwd: top,
106
+ stdio: quiet ? 'ignore' : 'inherit',
107
+ timeout: WORKTREE_ADD_TIMEOUT_MS,
108
+ });
109
+ if (r.status !== 0)
110
+ return null;
111
+ // Carry gitignored local config (e.g. .env) into the new tree — best-effort.
112
+ copyWorktreeIncludes(top, dest);
113
+ const branchNote = localExists ? '' : remoteExists ? ` (new, tracking origin/${branch})` : ' (new)';
114
+ return { dest, branch, branchNote };
115
+ }
27
116
  function worktree(branch, opts = {}) {
28
117
  const top = (0, git_1.gitToplevel)();
29
118
  if (!top)
@@ -35,29 +124,78 @@ function worktree(branch, opts = {}) {
35
124
  const dest = node_path_1.default.resolve(opts.path || node_path_1.default.join(node_path_1.default.dirname(top), `${base}-${safeBranch}`));
36
125
  if (node_fs_1.default.existsSync(dest))
37
126
  (0, ctx_1.die)(`destination already exists: ${dest}`);
38
- const localExists = refExists(`refs/heads/${branch}`, top);
39
- const remoteExists = !localExists && refExists(`refs/remotes/origin/${branch}`, top);
40
- // Existing local branch → check it out; existing remote-only branch → create a
41
- // local tracking branch; otherwise → new branch from --from (or HEAD).
42
- const args = localExists
43
- ? ['worktree', 'add', dest, branch]
44
- : remoteExists
45
- ? ['worktree', 'add', '-b', branch, dest, `origin/${branch}`]
46
- : ['worktree', 'add', '-b', branch, dest, opts.from || 'HEAD'];
47
- const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: top, stdio: 'inherit' });
48
- if (r.status !== 0)
49
- (0, ctx_1.die)(`git worktree add failed (exit ${r.status ?? '?'})`);
50
- const branchNote = localExists ? '' : remoteExists ? ` (new, tracking origin/${branch})` : ' (new)';
127
+ const res = provisionWorktree({ top: top, branch, dest, fromRef: opts.from });
128
+ if (!res)
129
+ (0, ctx_1.die)(`git worktree add failed`);
51
130
  process.stdout.write([
52
131
  ``,
53
- `✓ worktree ready: ${dest}`,
54
- ` branch: ${branch}${branchNote}`,
132
+ `✓ worktree ready: ${res.dest}`,
133
+ ` branch: ${branch}${res.branchNote}`,
55
134
  ``,
56
135
  `Start a FRESH agent session inside it so it gets its own Convene identity:`,
57
- ` cd ${dest}`,
136
+ ` cd ${res.dest}`,
58
137
  ` # install deps for this package if needed, then launch your agent (e.g. \`claude\`)`,
59
138
  ``,
60
- `Remove it when done: git worktree remove ${dest}`,
139
+ `Remove it when done: git worktree remove ${res.dest}`,
61
140
  ``,
62
141
  ].join('\n'));
63
142
  }
143
+ /** A safe, filesystem-friendly token from an arbitrary string (for branch/dir names). */
144
+ function safeToken(s) {
145
+ return s.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'sess';
146
+ }
147
+ /**
148
+ * The AUTO-ISOLATE provisioning entry point used by SessionStart when a session
149
+ * boots into a checkout that already has a live sibling. FAIL-OPEN end-to-end:
150
+ * returns {dest, branch} on success or null on ANY error — the boot proceeds
151
+ * normally on null.
152
+ *
153
+ * Strategy:
154
+ * 1. Best-effort refresh the base (`git fetch origin main`) so the new tree is
155
+ * based on fresh upstream, not a stale local tip. A fetch failure is ignored.
156
+ * 2. Pick the base ref: the current branch's upstream if it has one, else
157
+ * `origin/main` (falling back to `HEAD` only if neither resolves).
158
+ * 3. Compute a NON-colliding branch name `auto/<disc-or-member>-<short>` and a
159
+ * NON-colliding sibling destination, bumping a counter on collision.
160
+ * 4. provisionWorktree(...) (which also copies `.worktreeinclude`).
161
+ */
162
+ function provisionAutoWorktree(top, slug) {
163
+ try {
164
+ // 1. Best-effort refresh of the canonical base.
165
+ (0, git_1.gitFetch)('main', 'origin', top);
166
+ // 2. Base ref: current branch's upstream, else origin/main, else HEAD.
167
+ let fromRef = 'origin/main';
168
+ if (!refExists('refs/remotes/origin/main', top))
169
+ fromRef = 'HEAD';
170
+ const cur = (0, git_1.currentBranch)(top);
171
+ if (cur) {
172
+ const up = (0, node_child_process_1.spawnSync)('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${cur}@{upstream}`], {
173
+ cwd: top,
174
+ encoding: 'utf8',
175
+ });
176
+ const upstream = up.status === 0 ? (up.stdout || '').trim() : '';
177
+ if (upstream && refExists(`refs/remotes/${upstream}`, top))
178
+ fromRef = upstream;
179
+ }
180
+ // 3. Non-colliding branch name + destination.
181
+ const disc = (0, git_1.sessionDiscriminator)();
182
+ const tag = safeToken(disc || slug);
183
+ const base = (0, git_1.worktreeBasename)(top);
184
+ const parent = node_path_1.default.dirname(top);
185
+ let branch = `auto/${tag}`;
186
+ let dest = node_path_1.default.join(parent, `${base}-auto-${tag}`);
187
+ let n = 1;
188
+ while (refExists(`refs/heads/${branch}`, top) || node_fs_1.default.existsSync(dest)) {
189
+ n += 1;
190
+ branch = `auto/${tag}-${n}`;
191
+ dest = node_path_1.default.join(parent, `${base}-auto-${tag}-${n}`);
192
+ if (n > 50)
193
+ return null; // pathological — give up rather than spin
194
+ }
195
+ const res = provisionWorktree({ top, branch, dest, fromRef, quiet: true });
196
+ return res ? { dest: res.dest, branch: res.branch } : null;
197
+ }
198
+ catch {
199
+ return null; // fail-open: any error → no relocation, boot proceeds
200
+ }
201
+ }
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.CACHE_DIR = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
7
7
  exports.homeBase = homeBase;
8
8
  exports.resolveFetchTimeoutMs = resolveFetchTimeoutMs;
9
+ exports.resolveWatchMaxMs = resolveWatchMaxMs;
9
10
  exports.isWorldReadable = isWorldReadable;
10
11
  exports.loadFileConfig = loadFileConfig;
11
12
  exports.loadProjectConfig = loadProjectConfig;
@@ -42,6 +43,21 @@ function resolveFetchTimeoutMs(fallback = 4000) {
42
43
  const n = raw ? Number(raw) : NaN;
43
44
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
44
45
  }
46
+ /**
47
+ * Hard ceiling (ms) on a single `convene watch` daemon's total lifetime — the
48
+ * absolute-runtime half of the leak fix (the other half is idle exit; see
49
+ * watch.ts). A detached watcher self-terminates once it has run this long so an
50
+ * orphaned daemon can never live forever (the 145-leaked-watchers bug). Mirrors
51
+ * resolveFetchTimeoutMs: a positive finite CONVENE_WATCH_MAX_MS wins (floored),
52
+ * else the fallback. Tests drive it tiny for a deterministic exit; the production
53
+ * default is 12h — comfortably longer than any real heads-down turn, and a fresh
54
+ * watcher is relaunched at the next SessionStart regardless.
55
+ */
56
+ function resolveWatchMaxMs(fallback = 12 * 60 * 60 * 1000) {
57
+ const raw = process.env.CONVENE_WATCH_MAX_MS;
58
+ const n = raw ? Number(raw) : NaN;
59
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
60
+ }
45
61
  exports.CONFIG_DIR = node_path_1.default.join(homeBase(), brand_1.BRAND.configDir);
46
62
  exports.CONFIG_FILE = node_path_1.default.join(exports.CONFIG_DIR, 'config.json');
47
63
  exports.CACHE_DIR = node_path_1.default.join(exports.CONFIG_DIR, 'cache');
package/dist/index.js CHANGED
@@ -57,9 +57,11 @@ const lane_1 = require("./commands/lane");
57
57
  const deploy_1 = require("./commands/deploy");
58
58
  const guard_1 = require("./commands/guard");
59
59
  const gate_push_1 = require("./commands/gate-push");
60
+ const beat_1 = require("./commands/beat");
60
61
  const practice_guard_1 = require("./commands/practice-guard");
61
62
  const override_1 = require("./commands/override");
62
63
  const watch_1 = require("./commands/watch");
64
+ const watch_reap_1 = require("./commands/watch-reap");
63
65
  const explain_1 = require("./commands/explain");
64
66
  const practices_1 = require("./commands/practices");
65
67
  const update_1 = require("./commands/update");
@@ -168,12 +170,22 @@ program
168
170
  .option('--reason <text>', 'why the gate is being overridden (required; attributed to the bus)')
169
171
  .option('--project <slug>')
170
172
  .action((id, opts) => (0, override_1.override)(id, opts));
173
+ program
174
+ .command('beat')
175
+ .description('PostToolUse hook: debounced session activity-beat (presence), fail-open + fast')
176
+ .option('--stdin', 'read the PostToolUse JSON payload from stdin (to derive the coarse area)')
177
+ .action((opts) => (0, beat_1.beat)(opts));
171
178
  program
172
179
  .command('watch')
173
180
  .description('SessionStart-launched detached long-poll for directed halts (fail-open, self-healing)')
174
181
  .option('--notify', 'best-effort desktop notification per surfaced halt')
175
182
  .option('--project <slug>')
176
183
  .action((opts) => (0, watch_1.watch)(opts));
184
+ program
185
+ .command('watch-reap')
186
+ .description('reap orphaned (PID-1) detached `convene watch` daemons (POSIX-only, fail-open)')
187
+ .option('--dry-run', 'list reapable watchers; kill nothing')
188
+ .action((opts) => (0, watch_reap_1.watchReap)({ dryRun: opts.dryRun }));
177
189
  program
178
190
  .command('notify-push')
179
191
  .description('git pre-push hook: post a [STATUS] summarizing the push (fail-silent)')