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.
@@ -3,7 +3,10 @@ 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.copyWorktreeIncludes = copyWorktreeIncludes;
7
+ exports.provisionWorktree = provisionWorktree;
6
8
  exports.worktree = worktree;
9
+ exports.provisionAutoWorktree = provisionAutoWorktree;
7
10
  /**
8
11
  * `convene worktree <branch>` — create an isolated git worktree for a parallel
9
12
  * session. This is Convene's recommended default for running several coding agents
@@ -14,6 +17,10 @@ exports.worktree = worktree;
14
17
  *
15
18
  * DIE-LOUD like the other interactive verbs (stderr + non-zero exit on failure).
16
19
  * Pure git plumbing — does NOT require the repo to be on the Convene bus.
20
+ *
21
+ * The provisioning CORE (`provisionWorktree`) is factored out of the command so
22
+ * the auto-isolate SessionStart path (`provisionAutoWorktree`) can reuse the exact
23
+ * same git plumbing + `.worktreeinclude` copy — fail-OPEN there, die-LOUD here.
17
24
  */
18
25
  const node_child_process_1 = require("node:child_process");
19
26
  const node_path_1 = __importDefault(require("node:path"));
@@ -24,6 +31,88 @@ function refExists(ref, cwd) {
24
31
  const r = (0, node_child_process_1.spawnSync)('git', ['rev-parse', '--verify', '--quiet', ref], { cwd, encoding: 'utf8' });
25
32
  return r.status === 0;
26
33
  }
