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
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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
/**
|
|
27
|
-
|
|
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
|
+
}
|
package/dist/commands/fetch.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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.
|
|
43
|
-
*
|
|
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; //
|
|
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
|
|
84
|
-
// sentinel so the first fetch doesn't
|
|
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 = {}) {
|