@sealant/mend 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pair.js ADDED
@@ -0,0 +1,83 @@
1
+ const paint = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}` : text;
2
+ const dim = paint("2");
3
+ const green = paint("32");
4
+ const say = (line) => process.stdout.write(`${line}\n`);
5
+ const fail = (message) => {
6
+ process.stderr.write(`mend: ${message}\n`);
7
+ process.exit(1);
8
+ };
9
+ const takeFlagValue = (args, flag) => {
10
+ const at = args.indexOf(flag);
11
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
12
+ };
13
+ /**
14
+ * The code as a human reads it aloud: 8 Crockford characters in two groups. The
15
+ * server compares case-insensitively with dashes stripped, so the dash is purely
16
+ * for the eye and either form can be typed back.
17
+ */
18
+ export const groupCode = (code) => {
19
+ const bare = code.replace(/[^0-9a-z]/gi, "").toUpperCase();
20
+ return bare.length <= 4 ? bare : `${bare.slice(0, 4)}-${bare.slice(4)}`;
21
+ };
22
+ /** The scanned string. The device reads the base URL and the code out of it, nothing else. */
23
+ export const pairingLink = (url, code) => `mend://pair?u=${encodeURIComponent(url)}&c=${code}`;
24
+ /**
25
+ * Tailscale hands every node an IPv4 in the CGNAT range 100.64.0.0/10 — the same
26
+ * observation packages/api/src/machine.ts makes. A tailnet URL is preferred because
27
+ * it keeps working when the phone leaves the LAN.
28
+ */
29
+ const isTailnetUrl = (value) => {
30
+ let host;
31
+ try {
32
+ host = new URL(value).hostname;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ const parts = host.split(".").map(Number);
38
+ const [first, second] = parts;
39
+ if (parts.length !== 4 || first === undefined || second === undefined)
40
+ return false;
41
+ return first === 100 && second >= 64 && second <= 127;
42
+ };
43
+ /** `--url` wins; otherwise the tailnet address, otherwise the first candidate the server offered. */
44
+ export const chooseUrl = (urls, override) => override ?? urls.find(isTailnetUrl) ?? urls[0] ?? null;
45
+ /** Whole minutes left, floored at 0; null when the server sent a date this CLI cannot read. */
46
+ export const minutesUntil = (expiresAt, now = Date.now()) => {
47
+ const at = Date.parse(expiresAt);
48
+ if (Number.isNaN(at))
49
+ return null;
50
+ return Math.max(0, Math.round((at - now) / 60_000));
51
+ };
52
+ /** The QR as block characters. Deliberately not gated on isTTY — a piped QR still scans. */
53
+ export const renderQr = async (text) => {
54
+ const { toString: toQrString } = await import("qrcode");
55
+ const options = { type: "terminal", small: true };
56
+ return (await toQrString(text, options)).trimEnd();
57
+ };
58
+ /**
59
+ * `mend pair [--url <base url>]`: mint a pairing code and show it. The device names
60
+ * itself when it claims the code, so this side sends nothing but the request.
61
+ */
62
+ export const pairCommand = async (args, api) => {
63
+ const pairing = await api("POST", "/me/devices/pairings");
64
+ const url = chooseUrl(pairing.urls, takeFlagValue(args, "--url"));
65
+ if (url === null) {
66
+ return fail("no LAN or tailnet address to hand the device — pass one: mend pair --url http://<host>:<port>");
67
+ }
68
+ const minutes = minutesUntil(pairing.expiresAt);
69
+ say(await renderQr(pairingLink(url, pairing.code)));
70
+ say(`${green("✓")} pairing code ${groupCode(pairing.code)}`);
71
+ say(` ${dim("url")} ${url}`);
72
+ say(` ${dim("expires")} ${minutes === null ? pairing.expiresAt : `in ${minutes} min`} ${dim("· one device, once")}`);
73
+ say("");
74
+ say(dim(" scan it in the Mend app, or enter the url and the code there by hand"));
75
+ say(dim(" the app is apps/mobile — build it yourself; it is not published yet"));
76
+ };
77
+ /** `mend qr <text>`: print a terminal QR of anything. Hidden from help; the installer uses it. */
78
+ export const qrCommand = async (args) => {
79
+ const text = args.join(" ").trim();
80
+ if (text === "")
81
+ return fail("usage: mend qr <text>");
82
+ say(await renderQr(text));
83
+ };
@@ -1,15 +1,8 @@
1
- import { CONTINUE_COMMANDS } from "./shared.js";
2
1
  export const reviewTargetForSession = (session, annotation, projectName) => annotation?.changeId === null || annotation?.changeId === undefined
3
2
  ? null
4
3
  : { session, changeId: annotation.changeId, projectName };
5
4
  export const commentRange = (start, current) => start === null
6
5
  ? { line: current, endLine: null }
7
6
  : { line: Math.min(start, current), endLine: Math.max(start, current) };
8
- /** Close the review loop without changing session identity or worktree. */
9
- export const deliverReview = async (api, session, instruction) => {
10
- const build = CONTINUE_COMMANDS[session.harness];
11
- if (build === undefined)
12
- throw new Error(`Harness “${session.harness}” has no known relaunch command`);
13
- await api("POST", `/sessions/${session.id}/follow-up/deliver`, {});
14
- await api("POST", `/sessions/${session.id}/launch`, { argv: build(instruction) });
15
- };
7
+ /** One server-owned operation: persist intent, launch, correlate, finalize. */
8
+ export const deliverReview = (api, sessionId, input) => api("POST", `/sessions/${sessionId}/follow-up/deliver`, input);
package/dist/review.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { RGBA, SyntaxStyle, } from "@opentui/core";
4
4
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
8
8
  import { commentRange, deliverReview } from "./review-workflow.js";
9
- import { LIVE_STATUSES } from "./shared.js";
10
9
  import { openUrl } from "./terminal.js";
11
10
  import { ADD_WASH, AMBER, BG, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, PANEL, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
12
11
  const REVIEW_KEY = (changeId) => ["review", changeId];
@@ -27,7 +26,19 @@ const truncate = (text, width) => {
27
26
  return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}…`;
28
27
  };
29
28
  const fetchReview = async (ctx, changeId) => {
30
- const wire = await ctx.api("GET", `/changes/${changeId}/diff`);
29
+ const opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
30
+ idempotencyKey: `cli-review:${changeId}`,
31
+ });
32
+ const pinned = await ctx.api("GET", `/changes/${changeId}/reviews/${opened.slice.id}/diff`);
33
+ const wire = {
34
+ change: pinned.change,
35
+ diff: pinned.patch,
36
+ files: pinned.files.map((file) => ({
37
+ path: file.newPath ?? file.oldPath ?? "unknown path",
38
+ additions: file.additions,
39
+ deletions: file.deletions,
40
+ })),
41
+ };
31
42
  const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
32
43
  ctx.api("GET", `/changes/${changeId}/comments`),
33
44
  ctx.api("GET", `/changes/${changeId}/tour`),
@@ -37,6 +48,11 @@ const fetchReview = async (ctx, changeId) => {
37
48
  ]);
38
49
  return {
39
50
  wire,
51
+ sliceId: pinned.slice.id,
52
+ checkpointAId: pinned.checkpointA.id,
53
+ checkpointBId: pinned.checkpointB.id,
54
+ diffDigest: pinned.slice.diffDigest,
55
+ anchorFiles: pinned.anchorFiles,
40
56
  files: buildReviewFiles(wire.diff, wire.files),
41
57
  comments,
42
58
  tour,
@@ -113,14 +129,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
113
129
  const [editor, setEditor] = useState(null);
114
130
  const [busy, setBusy] = useState(false);
115
131
  const [status, setStatus] = useState("");
116
- const [checkpointState, setCheckpointState] = useState("recording");
132
+ const checkpointState = data === undefined ? (isFetching ? "recording" : "failed") : "recorded";
117
133
  const lockRef = useRef(false);
118
134
  const editorRef = useRef(null);
119
135
  const diffRef = useRef(null);
120
136
  const diffScrollRef = useRef(null);
121
137
  const fileScrollRef = useRef(null);
122
138
  const commentScrollRef = useRef(null);
123
- const checkpointedRef = useRef(false);
124
139
  const files = useMemo(() => {
125
140
  const order = {
126
141
  added: 0,
@@ -152,21 +167,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
152
167
  const refresh = () => {
153
168
  void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
154
169
  };
155
- const recordReviewCheckpoint = () => {
156
- if (checkpointedRef.current)
157
- return;
158
- checkpointedRef.current = true;
159
- setCheckpointState("recording");
160
- void ctx
161
- .api("POST", `/sessions/${session.id}/checkpoints`, { trigger: "review-open" })
162
- .then(() => setCheckpointState("recorded"))
163
- .catch((error) => {
164
- checkpointedRef.current = false;
165
- setCheckpointState("failed");
166
- setStatus(`Checkpoint not recorded · ${errorMessage(error)} · K retries`);
167
- });
168
- };
169
- useEffect(recordReviewCheckpoint, [ctx, session.id]);
170
170
  useEffect(() => {
171
171
  fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
172
172
  }, [fileIndex]);
@@ -325,6 +325,8 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
325
325
  setEditor({
326
326
  kind: "send",
327
327
  instruction: assembleReviewInstruction(data.wire.change, sendable),
328
+ commentIds: sendable.map((comment) => comment.id),
329
+ idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`,
328
330
  });