34
+ /**
35
+ * The `.worktreeinclude` format (read from `<top>/.worktreeinclude`):
36
+ * - one relative path per line, resolved against the SOURCE worktree's toplevel;
37
+ * - lines beginning with `#` are comments; blank/whitespace-only lines are ignored;
38
+ * - a leading `!` or absolute path or `..` traversal is rejected (never escapes <top>);
39
+ * - each entry may be a file OR a directory (copied recursively);
40
+ * - a missing entry is silently skipped (gitignored config that just isn't present).
41
+ *
42
+ * Purpose: carry gitignored local config (e.g. `.env`, `.envrc`, `.claude/settings.local.json`)
43
+ * from the source checkout into a freshly-provisioned worktree, since git itself
44
+ * only materializes TRACKED files. Best-effort: any per-entry error is swallowed so
45
+ * a copy failure can never wedge worktree creation (critical on the auto-isolate path).
46
+ */
47
+ function copyWorktreeIncludes(top, dest) {
48
+ let raw;
49
+ try {
50
+ raw = node_fs_1.default.readFileSync(node_path_1.default.join(top, '.worktreeinclude'), 'utf8');
51
+ }
52
+ catch {
53
+ return; // no include file → nothing to carry
54
+ }
55
+ for (const line of raw.split('\n')) {
56
+ const entry = line.trim();
57
+ if (!entry || entry.startsWith('#'))
58
+ continue;
59
+ // Reject anything that could escape <top>: leading '!', absolute, or any '..' segment.
60
+ if (entry.startsWith('!') || node_path_1.default.isAbsolute(entry))
61
+ continue;
62
+ const rel = node_path_1.default.normalize(entry);
63
+ if (rel === '..' || rel.startsWith('..' + node_path_1.default.sep) || rel.split(node_path_1.default.sep).includes('..'))
64
+ continue;
65
+ const src = node_path_1.default.join(top, rel);
66
+ const dst = node_path_1.default.join(dest, rel);
67
+ try {
68
+ if (!node_fs_1.default.existsSync(src))
69
+ continue; // skip missing entries
70
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(dst), { recursive: true });
71
+ node_fs_1.default.cpSync(src, dst, { recursive: true });
72
+ }
73
+ catch {
74
+ /* best-effort per entry — never let a copy failure wedge provisioning */
75
+ }
76
+ }
77
+ }
78
+ /**
79
+ * Hard ceiling on `git worktree add` (a full working-tree checkout). On the AUTO
80
+ * path this runs SYNCHRONOUSLY inside SessionStart, and the boot's async 6s
81
+ * watchdog cannot interrupt synchronous work — so a large/slow repo could stall the
82
+ * boot. A bounded spawnSync caps that: a timeout kills git and we fail-open.
83
+ */
84
+ const WORKTREE_ADD_TIMEOUT_MS = 20_000;
85
+ /**
86
+ * The reusable provisioning core: resolve the branch (existing local / existing
87
+ * remote-only / new), run `git worktree add`, then copy `.worktreeinclude` config
88
+ * into the new tree. Returns {dest, branch} on success or null on ANY failure —
89
+ * the caller decides whether to die-loud (interactive) or fail-open (auto).
90
+ */
91
+ function provisionWorktree(args) {
92
+ const { top, branch, dest, fromRef, quiet } = args;
93
+ if (node_fs_1.default.existsSync(dest))
94
+ return null;
95
+ const localExists = refExists(`refs/heads/${branch}`, top);
96
+ const remoteExists = !localExists && refExists(`refs/remotes/origin/${branch}`, top);
97
+ // Existing local branch → check it out; existing remote-only branch → create a
98
+ // local tracking branch; otherwise → new branch from fromRef (or HEAD).
99
+ const gitArgs = localExists
100
+ ? ['worktree', 'add', dest, branch]
101
+ : remoteExists
102
+ ? ['worktree', 'add', '-b', branch, dest, `origin/${branch}`]
103
+ : ['worktree', 'add', '-b', branch, dest, fromRef || 'HEAD'];
104
+ const r = (0, node_child_process_1.spawnSync)('git', gitArgs, {
105
+ cwd: top,
106
+ stdio: quiet ? 'ignore' : 'inherit',
107
+ timeout: WORKTREE_ADD_TIMEOUT_MS,
108
+ });
109
+ if (r.status !== 0)
110
+ return null;
111
+ // Carry gitignored local config (e.g. .env) into the new tree — best-effort.
112
+ copyWorktreeIncludes(top, dest);
113
+ const branchNote = localExists ? '' : remoteExists ? ` (new, tracking origin/${branch})` : ' (new)';
114
+ return { dest, branch, branchNote };
115
+ }
27
116
  function worktree(branch, opts = {}) {
28
117
  const top = (0, git_1.gitToplevel)();
29
118
  if (!top)
@@ -35,29 +124,78 @@ function worktree(branch, opts = {}) {
35
124
  const dest = node_path_1.default.resolve(opts.path || node_path_1.default.join(node_path_1.default.dirname(top), `${base}-${safeBranch}`));
36
125
  if (node_fs_1.default.existsSync(dest))
37
126
  (0, ctx_1.die)(`destination already exists: ${dest}`);
38
- const localExists = refExists(`refs/heads/${branch}`, top);
39
- const remoteExists = !localExists && refExists(`refs/remotes/origin/${branch}`, top);
40
- // Existing local branch → check it out; existing remote-only branch → create a
41
- // local tracking branch; otherwise → new branch from --from (or HEAD).
42
- const args = localExists
43
- ? ['worktree', 'add', dest, branch]
44
- : remoteExists
45
- ? ['worktree', 'add', '-b', branch, dest, `origin/${branch}`]
46
- : ['worktree', 'add', '-b', branch, dest, opts.from || 'HEAD'];
47
- const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: top, stdio: 'inherit' });
48
- if (r.status !== 0)
49
- (0, ctx_1.die)(`git worktree add failed (exit ${r.status ?? '?'})`);
50
- const branchNote = localExists ? '' : remoteExists ? ` (new, tracking origin/${branch})` : ' (new)';
127
+ const res = provisionWorktree({ top: top, branch, dest, fromRef: opts.from });
128
+ if (!res)
129
+ (0, ctx_1.die)(`git worktree add failed`);
51
130
  process.stdout.write([
52
131
  ``,
53
- `✓ worktree ready: ${dest}`,
54
- ` branch: ${branch}${branchNote}`,
132
+ `✓ worktree ready: ${res.dest}`,
133
+ ` branch: ${branch}${res.branchNote}`,
55
134
  ``,
56
135
  `Start a FRESH agent session inside it so it gets its own Convene identity:`,
57
- ` cd ${dest}`,
136
+ ` cd ${res.dest}`,
58
137
  ` # install deps for this package if needed, then launch your agent (e.g. \`claude\`)`,
59
138
  ``,
60
- `Remove it when done: git worktree remove ${dest}`,
139
+ `Remove it when done: git worktree remove ${res.dest}`,
61
140
  ``,
62
141
  ].join('\n'));
