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 +9 -0
- package/dist/cache.js +173 -1
- package/dist/commands/auth.js +33 -3
- package/dist/commands/beat.js +145 -0
- package/dist/commands/fetch.js +14 -0
- package/dist/commands/init.js +7 -0
- package/dist/commands/session-start.js +72 -5
- package/dist/commands/watch-reap.js +212 -0
- package/dist/commands/watch.js +109 -26
- package/dist/commands/worktree.js +155 -17
- package/dist/config.js +16 -0
- package/dist/index.js +12 -0
- package/dist/render.js +45 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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)')
|