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 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
@@ -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
- * `title [defaultLevel]` and accept enter=keep, 's'=skip, or a level name from
60
- * availableLevels. Skimmable; unknown levels re-prompt-free (keep at default).
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
- const ans = (await rl.question(` ${p.title} [${sel.level}] (${levels}): `)).trim().toLowerCase();
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 === '') {
@@ -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
- /** (Re)launch `convene watch` detached so it outlives this doctor process. */
25
- function relaunchWatch() {
27
+ /**
28
+ * (Re)launch `convene watch` detached so it outlives this doctor process — but
29
+ * SKIP if a live watcher already owns this scope (the daemon-leak dedup guard),
30
+ * so `doctor --fix` never piles up a duplicate. Returns true iff a watcher is
31
+ * now (re)launched or already running.
32
+ */
33
+ function relaunchWatch(slug) {
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
+ }
@@ -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
- const FETCH_TIMEOUT_MS = 4000;
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) {
@@ -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
- const FETCH_TIMEOUT_MS = 4000;
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
- const FEED_ATTEMPT_MS = 3500;
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
+ }