63
142
  }
143
+ /** A safe, filesystem-friendly token from an arbitrary string (for branch/dir names). */
144
+ function safeToken(s) {
145
+ return s.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'sess';
146
+ }
147
+ /**
148
+ * The AUTO-ISOLATE provisioning entry point used by SessionStart when a session
149
+ * boots into a checkout that already has a live sibling. FAIL-OPEN end-to-end:
150
+ * returns {dest, branch} on success or null on ANY error — the boot proceeds
151
+ * normally on null.
152
+ *
153
+ * Strategy:
154
+ * 1. Best-effort refresh the base (`git fetch origin main`) so the new tree is
155
+ * based on fresh upstream, not a stale local tip. A fetch failure is ignored.
156
+ * 2. Pick the base ref: the current branch's upstream if it has one, else
157
+ * `origin/main` (falling back to `HEAD` only if neither resolves).
158
+ * 3. Compute a NON-colliding branch name `auto/<disc-or-member>-<short>` and a
159
+ * NON-colliding sibling destination, bumping a counter on collision.
160
+ * 4. provisionWorktree(...) (which also copies `.worktreeinclude`).
161
+ */
162
+ function provisionAutoWorktree(top, slug) {
163
+ try {
164
+ // 1. Best-effort refresh of the canonical base.
165
+ (0, git_1.gitFetch)('main', 'origin', top);
166
+ // 2. Base ref: current branch's upstream, else origin/main, else HEAD.
167
+ let fromRef = 'origin/main';
168
+ if (!refExists('refs/remotes/origin/main', top))
169
+ fromRef = 'HEAD';
170
+ const cur = (0, git_1.currentBranch)(top);
171
+ if (cur) {
172
+ const up = (0, node_child_process_1.spawnSync)('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${cur}@{upstream}`], {
173
+ cwd: top,
174
+ encoding: 'utf8',
175
+ });
176
+ const upstream = up.status === 0 ? (up.stdout || '').trim() : '';
177
+ if (upstream && refExists(`refs/remotes/${upstream}`, top))
178
+ fromRef = upstream;
179
+ }
180
+ // 3. Non-colliding branch name + destination.
181
+ const disc = (0, git_1.sessionDiscriminator)();
182
+ const tag = safeToken(disc || slug);
183
+ const base = (0, git_1.worktreeBasename)(top);
184
+ const parent = node_path_1.default.dirname(top);
185
+ let branch = `auto/${tag}`;
186
+ let dest = node_path_1.default.join(parent, `${base}-auto-${tag}`);
187
+ let n = 1;
188
+ while (refExists(`refs/heads/${branch}`, top) || node_fs_1.default.existsSync(dest)) {
189
+ n += 1;
190
+ branch = `auto/${tag}-${n}`;
191
+ dest = node_path_1.default.join(parent, `${base}-auto-${tag}-${n}`);
192
+ if (n > 50)
193
+ return null; // pathological — give up rather than spin
194
+ }
195
+ const res = provisionWorktree({ top, branch, dest, fromRef, quiet: true });
196
+ return res ? { dest: res.dest, branch: res.branch } : null;
197
+ }
198
+ catch {
199
+ return null; // fail-open: any error → no relocation, boot proceeds
200
+ }
201
+ }
package/dist/config.js CHANGED
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.CACHE_DIR = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
7
7
  exports.homeBase = homeBase;
8
+ exports.resolveFetchTimeoutMs = resolveFetchTimeoutMs;
9
+ exports.resolveWatchMaxMs = resolveWatchMaxMs;
8
10
  exports.isWorldReadable = isWorldReadable;
9
11
  exports.loadFileConfig = loadFileConfig;
10
12
  exports.loadProjectConfig = loadProjectConfig;
@@ -28,6 +30,34 @@ const brand_1 = require("./brand");
28
30
  function homeBase() {
29
31
  return process.env.CONVENE_HOME_OVERRIDE || node_os_1.default.homedir();
30
32
  }