329
331
  };
330
332
  const submitEditor = async () => {
@@ -338,17 +340,54 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
338
340
  setWorking(true);
339
341
  try {
340
342
  if (editor.kind === "comment") {
341
- await ctx.api("POST", `/changes/${changeId}/comments`, {
342
- file: editor.file,
343
- line: editor.line,
344
- endLine: editor.endLine,
343
+ if (data === undefined)
344
+ throw new Error("The pinned Review is not available.");
345
+ const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
346
+ const endLine = editor.endLine ?? editor.line;
347
+ const hunk = editor.line === null || endLine === null
348
+ ? null
349
+ : (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
350
+ editor.line !== null &&
351
+ editor.line >= candidate.newStart &&
352
+ endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
353
+ if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
354
+ throw new Error("The selected line is outside the pinned Review anchor.");
355
+ }
356
+ await ctx.api("POST", `/changes/${changeId}/reviews/${data.sliceId}/comments`, {
357
+ target: editor.file === null
358
+ ? {
359
+ oldPath: null,
360
+ newPath: null,
361
+ side: null,
362
+ startLine: null,
363
+ endLine: null,
364
+ hunkContextHash: null,
365
+ }
366
+ : {
367
+ oldPath: anchorFile?.oldPath ?? null,
368
+ newPath: anchorFile?.newPath ?? null,
369
+ side: "new",
370
+ startLine: editor.line,
371
+ endLine,
372
+ hunkContextHash: hunk?.contextHash ?? null,
373
+ },
345
374
  body: value,
346
375
  });
347
376
  setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
348
377
  }
349
378
  else {
350
- await ctx.api("POST", `/sessions/${session.id}/follow-up`, { instruction: value });
351
- setStatus("Follow-up saved · y delivers it and relaunches the same session");
379
+ if (data === undefined)
380
+ throw new Error("The pinned Review is not available.");
381
+ await deliverReview(ctx.api, session.id, {
382
+ reviewSliceId: data.sliceId,
383
+ checkpointAId: data.checkpointAId,
384
+ checkpointBId: data.checkpointBId,
385
+ diffDigest: data.diffDigest,
386
+ commentIds: editor.commentIds,
387
+ instruction: value,
388
+ idempotencyKey: editor.idempotencyKey,
389
+ });
390
+ setStatus("Follow-up delivery requested · retry keeps the same run");
352
391
  }
353
392
  setEditor(null);
354
393
  refresh();
@@ -366,16 +405,29 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
366
405
  void runAction(`${state} ${anchorOf(selectedComment)}`, () => ctx.api("POST", `/changes/${changeId}/comments/${selectedComment.id}/state`, { state }));
367
406
  };
368
407
  const deliverAndRelaunch = () => {
369
- if (data?.followUp?.status !== "pending") {
370
- setStatus("No pending follow-up — s assembles the open review comments first");
408
+ const followUp = data?.followUp ?? null;
409
+ if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
410
+ setStatus("No retryable follow-up — s assembles the open Review comments first");
371
411
  return;
372
412
  }
373
- if (LIVE_STATUSES.has(data.sessionStatus)) {
374
- setStatus("The session is live — deliver the pending follow-up once it settles");
413
+ if (followUp.reviewSliceId === null ||
414
+ followUp.checkpointAId === null ||
415
+ followUp.checkpointBId === null ||
416
+ followUp.diffDigest === null ||
417
+ followUp.idempotencyKey === null) {
418
+ setStatus("Legacy follow-up · recreate it from a pinned Review before delivery");
375
419
  return;
376
420
  }
377
- const instruction = data.followUp.instruction;
378
- void runAction("Delivering review and relaunching the same session", () => deliverReview(ctx.api, session, instruction), "Review delivered · the same session is running · diff updates live");
421
+ const input = {
422
+ reviewSliceId: followUp.reviewSliceId,
423
+ checkpointAId: followUp.checkpointAId,
424
+ checkpointBId: followUp.checkpointBId,
425
+ diffDigest: followUp.diffDigest,
426
+ commentIds: followUp.commentIds,
427
+ instruction: followUp.instruction,
428
+ idempotencyKey: followUp.idempotencyKey,
429
+ };
430
+ void runAction("Delivering the persisted Review bundle", () => deliverReview(ctx.api, session.id, input), "Delivery reconciled · the same idempotency key names one run");
379
431
  };
380
432
  const focusOrder = wide
381
433
  ? ["files", "diff", "comments"]
@@ -410,12 +462,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
410
462
  setStatus("Refreshing live change…");
411
463
  return refresh();
412
464
  }
413
- if (key.name === "k" && key.shift) {
414
- if (checkpointState !== "failed")
415
- return;
416
- recordReviewCheckpoint();
417
- return;
418
- }
419
465
  if (key.name === "o") {
420
466
  openUrl(`${ctx.config.url}/changes/${changeId}`);
421
467
  setStatus("Opened web review");
@@ -518,13 +564,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
518
564
  const displayPatch = selectedFile === null || !showWhitespace
519
565
  ? (selectedFile?.patch ?? "")
520
566
  : visibleWhitespace(selectedFile.patch);
521
- const footer = editor !== null
522
- ? " ctrl+enter save · esc cancel"
523
- : focus === "files"
567
+ const footer = editor === null
568
+ ? focus === "files"
524
569
  ? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
525
570
  : focus === "comments"
526
571
  ? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
527
- : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`;
572
+ : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
573
+ : " ctrl+enter save · esc cancel";
528
574
  const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
529
575
  rootOptions: { backgroundColor: PANEL, border: false },
530
576
  wrapperOptions: { backgroundColor: PANEL },
@@ -553,7 +599,7 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
553
599
  contentOptions: { backgroundColor: PANEL },
554
600
  }, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
555
601
  ? {}
556
- : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : null] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
602
+ : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment === null ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
557
603
  ? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
558
604
  : ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
559
605
  }
package/dist/shared.js CHANGED
@@ -18,17 +18,163 @@ export const CONTINUE_COMMANDS = {
18
18
  claude: (instruction) => ["claude", instruction],
19
19
  opencode: (instruction) => ["opencode", "run", instruction],
20
20
  };
21
+ /** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
22
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
23
+ const LAUNCH_ERROR = {
24
+ project: null,
25
+ prompt: null,
26
+ model: null,
27
+ effort: null,
28
+ base: null,
29
+ ask: false,
30
+ fast: false,
31
+ custom: [],
32
+ };
33
+ /**
34
+ * `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
35
+ * [--base <ref>] [--ask] [--project <p>]`, plus `mend run … -- <command...>`.
36
+ * The first non-flag positional is the prompt; a second one is an error so a
37
+ * forgotten quote fails loudly instead of launching with half a sentence.
38
+ */
39
+ export const parseLaunchArgs = (args) => {
40
+ const dashdash = args.indexOf("--");
41
+ const custom = dashdash === -1 ? [] : args.slice(dashdash + 1);
42
+ const flagArgs = dashdash === -1 ? args : args.slice(0, dashdash);
43
+ let project = null;
44
+ let prompt = null;
45
+ let model = null;
46
+ let effort = null;
47
+ let base = null;
48
+ let ask = false;
49
+ let fast = false;
50
+ for (let index = 0; index < flagArgs.length; index += 1) {
51
+ const arg = flagArgs[index] ?? "";
52
+ if (arg === "--ask") {
53
+ ask = true;
54
+ continue;
55
+ }
56
+ if (arg === "--fast") {
57
+ fast = true;
58
+ continue;
59
+ }
60
+ if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
61
+ const value = flagArgs[index + 1];
62
+ if (value === undefined)
63
+ return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
64
+ if (arg === "--project")
65
+ project = value;
66
+ else if (arg === "--model")
67
+ model = value;
68
+ else if (arg === "--effort")
69
+ effort = value;
70
+ else
71
+ base = value;
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (arg.startsWith("-")) {
76
+ // Also catches a prompt starting with "-", which the harness would
77
+ // otherwise read as a flag of its own.
78
+ return {
79
+ ...LAUNCH_ERROR,
80
+ error: `unknown flag ${arg} — quote the prompt if it starts with "-"`,
81
+ };
82
+ }
83
+ if (prompt !== null) {
84
+ return {
85
+ ...LAUNCH_ERROR,
86
+ error: 'one prompt only — quote it: mend claude "fix the auth test"',
87
+ };
88
+ }
89
+ prompt = arg;
90
+ }
91
+ if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
92
+ return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
93
+ }
94
+ return { project, prompt, model, effort, base, ask, fast, custom, error: null };
95
+ };
21
96
  export const LIVE_STATUSES = new Set([
22
97
  "starting",
23
98
  "running",
24
99
  "waiting",
25
100
  "idle",
26
101
  ]);
27
- /** The cwd's adopted project: adopted-from-here first, then a basename match. */
28
- export const matchProjectByCwd = (projects, cwd) => {
29
- const byOrigin = projects.find((p) => p.originUrl !== null && cwd.startsWith(p.originUrl));
30
- const byName = projects.find((p) => p.name === path.basename(cwd));
31
- return byOrigin ?? byName;
102
+ /**
103
+ * Whether the session's AGENT is live. Session status is a fold over every process — a session
104
+ * reads `idle` while a shell holds the workspace after its agent ended — so the agent's own row
105
+ * answers when one exists; `starting` is a launch with no row yet.
106
+ */
107
+ export const agentIsLive = (session, currentAgent) => currentAgent === null
108
+ ? LIVE_STATUSES.has(session.status)
109
+ : session.status === "starting" ||
110
+ (currentAgent.exitedAt === null &&
111
+ (currentAgent.status === "starting" || currentAgent.status === "running"));
112
+ /** What an ended agent process's exit says about the work; null while it runs. */
113
+ export const agentOutcome = (currentAgent) => {
114
+ if (currentAgent === null || currentAgent.exitedAt === null)
115
+ return null;
116
+ if (currentAgent.status === "stopped")
117
+ return "stopped";
118
+ if (currentAgent.harness === "shell")
119
+ return "completed";
120
+ return currentAgent.exitCode === null || currentAgent.exitCode === 0 ? "completed" : "failed";
121
+ };
122
+ /**
123
+ * A git remote URL reduced to `host/owner/name` so https, ssh, scp-style, and
124
+ * `.git` spellings of the same repository compare equal. Null for anything
125
+ * that is not a URL-ish remote (a local path stays a path).
126
+ */
127
+ export const normalizeRemoteUrl = (raw) => {
128
+ if (raw === null)
129
+ return null;
130
+ const trimmed = raw.trim();
131
+ if (trimmed === "")
132
+ return null;
133
+ const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(trimmed);
134
+ const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/\s:]+)(?::\d+)?\/(.+)$/i.exec(trimmed);
135
+ const parts = url ?? scp;
136
+ if (parts === null)
137
+ return null;
138
+ const [, host, rest] = parts;
139
+ if (host === undefined || rest === undefined)
140
+ return null;
141
+ const repoPath = rest.replace(/\/+$/, "").replace(/\.git$/i, "");
142
+ return `${host.toLowerCase()}/${repoPath.toLowerCase()}`;
143
+ };
144
+ /**
145
+ * The cwd's adopted project, in order: a project adopted from this very
146
+ * path; a project whose origin is the same remote as the cwd repo's origin
147
+ * (normalized, so a GitHub-adopted project matches a clone of it anywhere);
148
+ * a project named like the repository root (through the same normalization
149
+ * adopt uses, so a checkout called "Mend" matches the project "mend").
150
+ */
151
+ export const matchProjectByCwd = (projects, facts) => {
152
+ const root = facts.repoRoot ?? facts.cwd;
153
+ const byPath = projects.find((p) => p.originUrl !== null &&
154
+ !p.originUrl.includes("://") &&
155
+ (root === p.originUrl ||
156
+ facts.cwd === p.originUrl ||
157
+ facts.cwd.startsWith(`${p.originUrl}/`)));
158
+ if (byPath !== undefined)
159
+ return byPath;
160
+ const remote = normalizeRemoteUrl(facts.originUrl);
161
+ const byRemote = remote === null ? undefined : projects.find((p) => normalizeRemoteUrl(p.originUrl) === remote);
162
+ if (byRemote !== undefined)
163
+ return byRemote;
164
+ const name = normalizeProjectName(path.basename(root));
165
+ return projects.find((p) => p.name === name);
166
+ };
167
+ /** Read the cwd's facts from git once; callers pass them to the matcher. */
168
+ export const cwdFacts = (cwd) => ({
169
+ cwd,
170
+ repoRoot: gitTopLevel(cwd),
171
+ originUrl: gitOriginUrl(cwd),
172
+ });
173
+ /** The cwd repo's origin remote, or null when there is no repo or no origin. */
174
+ export const gitOriginUrl = (cwd) => {
175
+ const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
176
+ const out = result.status === 0 ? result.stdout.trim() : "";
177
+ return out === "" ? null : out;
32
178
  };
33
179
  /** The repository root of `cwd`, or null when it is not inside a git repo. */
34
180
  export const gitTopLevel = (cwd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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",
@@ -32,11 +32,13 @@
32
32
  "@opentui/core": "^0.5.1",
33
33
  "@opentui/react": "^0.5.1",
34
34
  "@tanstack/react-query": "^5.101.4",
35
+ "qrcode": "^1.5.4",
35
36
  "react": "19.2.3"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@effect/vitest": "4.0.0-beta.98",
39
40
  "@types/node": "^22.10.2",
41
+ "@types/qrcode": "^1.5.5",
40
42
  "@types/react": "^19.2.0",
41
43
  "typescript": "^5.7.2",
42
44
  "vitest": "^3.0.5",