convene-cli 1.5.1 → 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.
- package/dist/api.js +9 -0
- package/dist/cache.js +173 -1
- package/dist/catalog/prompt.js +27 -5
- package/dist/commands/auth.js +128 -3
- package/dist/commands/beat.js +145 -0
- package/dist/commands/catchup.js +3 -1
- package/dist/commands/fetch.js +20 -3
- package/dist/commands/init.js +11 -1
- package/dist/commands/session-start.js +77 -7
- 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 +30 -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/catalog/prompt.js
CHANGED
|
@@ -3,6 +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.practiceBlurb = practiceBlurb;
|
|
6
7
|
exports.pickPracticesInteractively = pickPracticesInteractively;
|
|
7
8
|
/**
|
|
8
9
|
* Interactive best-practices picker for `convene init` / `convene setup`.
|
|
@@ -16,6 +17,24 @@ exports.pickPracticesInteractively = pickPracticesInteractively;
|
|
|
16
17
|
const promises_1 = __importDefault(require("node:readline/promises"));
|
|
17
18
|
const select_1 = require("./select");
|
|
18
19
|
const out = (m) => process.stdout.write(m + '\n');
|
|
20
|
+
/** Truncate to ~n chars on a soft boundary, adding an ellipsis when clipped. */
|
|
21
|
+
function truncate(s, n) {
|
|
22
|
+
if (s.length <= n)
|
|
23
|
+
return s;
|
|
24
|
+
return s.slice(0, n - 1).trimEnd() + '…';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The provenance/rationale context shown above each practice in the customizer, so
|
|
28
|
+
* a user can make an INFORMED adoption choice (feedback 52269531 — the picker used
|
|
29
|
+
* to show only a bare title). Renders the practice title, a production-learned
|
|
30
|
+
* marker (the provenance the Practice.productionLearned comment promises), and a
|
|
31
|
+
* truncated `why`. Pure + exported so it can be asserted without a TTY.
|
|
32
|
+
*/
|
|
33
|
+
function practiceBlurb(p) {
|
|
34
|
+
const mark = p.productionLearned ? ' ★ production-learned' : '';
|
|
35
|
+
const why = p.why ? `\n why: ${truncate(p.why, 110)}` : '';
|
|
36
|
+
return ` ${p.title}${mark}${why}`;
|
|
37
|
+
}
|
|
19
38
|
/** Count of opt-in (default-ON) practices in the named tiers — for the menu labels. */
|
|
20
39
|
function presetCount(catalog, preset) {
|
|
21
40
|
return (0, select_1.presetSelections)(catalog, preset).length;
|
|
@@ -55,22 +74,25 @@ async function pickPracticesInteractively(catalog) {
|
|
|
55
74
|
}
|
|
56
75
|
}
|
|
57
76
|
/**
|
|
58
|
-
* Customize from the recommended preset: for each chosen practice show
|
|
59
|
-
*
|
|
60
|
-
* availableLevels. Skimmable; unknown levels
|
|
77
|
+
* Customize from the recommended preset: for each chosen practice show its title,
|
|
78
|
+
* provenance, and a one-line `why` (via practiceBlurb), then accept enter=keep,
|
|
79
|
+
* 's'=skip, or a level name from availableLevels. Skimmable; unknown levels
|
|
80
|
+
* re-prompt-free (keep at default).
|
|
61
81
|
*/
|
|
62
82
|
async function customize(catalog, rl) {
|
|
63
83
|
const base = (0, select_1.presetSelections)(catalog, 'recommended');
|
|
64
84
|
const byId = new Map(catalog.practices.map((p) => [p.id, p]));
|
|
65
85
|
const result = [];
|
|
66
86
|
out('');
|
|
67
|
-
out('Customize — for each: [enter] keep · [s] skip · or type a level name.');
|
|
87
|
+
out('Customize — for each: [enter] keep the suggested level · [s] skip · or type a level name.');
|
|
68
88
|
for (const sel of base) {
|
|
69
89
|
const p = byId.get(sel.id);
|
|
70
90
|
if (!p)
|
|
71
91
|
continue;
|
|
72
92
|
const levels = p.availableLevels.join('/');
|
|
73
|
-
|
|
93
|
+
out('');
|
|
94
|
+
out(practiceBlurb(p));
|
|
95
|
+
const ans = (await rl.question(` [${sel.level}] (${levels}): `)).trim().toLowerCase();
|
|
74
96
|
if (ans === 's' || ans === 'skip')
|
|
75
97
|
continue;
|
|
76
98
|
if (ans === '') {
|
package/dist/commands/auth.js
CHANGED
|
@@ -6,9 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.login = login;
|
|
7
7
|
exports.whoami = whoami;
|
|
8
8
|
exports.assessLaneIdentity = assessLaneIdentity;
|
|
9
|
+
exports.assessSettingsIntegrity = assessSettingsIntegrity;
|
|
9
10
|
exports.doctor = doctor;
|
|
10
11
|
/** login / whoami / doctor. */
|
|
11
12
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
14
|
const node_child_process_1 = require("node:child_process");
|
|
13
15
|
const brand_1 = require("../brand");
|
|
14
16
|
const api_1 = require("../api");
|
|
@@ -18,12 +20,23 @@ const manifest_1 = require("../catalog/manifest");
|
|
|
18
20
|
const git_1 = require("../git");
|
|
19
21
|
const hook_1 = require("../hook");
|
|
20
22
|
const cache_1 = require("../cache");
|
|
23
|
+
const watch_reap_1 = require("./watch-reap");
|
|
21
24
|
const ctx_1 = require("../ctx");
|
|
22
25
|
/** A watch heartbeat older than this (or absent) means the watcher is down. */
|
|
23
26
|
const WATCH_STALE_SEC = 90;
|
|
24
|
-
/**
|
|
25
|
-
|
|
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) {
|
|
26
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
|
+
}
|
|
27
40
|
const bin = process.argv[1] || '';
|
|
28
41
|
if (!bin)
|
|
29
42
|
return false;
|
|
@@ -152,6 +165,96 @@ function assessLaneIdentity(lanes, myHandle, haveInstance, staleSec = LANE_STALE
|
|
|
152
165
|
}
|
|
153
166
|
return { name, ok: true, detail: `holding ${mine.length} lane(s), all fresh and owned by this session` };
|
|
154
167
|
}
|
|
168
|
+
/** Count non-overlapping occurrences of `sub` in `s`. */
|
|
169
|
+
function countOccurrences(s, sub) {
|
|
170
|
+
if (!sub)
|
|
171
|
+
return 0;
|
|
172
|
+
let n = 0;
|
|
173
|
+
let i = s.indexOf(sub);
|
|
174
|
+
while (i >= 0) {
|
|
175
|
+
n++;
|
|
176
|
+
i = s.indexOf(sub, i + sub.length);
|
|
177
|
+
}
|
|
178
|
+
return n;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Non-destructiveness assertion (feedback 3749eac9 / dhparmele): Convene earns
|
|
182
|
+
* authority by scope + ADDITIVE merge, never by overwriting user-owned files. This
|
|
183
|
+
* check confirms that posture is intact:
|
|
184
|
+
* - the global + project `.claude/settings.json` parse as JSON (a file Convene
|
|
185
|
+
* would otherwise leave untouched but also could not merge into — worth flagging);
|
|
186
|
+
* - any Convene hook coexists with user-owned hooks (additivity, not whole-object
|
|
187
|
+
* replace) — reported as a positive note, NEVER a failure;
|
|
188
|
+
* - the CLAUDE.md / AGENTS.md Convene marker blocks are well-formed (a balanced,
|
|
189
|
+
* non-inverted begin/end pair) so a `--refresh-docs` re-render stays surgical.
|
|
190
|
+
* Fails (ok:false) ONLY on genuine corruption (invalid JSON / malformed markers),
|
|
191
|
+
* never on a user simply having their own hooks. Diagnostic only — no auto-repair.
|
|
192
|
+
* Exported + parameterized so it can be asserted hermetically without a TTY/network.
|
|
193
|
+
*/
|
|
194
|
+
function assessSettingsIntegrity(top, globalSettingsPath = hook_1.SETTINGS_PATH) {
|
|
195
|
+
const name = 'settings';
|
|
196
|
+
const problems = [];
|
|
197
|
+
const notes = [];
|
|
198
|
+
const settingsFiles = [{ label: 'global', file: globalSettingsPath }];
|
|
199
|
+
if (top)
|
|
200
|
+
settingsFiles.push({ label: 'project', file: (0, hook_1.projectSettingsPath)(top) });
|
|
201
|
+
for (const { label, file } of settingsFiles) {
|
|
202
|
+
if (!node_fs_1.default.existsSync(file))
|
|
203
|
+
continue;
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
const raw = node_fs_1.default.readFileSync(file, 'utf8');
|
|
207
|
+
parsed = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
problems.push(`${label} settings.json is not valid JSON — Convene leaves it untouched but cannot merge into it (${file})`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (parsed && typeof parsed === 'object' && parsed.hooks && typeof parsed.hooks === 'object') {
|
|
214
|
+
let convene = 0;
|
|
215
|
+
let foreign = 0;
|
|
216
|
+
for (const ev of Object.keys(parsed.hooks)) {
|
|
217
|
+
const groups = Array.isArray(parsed.hooks[ev]) ? parsed.hooks[ev] : [];
|
|
218
|
+
for (const g of groups) {
|
|
219
|
+
const isConvene = Array.isArray(g?.hooks) &&
|
|
220
|
+
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('convene'));
|
|
221
|
+
if (isConvene)
|
|
222
|
+
convene++;
|
|
223
|
+
else
|
|
224
|
+
foreign++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (foreign > 0)
|
|
228
|
+
notes.push(`${label}: ${foreign} user-owned hook(s) preserved alongside ${convene} Convene hook(s)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (top) {
|
|
232
|
+
for (const fname of ['CLAUDE.md', 'AGENTS.md']) {
|
|
233
|
+
const p = node_path_1.default.join(top, fname);
|
|
234
|
+
if (!node_fs_1.default.existsSync(p))
|
|
235
|
+
continue;
|
|
236
|
+
let content;
|
|
237
|
+
try {
|
|
238
|
+
content = node_fs_1.default.readFileSync(p, 'utf8');
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const begins = countOccurrences(content, brand_1.BRAND.blockBegin);
|
|
244
|
+
const ends = countOccurrences(content, brand_1.BRAND.blockEnd);
|
|
245
|
+
if (begins !== ends || begins > 1) {
|
|
246
|
+
problems.push(`${fname} has a malformed Convene marker block (${begins} begin / ${ends} end) — repair the markers before a refresh`);
|
|
247
|
+
}
|
|
248
|
+
else if (begins === 1 && content.indexOf(brand_1.BRAND.blockBegin) > content.indexOf(brand_1.BRAND.blockEnd)) {
|
|
249
|
+
problems.push(`${fname} Convene marker block is inverted (begin after end) — repair the markers`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (problems.length)
|
|
254
|
+
return { name, ok: false, detail: problems.join('; ') };
|
|
255
|
+
const tail = notes.length ? ` (${notes.join('; ')})` : '';
|
|
256
|
+
return { name, ok: true, detail: `settings JSON valid + marker blocks intact; Convene edits are additive${tail}` };
|
|
257
|
+
}
|
|
155
258
|
async function doctor(opts) {
|
|
156
259
|
const checks = [];
|
|
157
260
|
const cfg = (0, config_1.resolveConfig)();
|
|
@@ -228,6 +331,9 @@ async function doctor(opts) {
|
|
|
228
331
|
? 'UserPromptSubmit `convene fetch` registered'
|
|
229
332
|
: 'hook NOT registered (run `convene init` or `convene doctor --fix`)',
|
|
230
333
|
});
|
|
334
|
+
// 6b. settings non-destructiveness — Convene's edits stay additive + marker-scoped;
|
|
335
|
+
// user-owned hooks/files are never clobbered. Fails only on genuine corruption.
|
|
336
|
+
checks.push(assessSettingsIntegrity(top));
|
|
231
337
|
// 7. watch heartbeat — a stale/absent heartbeat means the mid-task halt watcher
|
|
232
338
|
// is DOWN (so directed halts won't surface between turns). Only meaningful for a
|
|
233
339
|
// repo on the bus; --fix may (re)launch `convene watch` detached.
|
|
@@ -235,7 +341,7 @@ async function doctor(opts) {
|
|
|
235
341
|
let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
|
|
236
342
|
let watchOk = age != null && age <= WATCH_STALE_SEC;
|
|
237
343
|
if (!watchOk && opts.fix) {
|
|
238
|
-
if (relaunchWatch()) {
|
|
344
|
+
if (relaunchWatch(proj.slug)) {
|
|
239
345
|
// Give the freshly-launched daemon a beat to stamp its first heartbeat.
|
|
240
346
|
const until = Date.now() + 2500;
|
|
241
347
|
while (Date.now() < until) {
|
|
@@ -255,6 +361,25 @@ async function doctor(opts) {
|
|
|
255
361
|
? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
|
|
256
362
|
: `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
|
|
257
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
|
+
}
|
|
258
383
|
}
|
|
259
384
|
// 7b. parallel sessions sharing ONE checkout. Several agents in the same
|
|
260
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/catchup.js
CHANGED
|
@@ -25,7 +25,9 @@ const cache_1 = require("../cache");
|
|
|
25
25
|
const api_1 = require("../api");
|
|
26
26
|
const exit_1 = require("../exit");
|
|
27
27
|
const render_1 = require("../render");
|
|
28
|
-
|
|
28
|
+
// Default 4000ms; overridable via CONVENE_FETCH_TIMEOUT_MS (tests drive it small for
|
|
29
|
+
// deterministic, load-independent latency-budget assertions). See config.ts.
|
|
30
|
+
const FETCH_TIMEOUT_MS = (0, config_1.resolveFetchTimeoutMs)();
|
|
29
31
|
const WATCHDOG_MS = 6000;
|
|
30
32
|
const MAX_ITEMS = 400;
|
|
31
33
|
function emit(s) {
|
package/dist/commands/fetch.js
CHANGED
|
@@ -29,7 +29,9 @@ const render_1 = require("../render");
|
|
|
29
29
|
const catchup_1 = require("./catchup");
|
|
30
30
|
const exit_1 = require("../exit");
|
|
31
31
|
const CACHE_TTL_SEC = 3;
|
|
32
|
-
|
|
32
|
+
// Default 4000ms; overridable via CONVENE_FETCH_TIMEOUT_MS (tests drive it small for
|
|
33
|
+
// deterministic, load-independent latency-budget assertions). See config.ts.
|
|
34
|
+
const FETCH_TIMEOUT_MS = (0, config_1.resolveFetchTimeoutMs)();
|
|
33
35
|
const WATCHDOG_MS = 6000;
|
|
34
36
|
/** Catalog-version cache TTL for the behind-nudge — long enough to stay off the hot path. */
|
|
35
37
|
const CATALOG_VERSION_TTL_SEC = 3600;
|
|
@@ -50,8 +52,9 @@ const CATALOG_VERSION_TTL_SEC = 3600;
|
|
|
50
52
|
// fast blip, given the bus normally answers in ~0.15s.
|
|
51
53
|
/** Total network budget for the feed fetch across all attempts (== FETCH_TIMEOUT_MS). */
|
|
52
54
|
const FEED_BUDGET_MS = FETCH_TIMEOUT_MS;
|
|
53
|
-
/** First-attempt timeout. ~500ms under FETCH_TIMEOUT_MS, leaving room for a retry.
|
|
54
|
-
|
|
55
|
+
/** First-attempt timeout. ~500ms under FETCH_TIMEOUT_MS, leaving room for a retry.
|
|
56
|
+
* Derived from FETCH_TIMEOUT_MS so a small env override never exceeds the budget. */
|
|
57
|
+
const FEED_ATTEMPT_MS = Math.max(250, FETCH_TIMEOUT_MS - 500);
|
|
55
58
|
/** A failure faster than this is treated as a transient blip worth one retry. */
|
|
56
59
|
exports.FEED_FAST_FAIL_MS = 1200;
|
|
57
60
|
/** Brief pause before the retry, to let a restarting task come back. */
|
|
@@ -284,7 +287,21 @@ async function runFetch(opts = {}) {
|
|
|
284
287
|
lookbackMin: ctx.lookback,
|
|
285
288
|
openItems: toRenderMessages(data?.inbox ?? []),
|
|
286
289
|
recent: toRenderMessages(data?.messages ?? []),
|
|
290
|
+
presence: toRenderPresence(data?.presence ?? [], data?.server_time),
|
|
287
291
|
health: { state: 'ok', syncedAgoSec: ctx.syncedAgoSec, openCount: Number(data?.open_for_you ?? (data?.inbox?.length ?? 0)) },
|
|
288
292
|
}), opts.codexHook);
|
|
289
293
|
}
|
|
290
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
|
+
}
|