33
+ /**
34
+ * Network fetch timeout (ms) for the hook/catch-up paths. Overridable via
35
+ * CONVENE_FETCH_TIMEOUT_MS so the fail-open/timeout tests can drive the path
36
+ * deterministically with a small value instead of relying on the real 4s
37
+ * wall-clock — which, under full-suite serial load, collides with process-spawn
38
+ * + GC jitter and intermittently blows the latency-budget assertion. A garbage or
39
+ * non-positive value falls back to the default. Production default unchanged (4000).
40
+ */
41
+ function resolveFetchTimeoutMs(fallback = 4000) {
42
+ const raw = process.env.CONVENE_FETCH_TIMEOUT_MS;
43
+ const n = raw ? Number(raw) : NaN;
44
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
45
+ }
46
+ /**
47
+ * Hard ceiling (ms) on a single `convene watch` daemon's total lifetime — the
48
+ * absolute-runtime half of the leak fix (the other half is idle exit; see
49
+ * watch.ts). A detached watcher self-terminates once it has run this long so an
50
+ * orphaned daemon can never live forever (the 145-leaked-watchers bug). Mirrors
51
+ * resolveFetchTimeoutMs: a positive finite CONVENE_WATCH_MAX_MS wins (floored),
52
+ * else the fallback. Tests drive it tiny for a deterministic exit; the production
53
+ * default is 12h — comfortably longer than any real heads-down turn, and a fresh
54
+ * watcher is relaunched at the next SessionStart regardless.
55
+ */
56
+ function resolveWatchMaxMs(fallback = 12 * 60 * 60 * 1000) {
57
+ const raw = process.env.CONVENE_WATCH_MAX_MS;
58
+ const n = raw ? Number(raw) : NaN;
59
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
60
+ }
31
61
  exports.CONFIG_DIR = node_path_1.default.join(homeBase(), brand_1.BRAND.configDir);
32
62
  exports.CONFIG_FILE = node_path_1.default.join(exports.CONFIG_DIR, 'config.json');
33
63
  exports.CACHE_DIR = node_path_1.default.join(exports.CONFIG_DIR, 'cache');
package/dist/index.js CHANGED
@@ -57,9 +57,11 @@ const lane_1 = require("./commands/lane");
57
57
  const deploy_1 = require("./commands/deploy");
58
58
  const guard_1 = require("./commands/guard");
59
59
  const gate_push_1 = require("./commands/gate-push");
60
+ const beat_1 = require("./commands/beat");
60
61
  const practice_guard_1 = require("./commands/practice-guard");
61
62
  const override_1 = require("./commands/override");
62
63
  const watch_1 = require("./commands/watch");
64
+ const watch_reap_1 = require("./commands/watch-reap");
63
65
  const explain_1 = require("./commands/explain");
64
66
  const practices_1 = require("./commands/practices");
65
67
  const update_1 = require("./commands/update");
@@ -168,12 +170,22 @@ program
168
170
  .option('--reason <text>', 'why the gate is being overridden (required; attributed to the bus)')
169
171
  .option('--project <slug>')
170
172
  .action((id, opts) => (0, override_1.override)(id, opts));
173
+ program
174
+ .command('beat')
175
+ .description('PostToolUse hook: debounced session activity-beat (presence), fail-open + fast')
176
+ .option('--stdin', 'read the PostToolUse JSON payload from stdin (to derive the coarse area)')
177
+ .action((opts) => (0, beat_1.beat)(opts));
171
178
  program
172
179
  .command('watch')
173
180
  .description('SessionStart-launched detached long-poll for directed halts (fail-open, self-healing)')
174
181
  .option('--notify', 'best-effort desktop notification per surfaced halt')
175
182
  .option('--project <slug>')
176
183
  .action((opts) => (0, watch_1.watch)(opts));
184
+ program
185
+ .command('watch-reap')
186
+ .description('reap orphaned (PID-1) detached `convene watch` daemons (POSIX-only, fail-open)')
187
+ .option('--dry-run', 'list reapable watchers; kill nothing')
188
+ .action((opts) => (0, watch_reap_1.watchReap)({ dryRun: opts.dryRun }));
177
189
  program
178
190
  .command('notify-push')
179
191
  .description('git pre-push hook: post a [STATUS] summarizing the push (fail-silent)')
