@sealant/mend 0.14.0 → 0.15.2

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/main.js CHANGED
@@ -11,7 +11,7 @@ import { formatLoadReport } from "./env.js";
11
11
  import { loginCommand } from "./login.js";
12
12
  import { pairCommand, qrCommand } from "./pair.js";
13
13
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
14
- import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
14
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
15
15
  import { sshCommand } from "./ssh-setup.js";
16
16
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
17
17
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
@@ -284,7 +284,37 @@ const launch = async (config, harness, args) => {
284
284
  say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
285
285
  // The worktree's name comes first, then the session details — it is the
286
286
  // identity every list leads with. `mend run` stays scriptable: flag only.
287
- const worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
287
+ // `--worktree` insists on joining: the name must already exist here.
288
+ let worktreeName;
289
+ if (parsed.worktree !== null) {
290
+ const detail = await api(config, "GET", `/projects/${project.id}`);
291
+ const existing = detail.worktrees ?? [];
292
+ if (detail.worktrees === undefined) {
293
+ return fail("this server predates shared worktrees — use --name instead");
294
+ }
295
+ const match = existing.find((worktree) => worktree.name === parsed.worktree) ??
296
+ (existing.filter((worktree) => worktree.name.startsWith(parsed.worktree ?? "")).length === 1
297
+ ? existing.find((worktree) => worktree.name.startsWith(parsed.worktree ?? ""))
298
+ : undefined);
299
+ if (match === undefined) {
300
+ const names = existing.map((worktree) => worktree.name).join(", ");
301
+ return fail(`no worktree matches "${parsed.worktree}" in ${project.name}${names === "" ? "" : ` — have: ${names}`}`);
302
+ }
303
+ worktreeName = match.name;
304
+ }
305
+ else {
306
+ worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
307
+ }
308
+ // Say when the name joins an existing worktree — a join is a fact worth
309
+ // stating before the session exists, not a surprise in the tree later.
310
+ if (worktreeName !== null) {
311
+ const detail = await api(config, "GET", `/projects/${project.id}`).catch(() => null);
312
+ const joined = detail?.worktrees?.find((worktree) => worktree.name === worktreeName);
313
+ if (joined !== undefined) {
314
+ const members = (detail?.sessions ?? []).filter((candidate) => candidate.worktreeId === joined.id).length;
315
+ say(`${green("✓")} joins worktree ${joined.name} ${dim(`· ${members} session${members === 1 ? "" : "s"} · branch ${joined.branch}`)}`);
316
+ }
317
+ }
288
318
  const lifecycle = harness === "run"
289
319
  ? "background"
290
320
  : parsed.detach
@@ -593,7 +623,7 @@ const attach = async (config, args) => {
593
623
  const match = sessions.find((s) => s.id.startsWith(prefix));
594
624
  if (match === undefined)
595
625
  return fail(`no active session matches "${prefix}"`);
596
- say(`${green("✓")} attaching to ${match.branch} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
626
+ say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
597
627
  say("");
598
628
  await attachOrExit(config, match.id, match.harness);
599
629
  exitAfterSessionEnd(config, match.id);
@@ -2067,7 +2097,7 @@ const rejoinCommand = async (config, args) => {
2067
2097
  if (session === undefined)
2068
2098
  return fail("session selection failed");
2069
2099
  const alreadyLive = agentLiveIn(detail, session);
2070
- say(`${green("✓")} rejoining ${session.branch} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2100
+ say(`${green("✓")} rejoining ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2071
2101
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
2072
2102
  let restored = false;
2073
2103
  const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
@@ -2168,6 +2198,15 @@ const printSessionRow = (row) => {
2168
2198
  */
2169
2199
  const sessionsCommand = async (config, args) => {
2170
2200
  const all = args.includes("--all");
2201
+ // v1 stays byte-stable for pinned integrations; v2 is the worktree envelope.
2202
+ if (args.includes("--json=v2")) {
2203
+ const projectFlag = args.indexOf("--project");
2204
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2205
+ ? String(args[projectFlag + 1])
2206
+ : null;
2207
+ say(JSON.stringify(await buildWorktreesJson(config, projectName), null, 2));
2208
+ return;
2209
+ }
2171
2210
  const json = args.includes("--json");
2172
2211
  const projectFlag = args.indexOf("--project");
2173
2212
  const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
@@ -2243,6 +2282,117 @@ const sessionsCommand = async (config, args) => {
2243
2282
  for (const row of rows)
2244
2283
  printSessionRow(row);
2245
2284
  };
2285
+ /**
2286
+ * The worktree-grouped view both `mend worktrees --json` and
2287
+ * `mend sessions --json=v2` emit. Against a pre-worktree server every session
2288
+ * becomes its own pseudo worktree (`id: null`) — the envelope shape is stable
2289
+ * either way, so integrations pin on `version`, not server age.
2290
+ */
2291
+ const toWorktreeJsonSession = (session) => ({
2292
+ id: session.id,
2293
+ harness: session.harness,
2294
+ label: session.label,
2295
+ status: session.status,
2296
+ summary: session.summary,
2297
+ createdAt: session.createdAt,
2298
+ });
2299
+ const buildWorktreesJson = async (config, projectName) => {
2300
+ const projects = await api(config, "GET", "/projects");
2301
+ const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
2302
+ const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
2303
+ const worktrees = [];
2304
+ for (const detail of details) {
2305
+ const reviewOf = (sessionIds) => {
2306
+ const facts = detail.annotations.find((a) => sessionIds.has(a.sessionId));
2307
+ return facts === undefined
2308
+ ? null
2309
+ : {
2310
+ openComments: facts.openComments,
2311
+ totalComments: facts.totalComments,
2312
+ pendingFollowUp: detail.annotations.some((a) => sessionIds.has(a.sessionId) && a.pendingFollowUp),
2313
+ };
2314
+ };
2315
+ const urlOf = (sessionIds) => {
2316
+ const first = [...sessionIds][0];
2317
+ return first === undefined ? null : `${config.url.replace(/\/$/, "")}/sessions/${first}`;
2318
+ };
2319
+ if (detail.worktrees !== undefined) {
2320
+ for (const worktree of detail.worktrees) {
2321
+ const members = detail.sessions.filter((session) => session.worktreeId === worktree.id);
2322
+ const ids = new Set(members.map((session) => session.id));
2323
+ worktrees.push({
2324
+ id: worktree.id,
2325
+ name: worktree.name,
2326
+ projectId: detail.project.id,
2327
+ projectName: detail.project.name,
2328
+ branch: worktree.branch,
2329
+ baseSha: worktree.baseSha,
2330
+ baseRef: worktree.baseRef,
2331
+ createdAt: worktree.createdAt,
2332
+ reviewUrl: urlOf(ids),
2333
+ review: reviewOf(ids),
2334
+ sessions: members
2335
+ .toSorted((a, b) => b.createdAt.localeCompare(a.createdAt))
2336
+ .map(toWorktreeJsonSession),
2337
+ });
2338
+ }
2339
+ }
2340
+ else {
2341
+ for (const session of detail.sessions) {
2342
+ const ids = new Set([session.id]);
2343
+ worktrees.push({
2344
+ id: null,
2345
+ name: session.worktree,
2346
+ projectId: detail.project.id,
2347
+ projectName: detail.project.name,
2348
+ branch: session.branch,
2349
+ baseSha: session.baseSha,
2350
+ baseRef: session.baseRef,
2351
+ createdAt: session.createdAt,
2352
+ reviewUrl: urlOf(ids),
2353
+ review: reviewOf(ids),
2354
+ sessions: [toWorktreeJsonSession(session)],
2355
+ });
2356
+ }
2357
+ }
2358
+ }
2359
+ return { version: 2, worktrees };
2360
+ };
2361
+ /** `mend worktrees [--project <p>] [--json]` — the container-first listing. */
2362
+ const worktreesCommand = async (config, args) => {
2363
+ const json = args.includes("--json");
2364
+ const projectFlag = args.indexOf("--project");
2365
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2366
+ ? String(args[projectFlag + 1])
2367
+ : null;
2368
+ const payload = await buildWorktreesJson(config, projectName);
2369
+ if (json) {
2370
+ say(JSON.stringify(payload, null, 2));
2371
+ return;
2372
+ }
2373
+ if (payload.worktrees.length === 0) {
2374
+ say(dim("no worktrees yet — mend claude --name <worktree> starts one"));
2375
+ return;
2376
+ }
2377
+ for (const worktree of payload.worktrees) {
2378
+ const live = worktree.sessions.filter((session) => ACTIVE_STATUSES.has(session.status)).length;
2379
+ const facts = [
2380
+ `${worktree.sessions.length} session${worktree.sessions.length === 1 ? "" : "s"}`,
2381
+ ];
2382
+ if (live > 0)
2383
+ facts.push(green(`${live} live`));
2384
+ if (worktree.review !== null && worktree.review.openComments > 0) {
2385
+ facts.push(amber(`${worktree.review.openComments} open`));
2386
+ }
2387
+ const base = worktree.baseRef ?? worktree.baseSha.slice(0, 12);
2388
+ say(`${worktree.name.padEnd(24)} ${worktree.projectName} ${dim(`${worktree.branch} · base ${base}`)} ${facts.join(dim(" · "))}`);
2389
+ for (const session of worktree.sessions) {
2390
+ const status = session.status.padEnd(9);
2391
+ const name = session.label ?? `session ${session.id.slice(0, 8)}`;
2392
+ say(` └ ${session.harness.padEnd(8)} ${dim(session.id.slice(0, 8))} ${ACTIVE_STATUSES.has(session.status) ? green(status) : dim(status)} ${name}`);
2393
+ }
2394
+ }
2395
+ };
2246
2396
  // ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
2247
2397
  const hasNodeFfi = () => {
2248
2398
  try {
@@ -2276,6 +2426,7 @@ const dashboard = async (config) => {
2276
2426
  await runDashboard({
2277
2427
  config,
2278
2428
  cwd: process.cwd(),
2429
+ cwdBranch: gitCurrentBranch(process.cwd()),
2279
2430
  api: (method, route, body) => request(config, method, route, body),
2280
2431
  attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
2281
2432
  });
@@ -2295,11 +2446,13 @@ start
2295
2446
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
2296
2447
  adopt a repository into the store (default: cwd; any git
2297
2448
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
2298
- mend codex|claude|opencode ["prompt"] [--name <worktree>] [--model <id>]
2299
- [--effort low|medium|high|xhigh|max]
2449
+ mend codex|claude|opencode ["prompt"] [--name <worktree>] [--worktree <existing>]
2450
+ [--model <id>] [--effort low|medium|high|xhigh|max]
2300
2451
  [--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
2301
- new session worktree + launch the harness in it; the
2302
- worktree's name is asked first (--name skips the ask), a
2452
+ launch the harness in a worktree; the worktree's name is
2453
+ asked first (--name skips the ask — an EXISTING name joins
2454
+ that worktree as a new session; --worktree joins only,
2455
+ failing if absent), a
2303
2456
  quoted prompt becomes its first message,
2304
2457
  --ask restores the harness's permission prompts, --fast
2305
2458
  requests priority processing (codex service tier),
@@ -2325,6 +2478,9 @@ everything else
2325
2478
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2326
2479
  hardware keys sign here; Ctrl-C stops sharing)
2327
2480
  mend run -- <command...> same, with an arbitrary command
2481
+ mend worktrees [--project <p>] [--json]
2482
+ every worktree and the sessions inside it (mend sessions
2483
+ --json stays the v1 flat list; --json=v2 groups like this)
2328
2484
  mend attach <session-id-prefix> reattach this terminal to a running session
2329
2485
  mend stop <session-id-prefix> | --all [--project <p>]
2330
2486
  stop the agent — the workspace harvests and closes; the
@@ -2421,6 +2577,8 @@ const main = async () => {
2421
2577
  case "sessions":
2422
2578
  case "status":
2423
2579
  return sessionsCommand(config, rest);
2580
+ case "worktrees":
2581
+ return worktreesCommand(config, rest);
2424
2582
  case undefined:
2425
2583
  case "ui":
2426
2584
  return dashboard(config);
package/dist/shared.js CHANGED
@@ -35,6 +35,7 @@ const LAUNCH_ERROR = {
35
35
  effort: null,
36
36
  base: null,
37
37
  name: null,
38
+ worktree: null,
38
39
  ask: false,
39
40
  fast: false,
40
41
  detach: false,
@@ -58,6 +59,7 @@ export const parseLaunchArgs = (args) => {
58
59
  let effort = null;
59
60
  let base = null;
60
61
  let workName = null;
62
+ let joinWorktree = null;
61
63
  let ask = false;
62
64
  let fast = false;
63
65
  let detach = false;
@@ -84,7 +86,8 @@ export const parseLaunchArgs = (args) => {
84
86
  arg === "--model" ||
85
87
  arg === "--effort" ||
86
88
  arg === "--base" ||
87
- arg === "--name") {
89
+ arg === "--name" ||
90
+ arg === "--worktree") {
88
91
  const value = flagArgs[index + 1];
89
92
  if (value === undefined)
90
93
  return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
@@ -96,6 +99,8 @@ export const parseLaunchArgs = (args) => {
96
99
  effort = value;
97
100
  else if (arg === "--name")
98
101
  workName = normalizeProjectName(value);
102
+ else if (arg === "--worktree")
103
+ joinWorktree = value;
99
104
  else
100
105
  base = value;
101
106
  index += 1;
@@ -123,6 +128,12 @@ export const parseLaunchArgs = (args) => {
123
128
  if (detach && foreground) {
124
129
  return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
125
130
  }
131
+ if (workName !== null && joinWorktree !== null) {
132
+ return {
133
+ ...LAUNCH_ERROR,
134
+ error: "--name and --worktree contradict — --name names (or joins), --worktree only joins",
135
+ };
136
+ }
126
137
  return {
127
138
  project,
128
139
  prompt,
@@ -130,6 +141,7 @@ export const parseLaunchArgs = (args) => {
130
141
  effort,
131
142
  base,
132
143
  name: workName,
144
+ worktree: joinWorktree,
133
145
  ask,
134
146
  fast,
135
147
  detach,
@@ -161,6 +173,19 @@ export const isDetachChunk = (data) => {
161
173
  }
162
174
  return false;
163
175
  };
176
+ /**
177
+ * What to call a session's worktree in banners and rows: a NAMED worktree is
178
+ * its branch minus the `mend/` prefix; an unnamed one (`mend/session/<uuid>`)
179
+ * is called by its auto-name label, or its short id before one lands.
180
+ */
181
+ export const sessionDisplayName = (session) => {
182
+ if (session.branch.startsWith("mend/") && !session.branch.startsWith("mend/session/")) {
183
+ return session.branch.slice("mend/".length);
184
+ }
185
+ if (!session.branch.startsWith("mend/session/"))
186
+ return session.branch;
187
+ return session.label ?? `session ${session.id.slice(0, 8)}`;
188
+ };
164
189
  export const LIVE_STATUSES = new Set([
165
190
  "starting",
166
191
  "running",
@@ -238,6 +263,12 @@ export const cwdFacts = (cwd) => ({
238
263
  repoRoot: gitTopLevel(cwd),
239
264
  originUrl: gitOriginUrl(cwd),
240
265
  });
266
+ /** The branch checked out at `cwd`, or null outside a repo or on a detached HEAD. */
267
+ export const gitCurrentBranch = (cwd) => {
268
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
269
+ const out = result.status === 0 ? result.stdout.trim() : "";
270
+ return out === "" || out === "HEAD" ? null : out;
271
+ };
241
272
  /** The cwd repo's origin remote, or null when there is no repo or no origin. */
242
273
  export const gitOriginUrl = (cwd) => {
243
274
  const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
package/dist/tui-theme.js CHANGED
@@ -23,6 +23,11 @@ export const RULE = "#37374a";
23
23
  export const COBALT = "#6f96f5";
24
24
  /** The selection wash: a neutral lifted row — visible, not colorful. */
25
25
  export const WASH = "#2c2c35";
26
+ /**
27
+ * The one exception to the no-painted-background rule: a floating modal
28
+ * must occlude the content beneath it, so it owns its ground.
29
+ */
30
+ export const SURFACE = "#17171d";
26
31
  export const AMBER = "#e2ab42";
27
32
  export const GREEN = "#69c189";
28
33
  export const RED = "#e87f76";
@@ -55,6 +55,7 @@ export const eventFamilies = (payload) => {
55
55
  return [];
56
56
  switch (parsed.type) {
57
57
  case "project":
58
+ case "worktree":
58
59
  case "session-process":
59
60
  return ["workbench"];
60
61
  // Session facts feed both surfaces: rows and annotations on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.14.0",
3
+ "version": "0.15.2",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",