@sealant/mend 0.19.0 → 0.21.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.
Files changed (54) hide show
  1. package/dist/agent-share.js +150 -0
  2. package/dist/clipboard.js +73 -0
  3. package/dist/dashboard-model.js +4 -9
  4. package/dist/dashboard.js +26 -17
  5. package/dist/help.js +23 -8
  6. package/dist/main.js +153 -128
  7. package/dist/shared.js +54 -11
  8. package/man/mend-accounts.1 +1 -1
  9. package/man/mend-adopt.1 +3 -3
  10. package/man/mend-attach.1 +3 -1
  11. package/man/mend-codex.1 +3 -1
  12. package/man/mend-completions.1 +1 -1
  13. package/man/mend-connect.1 +1 -1
  14. package/man/mend-continue.1 +1 -1
  15. package/man/mend-doctor.1 +1 -1
  16. package/man/mend-dotfiles-sync.1 +1 -1
  17. package/man/mend-dotfiles.1 +1 -1
  18. package/man/mend-env-cluster.1 +1 -1
  19. package/man/mend-env-load.1 +1 -1
  20. package/man/mend-env-show.1 +1 -1
  21. package/man/mend-help.1 +1 -1
  22. package/man/mend-keys-init.1 +4 -4
  23. package/man/mend-keys-mode.1 +11 -0
  24. package/man/mend-keys-share.1 +4 -2
  25. package/man/mend-keys-show.1 +3 -3
  26. package/man/mend-login.1 +1 -1
  27. package/man/mend-logout.1 +1 -1
  28. package/man/mend-man.1 +1 -1
  29. package/man/mend-pair.1 +1 -1
  30. package/man/mend-projects.1 +1 -1
  31. package/man/mend-refresh.1 +1 -1
  32. package/man/mend-rejoin.1 +1 -1
  33. package/man/mend-resume.1 +1 -1
  34. package/man/mend-run.1 +1 -1
  35. package/man/mend-service-add.1 +1 -1
  36. package/man/mend-service-connect.1 +1 -1
  37. package/man/mend-service-init.1 +1 -1
  38. package/man/mend-service-list.1 +1 -1
  39. package/man/mend-service-logs.1 +1 -1
  40. package/man/mend-service-restart.1 +1 -1
  41. package/man/mend-service-run.1 +1 -1
  42. package/man/mend-service-stop.1 +1 -1
  43. package/man/mend-sessions.1 +1 -1
  44. package/man/mend-shell.1 +1 -1
  45. package/man/mend-skills-push.1 +1 -1
  46. package/man/mend-skills.1 +1 -1
  47. package/man/mend-ssh-setup.1 +1 -1
  48. package/man/mend-ssh.1 +1 -1
  49. package/man/mend-stop.1 +1 -1
  50. package/man/mend-ui.1 +1 -1
  51. package/man/mend-version.1 +1 -1
  52. package/man/mend-worktrees.1 +1 -1
  53. package/man/mend.1 +7 -4
  54. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -5,6 +5,8 @@ import { createRequire } from "node:module";
5
5
  import * as net from "node:net";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { bridgeUrlOf, shareAgent, startAgentShare } from "./agent-share.js";
9
+ import { readClipboardImage } from "./clipboard.js";
8
10
  import { doctorCommand } from "./doctor.js";
9
11
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
10
12
  import { formatLoadReport } from "./env.js";