package/dist/render.js CHANGED
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SESSION_OPEN_TAG = exports.UNTRUSTED_LABEL = void 0;
3
+ exports.RELOCATE_TAG = exports.SESSION_OPEN_TAG = exports.UNTRUSTED_LABEL = void 0;
4
4
  exports.inertToken = inertToken;
5
5
  exports.renderHealthLine = renderHealthLine;
6
6
  exports.renderRecentLine = renderRecentLine;
7
7
  exports.renderOpenItem = renderOpenItem;
8
8
  exports.renderChannelBlock = renderChannelBlock;
9
9
  exports.renderSessionOpenBlock = renderSessionOpenBlock;
10
+ exports.renderRelocateBlock = renderRelocateBlock;
10
11
  /**
11
12
  * The `<convene-channel>` render contract (P0-RENDER) + untrusted-prompt fencing
12
13
  * (P0-PROMPT). This is the ONE place the agent-facing text grammar is produced,
@@ -176,6 +177,31 @@ function renderLaneHaltSection(L, lanes, halts) {
176
177
  L.push('');
177
178
  }
178
179
  }
180
+ /** Compact "Active sessions" block — a faint pulse of who else is working now. */
181
+ function renderPresenceSection(L, presence) {
182
+ if (!presence.length)
183
+ return;
184
+ L.push('Active sessions (working now):');
185
+ for (const p of presence.slice(0, 8)) {
186
+ const bits = [inertToken(p.session, 64)];
187
+ if (p.area)
188
+ bits.push(inertToken(p.area, 32));
189
+ if (p.edits)
190
+ bits.push(`${p.edits} edit${p.edits === 1 ? '' : 's'}`);
191
+ if (p.ageSec != null && Number.isFinite(p.ageSec))
192
+ bits.push(presenceAge(p.ageSec));
193
+ L.push(' · ' + bits.join(' · '));
194
+ }
195
+ L.push('');
196
+ }
197
+ function presenceAge(sec) {
198
+ const s = Math.max(0, Math.round(sec));
199
+ if (s < 60)
200
+ return `${s}s ago`;
201
+ if (s < 3600)
202
+ return `${Math.round(s / 60)}m ago`;
203
+ return `${Math.round(s / 3600)}h ago`;
204
+ }
179
205
  function renderChannelBlock(input) {
180
206
  const { slug, member, session, lookbackMin, openItems, recent, health } = input;
181
207
  const lanes = input.lanes ?? [];
@@ -204,6 +230,7 @@ function renderChannelBlock(input) {
204
230
  // High-priority interrupts + active deploy lanes, above Open items (DEGRADED
205
231
  // suppresses these structurally — the caller passes empty arrays).
206
232
  renderLaneHaltSection(L, lanes, halts);
233
+ renderPresenceSection(L, input.presence ?? []);
207
234
  L.push('Open items for you:');
208
235
  if (openItems.length === 0) {
209
236
  L.push(' (none)');
@@ -295,3 +322,20 @@ function renderSessionOpenBlock(input) {
295
322
  L.push(`</${exports.SESSION_OPEN_TAG}>`);
296
323
  return L.join('\n');
297
324
  }
325
+ // ── <convene-relocate> (auto-isolate) ────────────────────────────────────────
326
+ exports.RELOCATE_TAG = 'convene-relocate';
327
+ /**
328
+ * The `<convene-relocate>` block emitted at SessionStart when this session booted
329
+ * into a checkout that already has a live sibling and a clean isolated worktree was
330
+ * provisioned for it. It is an IMPERATIVE, NON-alarming instruction the agent acts
331
+ * on — framed as "a tree is ready, move there", not a warning. Deliberately no
332
+ * "WARNING"; the move is the recommended, friction-free path.
333
+ */
334
+ function renderRelocateBlock(input) {
335
+ const { dest, branch } = input;
336
+ return (`<${exports.RELOCATE_TAG}>\n` +
337
+ `Another live session is active in this checkout. A clean isolated worktree is ready for you ` +
338
+ `at ${dest} (branch ${branch}, based on fresh origin/main, gitignored config copied). ` +
339
+ `To avoid clobbering the other session's files, relocate now: run \`cd ${dest}\` and continue your work there.\n` +
340
+ `</${exports.RELOCATE_TAG}>`);
341
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://dev.convene.live",