convene-cli 1.0.5 → 1.1.1

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
@@ -8,11 +8,24 @@ class ConveneApi {
8
8
  apiKey;
9
9
  session;
10
10
  tool;
11
- constructor(baseUrl, apiKey, session = null, tool = null) {
11
+ instance;
12
+ constructor(baseUrl, apiKey, session = null, tool = null,
13
+ /**
14
+ * Server-trusted opaque session-instance id (X-Convene-Session-Instance).
15
+ * The server scopes it to principal.memberId to stamp holder_instance; it
16
+ * never authorizes anything on its own. Optional — absent on legacy callers.
17
+ */
18
+ instance = null) {
12
19
  this.baseUrl = baseUrl;
13
20
  this.apiKey = apiKey;
14
21
  this.session = session;
15
22
  this.tool = tool;
23
+ this.instance = instance;
24
+ }
25
+ /** Attach/replace the session-instance id after construction (SessionStart mint). */
26
+ withInstance(instance) {
27
+ this.instance = instance;
28
+ return this;
16
29
  }
17
30
  async request(method, apiPath, opts = {}) {
18
31
  const ctrl = new AbortController();
@@ -25,6 +38,8 @@ class ConveneApi {
25
38
  headers['x-convene-session'] = this.session;
26
39
  if (this.tool)
27
40
  headers['x-convene-tool'] = this.tool;
41
+ if (this.instance)
42
+ headers['x-convene-session-instance'] = this.instance;
28
43
  if (opts.idempotencyKey)
29
44
  headers['idempotency-key'] = opts.idempotencyKey;
30
45
  const res = await fetch(`${this.baseUrl}${brand_1.BRAND.apiBase}${apiPath}`, {
@@ -71,9 +86,37 @@ class ConveneApi {
71
86
  const p = slug ? `/projects/${encodeURIComponent(slug)}/inbox` : '/inbox';
72
87
  return this.request('GET', p, { timeoutMs });
73
88
  }
89
+ /**
90
+ * GET /poll — the long-poll stream `convene watch` consumes. `since` is the
91
+ * resume cursor (Last-Event-ID semantics, a messages.id); `wait` is the server
92
+ * hold (seconds, capped server-side at 50). Returns `{messages, cursor}`; the
93
+ * server already relevance-filters to this session, the client further filters
94
+ * to halt/interrupt. The timeout MUST exceed `wait*1000` so the long-poll isn't
95
+ * aborted before the server's own deadline.
96
+ */
97
+ poll(slug, q = {}, timeoutMs) {
98
+ const params = new URLSearchParams();
99
+ if (q.since != null)
100
+ params.set('since', String(q.since));
101
+ if (q.wait != null)
102
+ params.set('wait', String(q.wait));
103
+ const qs = params.toString();
104
+ return this.request('GET', `/projects/${encodeURIComponent(slug)}/poll${qs ? `?${qs}` : ''}`, { timeoutMs });
105
+ }
74
106
  resolveRepo(repo, timeoutMs) {
75
107
  return this.request('GET', `/projects/resolve?repo=${encodeURIComponent(repo)}`, { timeoutMs });
76
108
  }
109
+ /**
110
+ * GET /help — "Ask Convene" self-knowledge (PUBLIC, no tenant data). With `q`
111
+ * returns the matched topics; powers `convene explain`. Bounded by a short timeout.
112
+ */
113
+ help(q, timeoutMs) {
114
+ const params = new URLSearchParams();
115
+ if (q)
116
+ params.set('q', q);
117
+ const qs = params.toString();
118
+ return this.request('GET', `/help${qs ? `?${qs}` : ''}`, { timeoutMs });
119
+ }
77
120
  post(slug, body, idempotencyKey, timeoutMs) {
78
121
  return this.request('POST', `/projects/${encodeURIComponent(slug)}/messages`, {
79
122
  body,
@@ -106,5 +149,64 @@ class ConveneApi {
106
149
  getProject(slug, timeoutMs) {
107
150
  return this.request('GET', `/projects/${encodeURIComponent(slug)}`, { timeoutMs });
108
151
  }
152
+ // ── Catch-up / session-open (WP2) ──────────────────────────────────────────
153
+ /**
154
+ * GET /session-open — the "open already knowing the world" digest. `advance`
155
+ * advances the read cursor in the SAME server transaction that returns the
156
+ * digest (rendered ⇒ advanced atomic). `since` overrides the stored cursor.
157
+ * Bounded by an explicit short timeout — never the 10s default.
158
+ */
159
+ sessionOpen(slug, q = {}, timeoutMs) {
160
+ const params = new URLSearchParams();
161
+ if (q.since != null)
162
+ params.set('since', String(q.since));
163
+ if (q.advance)
164
+ params.set('advance', 'true');
165
+ if (q.maxItems != null)
166
+ params.set('maxItems', String(q.maxItems));
167
+ const qs = params.toString();
168
+ return this.request('GET', `/projects/${encodeURIComponent(slug)}/session-open${qs ? `?${qs}` : ''}`, { timeoutMs });
169
+ }
170
+ /** POST /catchup/seen — render-then-advance cursor (monotonic GREATEST on the server). */
171
+ catchupSeen(slug, seq, timeoutMs) {
172
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/catchup/seen`, {
173
+ body: { seq },
174
+ idempotencyKey: `seen:${seq}`,
175
+ timeoutMs,
176
+ });
177
+ }
178
+ // ── Lanes (WP6) ────────────────────────────────────────────────────────────
179
+ /** GET /lanes — live board + recent releases (read-only, fail-open). */
180
+ lanes(slug, timeoutMs) {
181
+ return this.request('GET', `/projects/${encodeURIComponent(slug)}/lanes`, { timeoutMs });
182
+ }
183
+ /** GET /lane-state — the trusted read (server-derived holder_instance_self + ages). */
184
+ laneState(slug, intent, timeoutMs) {
185
+ const qs = intent ? `?intent=${encodeURIComponent(intent)}` : '';
186
+ return this.request('GET', `/projects/${encodeURIComponent(slug)}/lane-state${qs}`, { timeoutMs });
187
+ }
188
+ /**
189
+ * POST /lanes/:lane/claim — 200 {granted:true} on success (incl. self-renew),
190
+ * 409 LANE_HELD on a foreign conflict. holder_instance is stamped server-side
191
+ * from the X-Convene-Session-Instance header attached by this client.
192
+ */
193
+ laneClaim(slug, lane, body = {}, timeoutMs) {
194
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/claim`, { body, timeoutMs });
195
+ }
196
+ /** POST /lanes/:lane/release — holder-only; 200 even on a no-op (idempotent). */
197
+ laneRelease(slug, lane, timeoutMs) {
198
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/release`, { timeoutMs });
199
+ }
200
+ /** POST /lanes/:lane/force-release — owner-only (server-gated by middleware). */
201
+ laneForceRelease(slug, lane, timeoutMs) {
202
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/force-release`, { timeoutMs });
203
+ }
204
+ /**
205
+ * POST /gate-push — thin-client parity verdict (LANE-AUTHORITATIVE, compat
206
+ * advisory). Returns {verdict:'allow'|'wait'|'rebase', holder?}.
207
+ */
208
+ gatePush(slug, body, timeoutMs) {
209
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/gate-push`, { body, timeoutMs });
210
+ }
109
211
  }
110
212
  exports.ConveneApi = ConveneApi;
package/dist/cache.js CHANGED
@@ -3,9 +3,23 @@ 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.writeWatchHighWater = void 0;
6
7
  exports.readCache = readCache;
7
8
  exports.writeCache = writeCache;
8
9
  exports.ageSeconds = ageSeconds;
10
+ exports.readSessionInstance = readSessionInstance;
11
+ exports.mintSessionInstance = mintSessionInstance;
12
+ exports.ensureSessionInstance = ensureSessionInstance;
13
+ exports.liveSessionCount = liveSessionCount;
14
+ exports.markCatchupSurfaced = markCatchupSurfaced;
15
+ exports.catchupAlreadySurfaced = catchupAlreadySurfaced;
16
+ exports.readWatchHighWater = readWatchHighWater;
17
+ exports.persistHighWater = persistHighWater;
18
+ exports.appendWatchEntry = appendWatchEntry;
19
+ exports.appendWatch = appendWatch;
20
+ exports.readWatchSince = readWatchSince;
21
+ exports.touchWatchHeartbeat = touchWatchHeartbeat;
22
+ exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
9
23
  /**
10
24
  * Tiny per-project file cache so rapid successive prompts don't each hit the
11
25
  * network (P0-LATENCY). Short TTL; on a fetch failure the stale cache still
@@ -14,8 +28,20 @@ exports.ageSeconds = ageSeconds;
14
28
  const node_fs_1 = __importDefault(require("node:fs"));
15
29
  const node_path_1 = __importDefault(require("node:path"));
16
30
  const config_1 = require("./config");
31
+ const git_1 = require("./git");
32
+ /**
33
+ * Per-SESSION local-state key. Two Claude windows in one checkout share a slug,
34
+ * so slug-only file names collide — they clobber each other's session-instance,
35
+ * catch-up sentinel, and feed cache. Appending the session discriminator gives
36
+ * each concurrent session its own files. Absent a discriminator (plain terminal)
37
+ * this is just the bare slug, so existing single-session files are untouched.
38
+ */
39
+ function scoped(slug) {
40
+ const d = (0, git_1.sessionDiscriminator)();
41
+ return d ? `${slug}#${d}` : slug;
42
+ }
17
43
  function cacheFile(slug) {
18
- return node_path_1.default.join(config_1.CACHE_DIR, `${slug.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
44
+ return node_path_1.default.join(config_1.CACHE_DIR, `${scoped(slug).replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
19
45
  }
20
46
  function readCache(slug) {
21
47
  try {
@@ -37,3 +63,236 @@ function writeCache(slug, data) {
37
63
  function ageSeconds(entry) {
38
64
  return Math.max(0, Math.round((Date.now() - entry.fetchedAt) / 1000));
39
65
  }
66
+ // ── session-instance id (WP2) ────────────────────────────────────────────────
67
+ // An opaque per-session UUID minted at SessionStart and persisted in CACHE_DIR.
68
+ // Sent as X-Convene-Session-Instance so the server can stamp holder_instance and
69
+ // disambiguate same-basename worktrees / two terminals in one checkout. It NEVER
70
+ // authorizes anything by itself — the server scopes it to principal.memberId.
71
+ function slugFile(slug, ext) {
72
+ return node_path_1.default.join(config_1.CACHE_DIR, `${slug.replace(/[^a-zA-Z0-9_-]/g, '_')}.${ext}`);
73
+ }
74
+ function newUuid() {
75
+ try {
76
+ return require('node:crypto').randomUUID();
77
+ }
78
+ catch {
79
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
80
+ }
81
+ }
82
+ /** The opaque session-instance id for this slug, or null if none minted yet. */
83
+ function readSessionInstance(slug) {
84
+ try {
85
+ const v = node_fs_1.default.readFileSync(slugFile(scoped(slug), 'instance'), 'utf8').trim();
86
+ return v || null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Mint (always overwrite) a fresh opaque session-instance UUID at SessionStart.
94
+ * A fresh boot is a fresh instance, so overwriting is correct.
95
+ */
96
+ function mintSessionInstance(slug) {
97
+ const id = newUuid();
98
+ try {
99
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
100
+ node_fs_1.default.writeFileSync(slugFile(scoped(slug), 'instance'), id + '\n', { mode: 0o600 });
101
+ }
102
+ catch {
103
+ /* best-effort; the caller still uses the in-memory value */
104
+ }
105
+ return id;
106
+ }
107
+ /** The session-instance id, minting one if absent (used by non-SessionStart verbs). */
108
+ function ensureSessionInstance(slug) {
109
+ return readSessionInstance(slug) || mintSessionInstance(slug);
110
+ }
111
+ /**
112
+ * Count DISTINCT sessions that have touched this checkout within `maxAgeSec`, by
113
+ * scanning the per-session local-state files for `<slug>` (`.json` cache, refreshed
114
+ * each active prompt, + `.instance`). A bare slug and each `#<disc>` scope counts
115
+ * once. `doctor` uses this to nudge toward one-worktree-per-session when several
116
+ * agents share a checkout. Best-effort: any error → 0 (no nudge). The slug is
117
+ * sanitized to `[A-Za-z0-9_-]` so it is already regex-safe to anchor.
118
+ */
119
+ function liveSessionCount(slug, maxAgeSec) {
120
+ try {
121
+ const san = slug.replace(/[^a-zA-Z0-9_-]/g, '_');
122
+ const re = new RegExp(`^${san}(_[a-z0-9-]+)?\\.(json|instance)$`);
123
+ const cutoff = Date.now() - maxAgeSec * 1000;
124
+ const scopes = new Set();
125
+ for (const f of node_fs_1.default.readdirSync(config_1.CACHE_DIR)) {
126
+ const m = f.match(re);
127
+ if (!m)
128
+ continue;
129
+ let mtimeMs;
130
+ try {
131
+ mtimeMs = node_fs_1.default.statSync(node_path_1.default.join(config_1.CACHE_DIR, f)).mtimeMs;
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ if (mtimeMs < cutoff)
137
+ continue;
138
+ scopes.add(m[1] ?? ''); // the `_<disc>` token, or '' for a no-discriminator session
139
+ }
140
+ return scopes.size;
141
+ }
142
+ catch {
143
+ return 0;
144
+ }
145
+ }
146
+ // ── per-boot catch-up dedup sentinel (WP2) ───────────────────────────────────
147
+ // SessionStart writes a sentinel keyed by the session-instance once it has
148
+ // surfaced a catch-up; the first UserPromptSubmit `fetch` of that boot reads it
149
+ // and suppresses a duplicate rollup. Keyed by instance so a NEW boot (new
150
+ // instance) always re-surfaces.
151
+ function sentinelFile(slug) {
152
+ return slugFile(scoped(slug), 'catchup-seen');
153
+ }
154
+ /** Mark that SessionStart has already surfaced a catch-up for this instance. */
155
+ function markCatchupSurfaced(slug, instance) {
156
+ try {
157
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
158
+ node_fs_1.default.writeFileSync(sentinelFile(slug), instance + '\n', { mode: 0o600 });
159
+ }
160
+ catch {
161
+ /* best-effort */
162
+ }
163
+ }
164
+ /** True iff a catch-up was already surfaced for this exact session-instance. */
165
+ function catchupAlreadySurfaced(slug, instance) {
166
+ try {
167
+ return node_fs_1.default.readFileSync(sentinelFile(slug), 'utf8').trim() === instance;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ function watchFile(slug) {
174
+ return slugFile(slug, 'watch.jsonl');
175
+ }
176
+ function watchHighWaterFile(slug) {
177
+ return slugFile(slug, 'watch.hw');
178
+ }
179
+ function watchHeartbeatFile(slug) {
180
+ return slugFile(slug, 'watch.hb');
181
+ }
182
+ /** Read the persisted high-water seq for the watch jsonl (0 if none). */
183
+ function readWatchHighWater(slug) {
184
+ try {
185
+ const n = parseInt(node_fs_1.default.readFileSync(watchHighWaterFile(slug), 'utf8').trim(), 10);
186
+ return Number.isFinite(n) ? n : 0;
187
+ }
188
+ catch {
189
+ return 0;
190
+ }
191
+ }
192
+ /**
193
+ * Persist the high-water seq for the watch jsonl — MONOTONIC. A lower seq is
194
+ * ignored (GREATEST semantics) so a reader can never rewind past material it
195
+ * already drained. NEVER truncates the jsonl.
196
+ */
197
+ function persistHighWater(slug, seq) {
198
+ try {
199
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
200
+ const cur = readWatchHighWater(slug);
201
+ if (seq > cur)
202
+ node_fs_1.default.writeFileSync(watchHighWaterFile(slug), String(seq) + '\n', { mode: 0o600 });
203
+ }
204
+ catch {
205
+ /* best-effort */
206
+ }
207
+ }
208
+ /** Back-compat alias for the WP2 stub name; identical monotonic behavior. */
209
+ exports.writeWatchHighWater = persistHighWater;
210
+ /**
211
+ * APPEND a watch entry to the jsonl (append-only — never rewrites the file).
212
+ * The append is atomic at the OS level for a single small write, so a concurrent
213
+ * reader sees whole lines. Best-effort; never throws (the watch daemon is
214
+ * fail-open). Entries WITHOUT a finite numeric seq are dropped — an un-keyed
215
+ * entry can't participate in the high-water drain and would be re-rendered
216
+ * forever.
217
+ */
218
+ function appendWatchEntry(slug, entry) {
219
+ if (!entry || typeof entry.seq !== 'number' || !Number.isFinite(entry.seq))
220
+ return;
221
+ try {
222
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
223
+ node_fs_1.default.appendFileSync(watchFile(slug), JSON.stringify(entry) + '\n', { mode: 0o600 });
224
+ }
225
+ catch {
226
+ /* best-effort */
227
+ }
228
+ }
229
+ /** Back-compat alias for the WP2 stub name. */
230
+ function appendWatch(slug, entry) {
231
+ appendWatchEntry(slug, entry);
232
+ }
233
+ /**
234
+ * Read all watch entries with seq > highWater, ascending by seq, de-duplicated
235
+ * on seq (the daemon may re-append the same entry after a resume — the reader
236
+ * tolerates duplicates by keeping the first per seq). Does NOT advance the
237
+ * high-water and does NOT truncate — the caller decides what was actually
238
+ * rendered and calls persistHighWater(slug, lastRenderedSeq). A malformed line
239
+ * is skipped, never fatal.
240
+ */
241
+ function readWatchSince(slug, highWater) {
242
+ let raw;
243
+ try {
244
+ raw = node_fs_1.default.readFileSync(watchFile(slug), 'utf8');
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ const seen = new Set();
250
+ const out = [];
251
+ for (const line of raw.split('\n')) {
252
+ const s = line.trim();
253
+ if (!s)
254
+ continue;
255
+ let e;
256
+ try {
257
+ e = JSON.parse(s);
258
+ }
259
+ catch {
260
+ continue; // a torn/partial line — skip, the next drain re-reads it whole
261
+ }
262
+ if (typeof e.seq !== 'number' || !Number.isFinite(e.seq))
263
+ continue;
264
+ if (e.seq <= highWater)
265
+ continue;
266
+ if (seen.has(e.seq))
267
+ continue;
268
+ seen.add(e.seq);
269
+ out.push(e);
270
+ }
271
+ out.sort((a, b) => a.seq - b.seq);
272
+ return out;
273
+ }
274
+ // ── watch heartbeat (WP12) ───────────────────────────────────────────────────
275
+ // The daemon stamps a heartbeat each loop iteration; the health line / doctor
276
+ // read its age. A stale/absent heartbeat ⇒ watch is down ⇒ surface DEGRADED.
277
+ /** Stamp the watch heartbeat (epoch ms). Best-effort; never throws. */
278
+ function touchWatchHeartbeat(slug) {
279
+ try {
280
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
281
+ node_fs_1.default.writeFileSync(watchHeartbeatFile(slug), String(Date.now()) + '\n', { mode: 0o600 });
282
+ }
283
+ catch {
284
+ /* best-effort */
285
+ }
286
+ }
287
+ /** Age of the watch heartbeat in seconds, or null if never stamped/unreadable. */
288
+ function watchHeartbeatAgeSec(slug) {
289
+ try {
290
+ const ms = parseInt(node_fs_1.default.readFileSync(watchHeartbeatFile(slug), 'utf8').trim(), 10);
291
+ if (!Number.isFinite(ms))
292
+ return null;
293
+ return Math.max(0, Math.round((Date.now() - ms) / 1000));
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
@@ -5,15 +5,37 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.login = login;
7
7
  exports.whoami = whoami;
8
+ exports.assessLaneIdentity = assessLaneIdentity;
8
9
  exports.doctor = doctor;
9
10
  /** login / whoami / doctor. */
10
11
  const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_child_process_1 = require("node:child_process");
11
13
  const brand_1 = require("../brand");
12
14
  const api_1 = require("../api");
13
15
  const config_1 = require("../config");
14
16
  const git_1 = require("../git");
15
17
  const hook_1 = require("../hook");
18
+ const cache_1 = require("../cache");
16
19
  const ctx_1 = require("../ctx");
20
+ /** A watch heartbeat older than this (or absent) means the watcher is down. */
21
+ const WATCH_STALE_SEC = 90;
22
+ /** (Re)launch `convene watch` detached so it outlives this doctor process. */
23
+ function relaunchWatch() {
24
+ try {
25
+ const bin = process.argv[1] || '';
26
+ if (!bin)
27
+ return false;
28
+ const child = (0, node_child_process_1.spawn)(process.execPath, [bin, 'watch'], {
29
+ detached: true,
30
+ stdio: 'ignore',
31
+ });
32
+ child.unref();
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
17
39
  function readStdin() {
18
40
  try {
19
41
  return node_fs_1.default.readFileSync(0, 'utf8').trim();
@@ -67,6 +89,67 @@ async function whoami() {
67
89
  process.stdout.write(`server: ${me.ok ? 'reachable' : 'UNREACHABLE'}\n`);
68
90
  // The API key is never printed.
69
91
  }
92
+ /** A lane this session holds whose heartbeat is older than this looks wedged. */
93
+ const LANE_STALE_SEC = 600;
94
+ /**
95
+ * PLAN §11 "two-humans-on-one-handle" mitigation. `holder_instance` disambiguates
96
+ * worktrees, but a genuinely shared API key still shares a member identity — so a
97
+ * deploy lane can be held under YOUR handle by a session that ISN'T this one. This
98
+ * surfaces that, plus a hold this session owns that has gone stale. Two flags:
99
+ * - COLLISION (✗): a live lane held under your handle by a DIFFERENT instance —
100
+ * a shared key or a sibling worktree/terminal. A release/force under your key
101
+ * affects it, so you should know before you act.
102
+ * - STALE-MINE (✗): a lane THIS session holds whose heartbeat has gone cold — a
103
+ * likely-wedged hold worth releasing so it stops blocking others.
104
+ * Pure + deterministic so it unit-tests without the network. When no
105
+ * session-instance was minted (`haveInstance=false`) we cannot trust
106
+ * holder_instance_self to tell "me" from "a sibling", so we report UNVERIFIED
107
+ * rather than false-alarming every lane as a collision.
108
+ */
109
+ function assessLaneIdentity(lanes, myHandle, haveInstance, staleSec = LANE_STALE_SEC) {
110
+ const name = 'identity';
111
+ if (!myHandle)
112
+ return { name, ok: true, detail: 'skipped (no member identity)' };
113
+ const mine = lanes.filter((l) => l.holder_handle === myHandle);
114
+ if (mine.length === 0)
115
+ return { name, ok: true, detail: 'no deploy lane held under your identity' };
116
+ const label = (l) => {
117
+ const bits = [];
118
+ if (l.holder_tool)
119
+ bits.push(`tool=${l.holder_tool}`);
120
+ if (l.holder_session)
121
+ bits.push(`session=${l.holder_session}`);
122
+ bits.push(`heartbeat ${l.heartbeat_age_sec}s ago`);
123
+ return `${l.lane} (${bits.join(', ')})`;
124
+ };
125
+ if (!haveInstance) {
126
+ return {
127
+ name,
128
+ ok: true,
129
+ detail: `holding ${mine.length} lane(s) under your handle, but no session-instance is minted — ` +
130
+ `"you vs a sibling session under the same key" is UNVERIFIED; run inside a Claude Code session for the full check`,
131
+ };
132
+ }
133
+ const collisions = mine.filter((l) => !l.holder_instance_self);
134
+ if (collisions.length) {
135
+ return {
136
+ name,
137
+ ok: false,
138
+ detail: 'lane held under your handle by ANOTHER session — likely a shared API key or a sibling worktree/terminal ' +
139
+ `(a release/force under your key affects it): ${collisions.map(label).join('; ')}`,
140
+ };
141
+ }
142
+ const stale = mine.filter((l) => l.holder_instance_self && l.heartbeat_age_sec > staleSec);
143
+ if (stale.length) {
144
+ return {
145
+ name,
146
+ ok: false,
147
+ detail: 'a lane THIS session holds looks stale — likely a wedged hold; release it if that deploy is done ' +
148
+ `(\`convene lane release <lane>\`): ${stale.map(label).join('; ')}`,
149
+ };
150
+ }
151
+ return { name, ok: true, detail: `holding ${mine.length} lane(s), all fresh and owned by this session` };
152
+ }
70
153
  async function doctor(opts) {
71
154
  const checks = [];
72
155
  const cfg = (0, config_1.resolveConfig)();
@@ -143,6 +226,87 @@ async function doctor(opts) {
143
226
  ? 'UserPromptSubmit `convene fetch` registered'
144
227
  : 'hook NOT registered (run `convene init` or `convene doctor --fix`)',
145
228
  });
229
+ // 7. watch heartbeat — a stale/absent heartbeat means the mid-task halt watcher
230
+ // is DOWN (so directed halts won't surface between turns). Only meaningful for a
231
+ // repo on the bus; --fix may (re)launch `convene watch` detached.
232
+ if (proj?.slug) {
233
+ let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
234
+ let watchOk = age != null && age <= WATCH_STALE_SEC;
235
+ if (!watchOk && opts.fix) {
236
+ if (relaunchWatch()) {
237
+ // Give the freshly-launched daemon a beat to stamp its first heartbeat.
238
+ const until = Date.now() + 2500;
239
+ while (Date.now() < until) {
240
+ age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
241
+ if (age != null && age <= WATCH_STALE_SEC)
242
+ break;
243
+ }
244
+ watchOk = age != null && age <= WATCH_STALE_SEC;
245
+ }
246
+ }
247
+ checks.push({
248
+ name: 'watch',
249
+ ok: watchOk,
250
+ detail: watchOk
251
+ ? `halt watcher alive (heartbeat ${age}s ago)`
252
+ : age == null
253
+ ? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
254
+ : `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
255
+ });
256
+ }
257
+ // 7b. parallel sessions sharing ONE checkout. Several agents in the same
258
+ // working tree clobber each other's uncommitted files and (absent the
259
+ // discriminator) collapse to one bus identity. Convene now auto-disambiguates
260
+ // them, but a worktree apiece is the cleaner default — so nudge when ≥2 sessions
261
+ // have recently touched this checkout. Purely informational (never fails doctor).
262
+ if (proj?.slug) {
263
+ const n = (0, cache_1.liveSessionCount)(proj.slug, 30 * 60); // active within the last 30 min
264
+ if (n >= 2) {
265
+ checks.push({
266
+ name: 'sessions',
267
+ ok: true,
268
+ detail: `${n} sessions share this checkout (last 30m) — prefer one git worktree each: \`convene worktree <branch>\``,
269
+ });
270
+ }
271
+ }
272
+ // 8. lane identity (PLAN §11 two-humans-on-one-handle). A deploy lane held under
273
+ // your handle by a different session-instance (shared key / sibling worktree) or
274
+ // a stale hold this session owns. Fail-open: a lane-state read failure is
275
+ // informational, never a hard doctor failure. Sends this session's minted
276
+ // instance so the server can compute holder_instance_self trustworthily.
277
+ if (proj?.slug && cfg.apiKey) {
278
+ const session = cfg.member && top ? (0, git_1.sessionId)(cfg.member, top) : null;
279
+ const instance = (0, cache_1.readSessionInstance)(proj.slug);
280
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
281
+ const [state, board] = await Promise.all([
282
+ api.laneState(proj.slug, null, 4000),
283
+ api.lanes(proj.slug, 4000),
284
+ ]);
285
+ if (!state.ok || !Array.isArray(state.json?.lanes)) {
286
+ checks.push({ name: 'identity', ok: true, detail: 'skipped (lane-state unreachable)' });
287
+ }
288
+ else {
289
+ // Enrich each trusted lane-state row with display tool/session from the
290
+ // board (best-effort — the board read may fail independently).
291
+ const boardByLane = new Map();
292
+ if (board.ok && Array.isArray(board.json?.lanes)) {
293
+ for (const b of board.json.lanes)
294
+ boardByLane.set(b.lane, b);
295
+ }
296
+ const rows = state.json.lanes.map((l) => {
297
+ const b = boardByLane.get(l.lane);
298
+ return {
299
+ lane: l.lane,
300
+ holder_handle: l.holder_handle ?? null,
301
+ holder_instance_self: !!l.holder_instance_self,
302
+ heartbeat_age_sec: Number(l.heartbeat_age_sec) || 0,
303
+ holder_tool: b?.holder_tool ?? null,
304
+ holder_session: b?.holder_session ?? null,
305
+ };
306
+ });
307
+ checks.push(assessLaneIdentity(rows, cfg.member ?? null, instance != null));
308
+ }
309
+ }
146
310
  for (const c of checks) {
147
311
  process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
148
312
  }