@@ -12,7 +14,7 @@ import { findCommand, manFileName, renderCommand, renderGroup, renderIndex, rend
12
14
  import { loginCommand } from "./login.js";
13
15
  import { pairCommand, qrCommand } from "./pair.js";
14
16
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
15
- import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
17
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, isPasteChunk, LIVE_STATUSES, pasteBytes, trackBracketedPaste, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
16
18
  import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
17
19
  import { sshCommand } from "./ssh-setup.js";
18
20
  import { cliVersion, fetchServerVersion, versionLines } from "./version.js";
@@ -194,7 +196,7 @@ const adopt = async (config, args) => {
194
196
  say(`${dim(" default branch")} ${project.defaultBranch}`);
195
197
  // Say which signer did the work — the clone already proved it answers.
196
198
  if (project.gitAuthMode === "mend-key") {
197
- say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
199
+ say(`${dim(" git auth")} mend key ${dim("(your Mend key signed this clone)")}`);
198
200
  }
199
201
  else if (project.gitAuthMode === "bridge") {
200
202
  say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
@@ -452,9 +454,12 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
452
454
  finishAttachment?.();
453
455
  };
454
456
  const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
457
+ let bracketedPaste = false;
455
458
  const onTtyFrame = (event) => {
456
459
  if (typeof event.data !== "string") {
457
- process.stdout.write(Buffer.from(event.data));
460
+ const bytes = Buffer.from(event.data);
461
+ bracketedPaste = trackBracketedPaste(bytes, bracketedPaste);
462
+ process.stdout.write(bytes);
458
463
  return;
459
464
  }
460
465
  try {
@@ -484,9 +489,36 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
484
489
  }
485
490
  if (ws.readyState !== WebSocket.OPEN)
486
491
  return;
492
+ if (isPasteChunk(data)) {
493
+ // Ctrl+V — the agent's own clipboard is empty (the workspace has no
494
+ // display), so an image on THIS machine's clipboard goes up to Mend and
495
+ // its workspace path is pasted instead; codex and claude read the path.
496
+ // No image, or no way to ask: the keystroke goes through untouched.
497
+ void pasteClipboardImage(data);
498
+ return;
499
+ }
487
500
  // Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
488
501
  ws.send(new Uint8Array(data).buffer);
489
502
  };
503
+ const forward = (bytes) => {
504
+ if (ws.readyState === WebSocket.OPEN)
505
+ ws.send(new Uint8Array(bytes).buffer);
506
+ };
507
+ const pasteClipboardImage = async (keystroke) => {
508
+ const image = await readClipboardImage();
509
+ if (image === null) {
510
+ forward(keystroke);
511
+ return;
512
+ }
513
+ try {
514
+ const stored = await request(config, "POST", `/sessions/${sessionId}/images`, { contentsBase64: image.bytes.toString("base64") });
515
+ forward(pasteBytes(stored.path, bracketedPaste));
516
+ }
517
+ catch {
518
+ // The TUI owns the screen; a bell is the only honest signal left.
519
+ process.stdout.write("\x07");
520
+ }
521
+ };
490
522
  try {
491
523
  sendResize();
492
524
  process.stdout.on("resize", onWinch);
@@ -1443,9 +1475,9 @@ const printGitKey = (key) => {
1443
1475
  say(key.publicKey);
1444
1476
  if (key.fingerprint !== null)
1445
1477
  say(dim(` ${key.fingerprint}`));
1446
- say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
1447
- say(dim(" settings → deploy keys; grant write access if sessions should push),"));
1448
- say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
1478
+ say(dim(" add this to your git account's SSH keys (GitHub: settings → SSH keys) so"));
1479
+ say(dim(" every repository you can reach works; for one repository only, add it"));
1480
+ say(dim(" as that repository's deploy key instead (grant write if sessions push)"));
1449
1481
  };
1450
1482
  const keysShow = async (config) => {
1451
1483
  const key = await api(config, "GET", "/keys/git");
@@ -1461,125 +1493,73 @@ const keysInit = async (config) => {
1461
1493
  printGitKey(key);
1462
1494
  };
1463
1495
  /**
1464
- * The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
1465
- * the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
1466
- * bridge-mode git ops sign with a key that never leaves this machine — a
1467
- * hardware key blinks here, not on the server. Agent-protocol messages are
1468
- * relayed verbatim (length-prefixed frames, base64 over JSON); the only
1469
- * inspection is each message's type byte, to say what is being asked.
1496
+ * `mend keys share`: the ssh-agent bridge in the foreground (agent-share.ts
1497
+ * holds the relay). Prints every connect, drop, and signature; Ctrl-C stops.
1470
1498
  */
1471
1499
  const keysShare = async (config) => {
1472
1500
  const agentSock = process.env["SSH_AUTH_SOCK"];
1473
1501
  if (agentSock === undefined || agentSock === "") {
1474
1502
  return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
1475
1503
  }
1476
- const url = parseMendUrl(`${config.url}/api/keys/bridge/ws`);
1477
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1478
- url.searchParams.set("host", os.hostname());
1479
- if (config.token !== null)
1480
- url.searchParams.set("token", config.token);
1481
- /**
1482
- * One complete agent exchange against the local agent: fresh connection,
1483
- * write the framed request verbatim, read one framed response. A hardware
1484
- * key blocks here until the touch — hence the generous timeout.
1485
- */
1486
- const askLocalAgent = (payload) => new Promise((resolve, reject) => {
1487
- const connection = net.connect(agentSock, () => {
1488
- connection.write(payload);
1489
- });
1490
- const timer = setTimeout(() => {
1491
- connection.destroy();
1492
- reject(new Error("the agent did not answer within 60s — touch missed?"));
1493
- }, 60_000);
1494
- let pending = Buffer.alloc(0);
1495
- connection.on("data", (chunk) => {
1496
- pending = Buffer.concat([pending, chunk]);
1497
- if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
1498
- clearTimeout(timer);
1499
- connection.end();
1500
- resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
1501
- }
1502
- });
1503
- connection.on("error", (error) => {
1504
- clearTimeout(timer);
1505
- reject(error);
1506
- });
1507
- });
1508
- // Strictly one request at a time, in arrival order — the agent protocol is
1509
- // request/response and a hardware key cannot answer two touches at once.
1510
- let chain = Promise.resolve();
1511
- const SSH_AGENTC_SIGN_REQUEST = 13;
1512
- const SSH_AGENTC_REQUEST_IDENTITIES = 11;
1513
- let attempt = 0;
1514
- for (;;) {
1515
- const ws = new WebSocket(url);
1516
- const closed = new Promise((resolve) => {
1517
- ws.addEventListener("close", () => resolve(), { once: true });
1518
- });
1519
- const opened = await new Promise((resolve) => {
1520
- ws.addEventListener("open", () => resolve(true), { once: true });
1521
- ws.addEventListener("error", () => resolve(false), { once: true });
1522
- });
1523
- if (opened) {
1524
- attempt = 0;
1525
- say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
1526
- say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
1527
- ws.addEventListener("message", (event) => {
1528
- const text = typeof event.data === "string"
1529
- ? event.data
1530
- : Buffer.from(event.data).toString("utf8");
1531
- let frame;
1532
- try {
1533
- frame = JSON.parse(text);
1534
- }
1535
- catch {
1504
+ const url = bridgeUrlOf(parseMendUrl(config.url).toString().replace(/\/$/, ""), config.token, os.hostname());
1505
+ await shareAgent({
1506
+ url,
1507
+ agentSock,
1508
+ signal: new AbortController().signal,
1509
+ onEvent: (event) => {
1510
+ switch (event.kind) {
1511
+ case "connected":
1512
+ say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
1513
+ say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
1536
1514
  return;
1537
- }
1538
- if (frame.t !== "req" ||
1539
- typeof frame.id !== "number" ||
1540
- typeof frame.payload !== "string") {
1515
+ case "disconnected":
1516
+ say(dim(`not connected — retrying in ${Math.round(event.retryMs / 1000)}s (Ctrl-C stops)`));
1541
1517
  return;
1542
- }
1543
- const id = frame.id;
1544
- const payload = Buffer.from(frame.payload, "base64");
1545
- const type = payload[4];
1546
- const context = typeof frame.context === "string" ? frame.context : "mend";
1547
- chain = chain.then(async () => {
1548
- const started = Date.now();
1549
- if (type === SSH_AGENTC_SIGN_REQUEST) {
1550
- say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
1551
- say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
1552
- }
1553
- else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
1554
- say(dim(` identities requested (${context})`));
1555
- }
1556
- try {
1557
- const response = await askLocalAgent(payload);
1558
- if (ws.readyState !== WebSocket.OPEN)
1559
- return null;
1560
- ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
1561
- if (type === SSH_AGENTC_SIGN_REQUEST) {
1562
- say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
1563
- }
1564
- }
1565
- catch (error) {
1566
- const message = error instanceof Error ? error.message : String(error);
1567
- if (ws.readyState === WebSocket.OPEN) {
1568
- ws.send(JSON.stringify({ t: "err", id, message }));
1569
- }
1570
- if (type === SSH_AGENTC_SIGN_REQUEST)
1571
- say(`${amber("✗")} not signed — ${message}`);
1572
- }
1573
- return null;
1574
- });
1575
- });
1576
- }
1577
- await closed;
1578
- attempt += 1;
1579
- const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
1580
- say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
1581
- await new Promise((resolve) => setTimeout(resolve, delay));
1518
+ case "sign-requested":
1519
+ say(`${amber("✎")} signature requested by mend ${dim(`(${event.context})`)}`);
1520
+ say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
1521
+ return;
1522
+ case "identities-requested":
1523
+ say(dim(` identities requested (${event.context})`));
1524
+ return;
1525
+ case "signed":
1526
+ say(`${green("✓")} signed ${dim(`(${event.seconds.toFixed(1)}s)`)}`);
1527
+ return;
1528
+ case "not-signed":
1529
+ say(`${amber("✗")} not signed — ${event.message}`);
1530
+ return;
1531
+ }
1532
+ },
1533
+ });
1534
+ };
1535
+ /**
1536
+ * `mend keys mode [mend-key|bridge]`: how this user's remotes are reached by
1537
+ * default. The Mend key lives on the server and works whenever the server
1538
+ * is up — detached sessions, the phone, the hot pool. Bridge signs with this
1539
+ * machine's ssh-agent and only works while a mend command is running here.
1540
+ */
1541
+ const keysMode = async (config, args) => {
1542
+ const [value] = args;
1543
+ if (value !== undefined && value !== "mend-key" && value !== "bridge") {
1544
+ return fail(`keys mode takes mend-key or bridge, not "${value}"`);
1545
+ }
1546
+ const access = value === undefined
1547
+ ? await api(config, "GET", "/me/git-access")
1548
+ : await api(config, "PUT", "/me/git-access", { mode: value });
1549
+ if (value !== undefined)
1550
+ say(`${green("✓")} git access · ${value}`);
1551
+ if (access.mode === "mend-key") {
1552
+ say(`mend-key ${dim("— your Mend key on the server signs; works whenever the server is up")}`);
1553
+ if (access.key.exists)
1554
+ printGitKey(access.key);
1555
+ else
1556
+ say(dim(" no key yet — mend keys init creates it"));
1557
+ return;
1582
1558
  }
1559
+ say(`bridge ${dim("— this machine's ssh-agent signs; only while a mend command runs here")}`);
1560
+ say(access.bridge.connected
1561
+ ? `${green("●")} signer connected · ${access.bridge.clientName ?? "unknown machine"}`
1562
+ : dim(" no signer connected — mend shares the agent whenever it runs (or: mend keys share)"));
1583
1563
  };
1584
1564
  const keysCommand = async (config, args) => {
1585
1565
  const [verb] = args;
@@ -1588,6 +1568,8 @@ const keysCommand = async (config, args) => {
1588
1568
  return keysInit(config);
1589
1569
  case "share":
1590
1570
  return keysShare(config);
1571
+ case "mode":
1572
+ return keysMode(config, args.slice(1));
1591
1573
  case "show":
1592
1574
  case undefined:
1593
1575
  return keysShow(config);
@@ -2534,15 +2516,58 @@ const dashboard = async (config) => {
2534
2516
  process.exit(rerun.status ?? 0);
2535
2517
  }
2536
2518
  const { runDashboard } = await import("./dashboard.js");
2537
- await runDashboard({
2538
- config,
2539
- cwd: process.cwd(),
2540
- cwdBranch: gitCurrentBranch(process.cwd()),
2541
- api: (method, route, body) => request(config, method, route, body),
2542
- attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
2543
- });
2519
+ const agentShare = await startShareIfBridge(config);
2520
+ try {
2521
+ await runDashboard({
2522
+ config,
2523
+ cwd: process.cwd(),
2524
+ cwdBranch: gitCurrentBranch(process.cwd()),
2525
+ api: (method, route, body) => request(config, method, route, body),
2526
+ attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
2527
+ agentShare,
2528
+ });
2529
+ }
2530
+ finally {
2531
+ agentShare?.stop();
2532
+ }
2544
2533
  };
2545
2534
  // ─── entry ──────────────────────────────────────────────────────────────────
2535
+ /**
2536
+ * Share this machine's ssh-agent for as long as the calling command runs —
2537
+ * only when the user's git access is bridge (mend keys mode). Their remotes
2538
+ * then sign here, and a base fetch before a worktree is created can't be
2539
+ * skipped for want of a signer. Any other mode, no agent, or an unreachable
2540
+ * server: nothing is shared. A newer share replaces an older one on the
2541
+ * server, so overlapping mend commands hand over rather than fight.
2542
+ */
2543
+ const startShareIfBridge = async (config) => {
2544
+ const agentSock = process.env["SSH_AUTH_SOCK"];
2545
+ if (agentSock === undefined || agentSock === "")
2546
+ return null;
2547
+ let access;
2548
+ try {
2549
+ access = await request(config, "GET", "/me/git-access");
2550
+ }
2551
+ catch {
2552
+ return null;
2553
+ }
2554
+ if (access.mode !== "bridge")
2555
+ return null;
2556
+ return startAgentShare({
2557
+ url: bridgeUrlOf(parseMendUrl(config.url).toString().replace(/\/$/, ""), config.token, os.hostname()),
2558
+ agentSock,
2559
+ });
2560
+ };
2561
+ /** Run one command under the share (see startShareIfBridge); stop it on the way out. */
2562
+ const withAgentShare = async (config, work) => {
2563
+ const share = await startShareIfBridge(config);
2564
+ try {
2565
+ return await work();
2566
+ }
2567
+ finally {
2568
+ share?.stop();
2569
+ }
2570
+ };
2546
2571
  /** `mend help [command...]`: the index, a group, or one page. */
2547
2572
  const helpCommand = (words) => {
2548
2573
  if (words.length === 0) {
@@ -2599,13 +2624,13 @@ const main = async () => {
2599
2624
  case "claude":
2600
2625
  case "opencode":
2601
2626
  case "run":
2602
- return launch(config, command, rest);
2627
+ return withAgentShare(config, () => launch(config, command, rest));
2603
2628
  case "attach":
2604
- return attach(config, rest);
2629
+ return withAgentShare(config, () => attach(config, rest));
2605
2630
  case "stop":
2606
2631
  return stopCommand(config, rest);
2607
2632
  case "shell":
2608
- return shellCommand(config, rest);
2633
+ return withAgentShare(config, () => shellCommand(config, rest));
2609
2634
  case "service":
2610
2635
  return serviceCommand(config, rest);
2611
2636
  case "login":
@@ -2640,9 +2665,9 @@ const main = async () => {
2640
2665
  case "continue":
2641
2666
  return continueSession(config, rest);
2642
2667
  case "resume":
2643
- return resumeCommand(config, rest);
2668
+ return withAgentShare(config, () => resumeCommand(config, rest));
2644
2669
  case "rejoin":
2645
- return rejoinCommand(config, rest);
2670
+ return withAgentShare(config, () => rejoinCommand(config, rest));
2646
2671
  case "projects":
2647
2672
  return projectsCommand(config);
2648
2673
  case "refresh":
package/dist/shared.js CHANGED
@@ -161,17 +161,7 @@ export const parseLaunchArgs = (args) => {
161
161
  export const isDetachChunk = (data) => {
162
162
  if (data.includes(0x1d))
163
163
  return true;
164
- // latin1 maps bytes 1:1, so the scan sees exactly the wire bytes. A string
165
- // scan rather than a regex: an escape byte inside a pattern trips lint, and
166
- // anchoring on the real ESC[ prefix keeps pasted text from ever matching.
167
- const text = data.toString("latin1");
168
- const prefix = "\u001b[93;5";
169
- for (let start = text.indexOf(prefix); start !== -1; start = text.indexOf(prefix, start + 1)) {
170
- const rest = text.slice(start + prefix.length);
171
- if (rest.startsWith("u") || rest.startsWith(":1u") || rest.startsWith(":2u"))
172
- return true;
173
- }
174
- return false;
164
+ return csiUKeysOf(data).some((key) => key.code === 93 && isCtrlChord(key) && isPressOrRepeat(key));
175
165
  };
176
166
  /**
177
167
  * What to call a session's worktree in banners and rows: a NAMED worktree is
@@ -293,3 +283,56 @@ export const normalizeProjectName = (raw) => {
293
283
  .replace(/-+$/, "");
294
284
  return slug === "" ? "project" : slug.slice(0, 64);
295
285
  };
286
+ // Built from a string: an escape byte inside a regex literal trips lint.
287
+ const CSI_U = new RegExp(`${String.fromCharCode(0x1b)}\\[(\\d+)(?::\\d*)*(?:;(\\d+)(?::(\\d+))?)?(?:;[\\d:]+)?u`, "g");
288
+ const LOCK_MODIFIERS = 64 | 128;
289
+ /** Every CSI-u key report inside a chunk, in order. */
290
+ export const csiUKeysOf = (data) => {
291
+ // latin1 maps bytes 1:1, so the scan sees exactly the wire bytes.
292
+ const text = data.toString("latin1");
293
+ const keys = [];
294
+ for (const match of text.matchAll(CSI_U)) {
295
+ const code = Number(match[1]);
296
+ const mods = (match[2] === undefined ? 1 : Number(match[2])) - 1;
297
+ const event = match[3] === undefined ? 1 : Number(match[3]);
298
+ keys.push({ code, mods: mods & ~LOCK_MODIFIERS, event });
299
+ }
300
+ return keys;
301
+ };
302
+ const CTRL = 4;
303
+ const SHIFT = 1;
304
+ /** Ctrl held, optionally shift, nothing else (alt/super/hyper/meta make it another chord). */
305
+ const isCtrlChord = (key) => (key.mods & ~SHIFT) === CTRL;
306
+ const isPressOrRepeat = (key) => key.event === 1 || key.event === 2;
307
+ /**
308
+ * Whether one stdin chunk is Ctrl+V — the raw 0x16 byte on its own, or one
309
+ * CSI-u report of `v` (118) or `V` (86) with ctrl, on press or repeat. Exact
310
+ * for the raw byte: a paste that happens to carry 0x16 is not a request.
311
+ */
312
+ export const isPasteChunk = (data) => {
313
+ if (data.length === 1 && data[0] === 0x16)
314
+ return true;
315
+ const keys = csiUKeysOf(data);
316
+ const key = keys[0];
317
+ return (keys.length === 1 &&
318
+ key !== undefined &&
319
+ (key.code === 118 || key.code === 86) &&
320
+ isCtrlChord(key) &&
321
+ isPressOrRepeat(key) &&
322
+ data.toString("latin1").startsWith("\u001b["));
323
+ };
324
+ /**
325
+ * Follow the remote app's bracketed-paste mode through the bytes it writes:
326
+ * the last `ESC [ ? 2004 h|l` in a chunk wins; a chunk with neither keeps the
327
+ * current state. A pasted image path is wrapped only when the app asked.
328
+ */
329
+ export const trackBracketedPaste = (output, current) => {
330
+ const text = output.toString("latin1");
331
+ const on = text.lastIndexOf("\u001b[?2004h");
332
+ const off = text.lastIndexOf("\u001b[?2004l");
333
+ if (on === -1 && off === -1)
334
+ return current;
335
+ return on > off;
336
+ };
337
+ /** The bytes that paste `text` into the remote app, bracketed when it asked. */
338
+ export const pasteBytes = (text, bracketed) => Buffer.from(bracketed ? `\u001b[200~${text}\u001b[201~` : text, "utf8");
@@ -1,4 +1,4 @@
1
- .TH MEND-ACCOUNTS 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ACCOUNTS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-accounts \- your connected accounts on the platform
4
4
  .SH SYNOPSIS
package/man/mend-adopt.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-ADOPT 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ADOPT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-adopt \- adopt a repository into the store
4
4
  .SH SYNOPSIS
@@ -8,14 +8,14 @@ mend\-adopt \- adopt a repository into the store
8
8
  .PP
9
9
  Clones the repository into Mend's store. Every session then gets its own worktree of it. The default source is the current directory; any git URL works, GitHub, GitLab, self\-hosted, ssh://, or a local path.
10
10
  .PP
11
- \-\-auth says how the store fetches from the remote. ambient uses the server's own credentials. mend\-key uses this machine's deploy key (see mend keys). bridge relays this machine's ssh\-agent while mend keys share runs, so hardware keys stay here.
11
+ \-\-auth says how the store fetches from the remote. Default: your mode from mend keys mode. mend\-key signs with your Mend key on the server (see mend keys). bridge relays this machine's ssh\-agent while a mend command runs here, so hardware keys stay on your desk. ambient uses the server's own credentials.
12
12
  .SH OPTIONS
13
13
  .TP
14
14
  .B \-\-name <name>
15
15
  the project's name in Mend. Default: the repository's
16
16
  .TP
17
17
  .B \-\-auth <mode>
18
- ambient, mend\-key, or bridge. Default: ambient
18
+ mend\-key, bridge, or ambient. Default: mend keys mode
19
19
  .SH EXAMPLES
20
20
  .PP
21
21
  .B mend adopt
package/man/mend-attach.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-ATTACH 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ATTACH 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-attach \- reattach this terminal to a running session
4
4
  .SH SYNOPSIS
@@ -9,5 +9,7 @@ mend\-attach \- reattach this terminal to a running session
9
9
  With no id, the one live session is taken; with several, a picker opens. A prefix of the id is enough.
10
10
  .PP
11
11
  A session that was picked up on the phone is taken back into this terminal: the phone's agent ends and the same conversation continues here.
12
+ .PP
13
+ Ctrl+V with an image on this machine's clipboard sends the image to the session and pastes its path; codex and claude read it. Needs wl\-paste on Wayland, xclip on X11, nothing extra on macOS.
12
14
  .SH SEE ALSO
13
15
  mend(1), mend\-stop(1), mend\-rejoin(1), mend\-shell(1)
package/man/mend-codex.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-CODEX 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-CODEX 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-codex \- launch codex, claude, or opencode in a recorded worktree
4
4
  .SH SYNOPSIS
@@ -12,6 +12,8 @@ mend codex, mend claude, and mend opencode are the same command with a different
12
12
  The worktree's name is asked first. \-\-name skips the ask; an existing name joins that worktree as a new session. \-\-worktree joins only and fails if the name is unknown. A quoted prompt becomes the first message.
13
13
  .PP
14
14
  Detach with Ctrl+] and the session keeps running. Reattach from any terminal with mend attach, or from the phone.
15
+ .PP
16
+ Ctrl+V with an image on this machine's clipboard sends the image to the session and pastes its path; codex and claude read it. Needs wl\-paste on Wayland, xclip on X11, nothing extra on macOS.
15
17
  .SH OPTIONS
16
18
  .TP
17
19
  .B \-\-name <worktree>
@@ -1,4 +1,4 @@
1
- .TH MEND-COMPLETIONS 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-COMPLETIONS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-completions \- print the TAB\-completion hook
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-CONNECT 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-CONNECT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-connect \- send this machine's provider credential to the platform
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-CONTINUE 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-CONTINUE 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-continue \- resume a session with its pending review follow\-up
4
4
  .SH SYNOPSIS
package/man/mend-doctor.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-DOCTOR 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-DOCTOR 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-doctor \- check this machine's setup
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-DOTFILES-SYNC 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-DOTFILES-SYNC 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-dotfiles\-sync \- capture config files from this machine into your store
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-DOTFILES 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-DOTFILES 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-dotfiles \- your dotfiles on the server: repo and synced files
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-CLUSTER 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ENV-CLUSTER 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-cluster \- bind Kubernetes secrets and configmaps to the workspaces
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-LOAD 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ENV-LOAD 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-load \- load a .env into the project
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-SHOW 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-ENV-SHOW 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-show \- what the project store holds: names only, never values
4
4
  .SH SYNOPSIS
package/man/mend-help.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-HELP 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-HELP 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-help \- this index, or one command's page
4
4
  .SH SYNOPSIS
@@ -1,11 +1,11 @@
1
- .TH MEND-KEYS-INIT 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-KEYS-INIT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
- mend\-keys\-init \- generate this machine's Mend deploy key (ed25519)
3
+ mend\-keys\-init \- create your Mend key (ed25519) on the server
4
4
  .SH SYNOPSIS
5
5
  .B mend keys init
6
6
  .br
7
7
  .SH DESCRIPTION
8
8
  .PP
9
- Creates a key under ~/.config/mend/keys for projects adopted with \-\-auth mend\-key. Add the public key on your git host as a deploy key.
9
+ One key per user, held on the server, never copied anywhere. Add the public key to your git account's SSH keys and every repository you can reach works, from detached sessions and the phone too. For one repository only, add it as that repository's deploy key instead.
10
10
  .SH SEE ALSO
11
- mend(1), mend\-keys\-show(1), mend\-adopt(1)
11
+ mend(1), mend\-keys\-show(1), mend\-keys\-mode(1), mend\-adopt(1)
@@ -0,0 +1,11 @@
1
+ .TH MEND-KEYS-MODE 1 "" "mend 0.21.0" "mend manual"
2
+ .SH NAME
3
+ mend\-keys\-mode \- how your remotes are reached: mend\-key or bridge
4
+ .SH SYNOPSIS
5
+ .B mend keys mode [mend\-key|bridge]
6
+ .br
7
+ .SH DESCRIPTION
8
+ .PP
9
+ mend\-key (the default) signs with your Mend key on the server and works whenever the server is up. bridge signs with this machine's ssh\-agent, so a hardware key never leaves your desk, but only while a mend command is running here. New projects adopt with this mode; a project's setup page can override it. Without a value, prints the current mode.
10
+ .SH SEE ALSO
11
+ mend(1), mend\-keys\-init(1), mend\-keys\-share(1)
@@ -1,4 +1,4 @@
1
- .TH MEND-KEYS-SHARE 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-KEYS-SHARE 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-keys\-share \- relay this machine's ssh\-agent to the server
4
4
  .SH SYNOPSIS
@@ -7,5 +7,7 @@ mend\-keys\-share \- relay this machine's ssh\-agent to the server
7
7
  .SH DESCRIPTION
8
8
  .PP
9
9
  Bridge mode for projects adopted with \-\-auth bridge: the server's git operations sign with the keys in this machine's ssh\-agent, so hardware keys never leave here. Runs until Ctrl\-C.
10
+ .PP
11
+ When your mode is bridge (mend keys mode), every attaching mend command and the dashboard do this on their own for as long as they run, so this command is for a machine that is not running one. The server takes one signer at a time; a newer share replaces the older one.
10
12
  .SH SEE ALSO
11
- mend(1), mend\-adopt(1)
13
+ mend(1), mend\-keys\-mode(1), mend\-adopt(1)