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 +103 -1
- package/dist/cache.js +260 -1
- package/dist/commands/auth.js +164 -0
- package/dist/commands/catchup.js +125 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/explain.js +59 -0
- package/dist/commands/fetch.js +77 -6
- package/dist/commands/gate-push.js +333 -0
- package/dist/commands/guard.js +315 -0
- package/dist/commands/init.js +193 -4
- package/dist/commands/lane.js +116 -0
- package/dist/commands/notify.js +4 -2
- package/dist/commands/post.js +55 -1
- package/dist/commands/session-start.js +105 -0
- package/dist/commands/setup.js +3 -0
- package/dist/commands/watch.js +147 -0
- package/dist/commands/worktree.js +63 -0
- package/dist/exit.js +49 -0
- package/dist/git.js +63 -2
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +108 -0
- package/dist/protocol.js +119 -25
- package/dist/render.js +181 -2
- package/dist/test-env.js +5 -0
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -8,11 +8,24 @@ class ConveneApi {
|
|
|
8
8
|
apiKey;
|
|
9
9
|
session;
|
|
10
10
|
tool;
|
|
11
|
-
|
|
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
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
}
|