@sealant/mend 0.20.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 (52) hide show
  1. package/dist/agent-share.js +150 -0
  2. package/dist/dashboard.js +22 -2
  3. package/dist/help.js +21 -8
  4. package/dist/main.js +120 -126
  5. package/dist/shared.js +33 -16
  6. package/man/mend-accounts.1 +1 -1
  7. package/man/mend-adopt.1 +3 -3
  8. package/man/mend-attach.1 +1 -1
  9. package/man/mend-codex.1 +1 -1
  10. package/man/mend-completions.1 +1 -1
  11. package/man/mend-connect.1 +1 -1
  12. package/man/mend-continue.1 +1 -1
  13. package/man/mend-doctor.1 +1 -1
  14. package/man/mend-dotfiles-sync.1 +1 -1
  15. package/man/mend-dotfiles.1 +1 -1
  16. package/man/mend-env-cluster.1 +1 -1
  17. package/man/mend-env-load.1 +1 -1
  18. package/man/mend-env-show.1 +1 -1
  19. package/man/mend-help.1 +1 -1
  20. package/man/mend-keys-init.1 +4 -4
  21. package/man/mend-keys-mode.1 +11 -0
  22. package/man/mend-keys-share.1 +4 -2
  23. package/man/mend-keys-show.1 +3 -3
  24. package/man/mend-login.1 +1 -1
  25. package/man/mend-logout.1 +1 -1
  26. package/man/mend-man.1 +1 -1
  27. package/man/mend-pair.1 +1 -1
  28. package/man/mend-projects.1 +1 -1
  29. package/man/mend-refresh.1 +1 -1
  30. package/man/mend-rejoin.1 +1 -1
  31. package/man/mend-resume.1 +1 -1
  32. package/man/mend-run.1 +1 -1
  33. package/man/mend-service-add.1 +1 -1
  34. package/man/mend-service-connect.1 +1 -1
  35. package/man/mend-service-init.1 +1 -1
  36. package/man/mend-service-list.1 +1 -1
  37. package/man/mend-service-logs.1 +1 -1
  38. package/man/mend-service-restart.1 +1 -1
  39. package/man/mend-service-run.1 +1 -1
  40. package/man/mend-service-stop.1 +1 -1
  41. package/man/mend-sessions.1 +1 -1
  42. package/man/mend-shell.1 +1 -1
  43. package/man/mend-skills-push.1 +1 -1
  44. package/man/mend-skills.1 +1 -1
  45. package/man/mend-ssh-setup.1 +1 -1
  46. package/man/mend-ssh.1 +1 -1
  47. package/man/mend-stop.1 +1 -1
  48. package/man/mend-ui.1 +1 -1
  49. package/man/mend-version.1 +1 -1
  50. package/man/mend-worktrees.1 +1 -1
  51. package/man/mend.1 +7 -4
  52. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ import * as net from "node:net";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ /** The bridge endpoint for a server url: ws(s), this machine named, the token as a query. */
4
+ export const bridgeUrlOf = (serverUrl, token, host) => {
5
+ const url = new URL(`${serverUrl}/api/keys/bridge/ws`);
6
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
7
+ url.searchParams.set("host", host);
8
+ if (token !== null)
9
+ url.searchParams.set("token", token);
10
+ return url;
11
+ };
12
+ /** Reconnect backoff: 1s, 2s, … capped at 30s. */
13
+ export const retryDelayMs = (attempt) => Math.min(30_000, 1000 * 2 ** Math.min(Math.max(attempt - 1, 0), 5));
14
+ const SSH_AGENTC_REQUEST_IDENTITIES = 11;
15
+ const SSH_AGENTC_SIGN_REQUEST = 13;
16
+ /**
17
+ * One complete agent exchange against the local agent: fresh connection,
18
+ * write the framed request verbatim, read one framed response. A hardware
19
+ * key blocks here until the touch — hence the generous timeout.
20
+ */
21
+ const askLocalAgent = (agentSock, payload) => new Promise((resolve, reject) => {
22
+ const connection = net.connect(agentSock, () => {
23
+ connection.write(payload);
24
+ });
25
+ const timer = setTimeout(() => {
26
+ connection.destroy();
27
+ reject(new Error("the agent did not answer within 60s — touch missed?"));
28
+ }, 60_000);
29
+ let pending = Buffer.alloc(0);
30
+ connection.on("data", (chunk) => {
31
+ pending = Buffer.concat([pending, chunk]);
32
+ if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
33
+ clearTimeout(timer);
34
+ connection.end();
35
+ resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
36
+ }
37
+ });
38
+ connection.on("error", (error) => {
39
+ clearTimeout(timer);
40
+ reject(error);
41
+ });
42
+ });
43
+ /** An abortable wait; an abort simply ends it early. */
44
+ const sleep = (ms, signal) => delay(ms, undefined, { signal }).then(() => undefined, () => undefined);
45
+ /** Share until the signal aborts; every connect, drop, and signature is an event. */
46
+ export const shareAgent = async (options) => {
47
+ const { url, agentSock, signal, onEvent } = options;
48
+ // Requests are answered one at a time: the agent protocol has one in flight.
49
+ let chain = Promise.resolve();
50
+ let attempt = 0;
51
+ while (!signal.aborted) {
52
+ const ws = new WebSocket(url);
53
+ const onAbort = () => ws.close();
54
+ signal.addEventListener("abort", onAbort, { once: true });
55
+ const closed = new Promise((resolve) => {
56
+ ws.addEventListener("close", () => resolve(), { once: true });
57
+ });
58
+ const opened = await new Promise((resolve) => {
59
+ ws.addEventListener("open", () => resolve(true), { once: true });
60
+ ws.addEventListener("error", () => resolve(false), { once: true });
61
+ });
62
+ if (opened) {
63
+ attempt = 0;
64
+ onEvent({ kind: "connected" });
65
+ ws.addEventListener("message", (event) => {
66
+ const text = typeof event.data === "string"
67
+ ? event.data
68
+ : Buffer.from(event.data).toString("utf8");
69
+ let frame;
70
+ try {
71
+ frame = JSON.parse(text);
72
+ }
73
+ catch {
74
+ return;
75
+ }
76
+ if (frame.t !== "req" ||
77
+ typeof frame.id !== "number" ||
78
+ typeof frame.payload !== "string") {
79
+ return;
80
+ }
81
+ const id = frame.id;
82
+ const payload = Buffer.from(frame.payload, "base64");
83
+ const type = payload[4];
84
+ const context = typeof frame.context === "string" ? frame.context : "mend";
85
+ chain = chain.then(async () => {
86
+ const started = Date.now();
87
+ if (type === SSH_AGENTC_SIGN_REQUEST)
88
+ onEvent({ kind: "sign-requested", context });
89
+ else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
90
+ onEvent({ kind: "identities-requested", context });
91
+ }
92
+ try {
93
+ const response = await askLocalAgent(agentSock, payload);
94
+ if (ws.readyState !== WebSocket.OPEN)
95
+ return null;
96
+ ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
97
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
98
+ onEvent({ kind: "signed", context, seconds: (Date.now() - started) / 1000 });
99
+ }
100
+ }
101
+ catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ if (ws.readyState === WebSocket.OPEN) {
104
+ ws.send(JSON.stringify({ t: "err", id, message }));
105
+ }
106
+ if (type === SSH_AGENTC_SIGN_REQUEST)
107
+ onEvent({ kind: "not-signed", context, message });
108
+ }
109
+ return null;
110
+ });
111
+ });
112
+ }
113
+ await closed;
114
+ signal.removeEventListener("abort", onAbort);
115
+ if (signal.aborted)
116
+ return;
117
+ attempt += 1;
118
+ const retryMs = retryDelayMs(attempt);
119
+ onEvent({ kind: "disconnected", retryMs });
120
+ await sleep(retryMs, signal);
121
+ }
122
+ };
123
+ /** Start sharing in the background; `stop` ends it. */
124
+ export const startAgentShare = (options) => {
125
+ const controller = new AbortController();
126
+ const listeners = new Set();
127
+ let state = "connecting";
128
+ void shareAgent({
129
+ ...options,
130
+ signal: controller.signal,
131
+ onEvent: (event) => {
132
+ if (event.kind === "connected")
133
+ state = "connected";
134
+ else if (event.kind === "disconnected")
135
+ state = "connecting";
136
+ for (const listener of listeners)
137
+ listener(event);
138
+ },
139
+ });
140
+ return {
141
+ snapshot: () => state,
142
+ subscribe: (listener) => {
143
+ listeners.add(listener);
144
+ return () => {
145
+ listeners.delete(listener);
146
+ };
147
+ },
148
+ stop: () => controller.abort(),
149
+ };
150
+ };
package/dist/dashboard.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
2
2
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
3
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
4
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
- import { useEffect, useRef, useState } from "react";
5
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
6
6
  import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
7
7
  import { reviewTargetForSession } from "./review-workflow.js";
8
8
  import { ReviewScreen } from "./review.js";
@@ -124,6 +124,8 @@ const keepSpanVisible = (scroll, top, height) => {
124
124
  };
125
125
  const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
126
126
  const PROJECTS_PANE_WIDTH = 30;
127
+ /** The header's share fact when no share runs. */
128
+ const noShare = () => "off";
127
129
  const App = ({ ctx, onQuit }) => {
128
130
  const renderer = useRenderer();
129
131
  const queryClient = useQueryClient();
@@ -143,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
143
145
  const [busy, setBusy] = useState(null);
144
146
  const [busyStarted, setBusyStarted] = useState(0);
145
147
  const [status, setStatus] = useState(null);
148
+ const shareState = useSyncExternalStore((onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => { }), ctx.agentShare === null ? noShare : ctx.agentShare.snapshot);
146
149
  const [editing, setEditing] = useState(null);
147
150
  /** Session id a stop is armed against; the second press fires it. */
148
151
  const [stopArmed, setStopArmed] = useState(null);
@@ -197,6 +200,23 @@ const App = ({ ctx, onQuit }) => {
197
200
  const selectSession = (id) => setSessionKey(`s:${id}`);
198
201
  const say = (text) => setStatus({ text, at: Date.now() });
199
202
  const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
203
+ // Signature requests are facts from outside React — the status line says them.
204
+ useEffect(() => {
205
+ const share = ctx.agentShare;
206
+ if (share === null)
207
+ return;
208
+ return share.subscribe((event) => {
209
+ if (event === null)
210
+ return;
211
+ if (event.kind === "sign-requested") {
212
+ say(`✎ signature requested (${event.context}) — touch your key if it blinks`);
213
+ }
214
+ else if (event.kind === "signed")
215
+ say(`✓ signed (${event.seconds.toFixed(1)}s)`);
216
+ else if (event.kind === "not-signed")
217
+ say(`✗ not signed — ${event.message}`);
218
+ });
219
+ }, [ctx.agentShare]);
200
220
  // A repo underfoot that the store has never met: raise the adopt offer the
201
221
  // first time the workbench answers and no project matches the cwd.
202
222
  useEffect(() => {
@@ -1051,7 +1071,7 @@ const App = ({ ctx, onQuit }) => {
1051
1071
  refetch();
1052
1072
  }, onQuit: onQuit }));
1053
1073
  }
1054
- return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
1074
+ return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] }), shareState === "off" ? null : (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: shareState === "connected" ? MUTED : FAINT, children: shareState === "connected" ? "agent shared" : "agent share reconnecting" })] }))] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
1055
1075
  if (typeof value === "string")
1056
1076
  submitRename(editing, value);
1057
1077
  } }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, adoptOffer !== null ? (_jsxs("box", { position: "absolute", zIndex: 11, left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - 7) / 2)), width: Math.min(70, terminalCols - 4), height: 7, border: true, borderStyle: "rounded", borderColor: COBALT, title: " adopt this repository? ", titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { children: " " }), _jsx("span", { fg: INK, children: adoptOffer.name }), _jsx("span", { fg: FAINT, children: " · not in the store yet" })] }), _jsx("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { fg: FAINT, children: " auth " }), ADOPT_AUTH_MODES.map((candidate, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " · " }) : null, _jsx("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `▸ ${candidate.label}` : candidate.label })] }, candidate.mode)))] }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: adoptOffer.source === adoptOffer.root
package/dist/help.js CHANGED
@@ -63,11 +63,11 @@ export const COMMANDS = [
63
63
  synopsis: ["[source] [--name <name>] [--auth ambient|mend-key|bridge]"],
64
64
  description: [
65
65
  "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.",
66
- "--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.",
66
+ "--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.",
67
67
  ],
68
68
  options: [
69
69
  { flag: "--name <name>", text: "the project's name in Mend. Default: the repository's" },
70
- { flag: "--auth <mode>", text: "ambient, mend-key, or bridge. Default: ambient" },
70
+ { flag: "--auth <mode>", text: "mend-key, bridge, or ambient. Default: mend keys mode" },
71
71
  ],
72
72
  examples: [
73
73
  { command: "mend adopt", text: "the repository you are standing in" },
@@ -471,21 +471,33 @@ export const COMMANDS = [
471
471
  {
472
472
  name: "keys init",
473
473
  section: "project setup",
474
- summary: "generate this machine's Mend deploy key (ed25519)",
474
+ summary: "create your Mend key (ed25519) on the server",
475
475
  synopsis: [],
476
476
  description: [
477
- "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.",
477
+ "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.",
478
478
  ],
479
- see: ["keys show", "adopt"],
479
+ see: ["keys show", "keys mode", "adopt"],
480
480
  },
481
481
  {
482
482
  name: "keys show",
483
483
  section: "project setup",
484
- summary: "print the public key",
484
+ summary: "print your Mend public key",
485
485
  synopsis: [],
486
- description: ["The key to add as a deploy key on your git host."],
486
+ description: [
487
+ "The key to add to your git account's SSH keys, or as a deploy key on one repository.",
488
+ ],
487
489
  see: ["keys init"],
488
490
  },
491
+ {
492
+ name: "keys mode",
493
+ section: "project setup",
494
+ summary: "how your remotes are reached: mend-key or bridge",
495
+ synopsis: ["[mend-key|bridge]"],
496
+ description: [
497
+ "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.",
498
+ ],
499
+ see: ["keys init", "keys share"],
500
+ },
489
501
  {
490
502
  name: "keys share",
491
503
  section: "project setup",
@@ -493,8 +505,9 @@ export const COMMANDS = [
493
505
  synopsis: [],
494
506
  description: [
495
507
  "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.",
508
+ "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.",
496
509
  ],
497
- see: ["adopt"],
510
+ see: ["keys mode", "adopt"],
498
511
  },
499
512
  // ── this machine ───────────────────────────────────────────────────────
500
513
  {
package/dist/main.js CHANGED
@@ -5,6 +5,7 @@ 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";
8
9
  import { readClipboardImage } from "./clipboard.js";
9
10
  import { doctorCommand } from "./doctor.js";
10
11
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
@@ -195,7 +196,7 @@ const adopt = async (config, args) => {
195
196
  say(`${dim(" default branch")} ${project.defaultBranch}`);
196
197
  // Say which signer did the work — the clone already proved it answers.
197
198
  if (project.gitAuthMode === "mend-key") {
198
- 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)")}`);
199
200
  }
200
201
  else if (project.gitAuthMode === "bridge") {
201
202
  say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
@@ -1474,9 +1475,9 @@ const printGitKey = (key) => {
1474
1475
  say(key.publicKey);
1475
1476
  if (key.fingerprint !== null)
1476
1477
  say(dim(` ${key.fingerprint}`));
1477
- say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
1478
- say(dim(" settings → deploy keys; grant write access if sessions should push),"));
1479
- 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)"));
1480
1481
  };
1481
1482
  const keysShow = async (config) => {
1482
1483
  const key = await api(config, "GET", "/keys/git");
@@ -1492,125 +1493,73 @@ const keysInit = async (config) => {
1492
1493
  printGitKey(key);
1493
1494
  };
1494
1495
  /**
1495
- * The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
1496
- * the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
1497
- * bridge-mode git ops sign with a key that never leaves this machine — a
1498
- * hardware key blinks here, not on the server. Agent-protocol messages are
1499
- * relayed verbatim (length-prefixed frames, base64 over JSON); the only
1500
- * 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.
1501
1498
  */
1502
1499
  const keysShare = async (config) => {
1503
1500
  const agentSock = process.env["SSH_AUTH_SOCK"];
1504
1501
  if (agentSock === undefined || agentSock === "") {
1505
1502
  return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
1506
1503
  }
1507
- const url = parseMendUrl(`${config.url}/api/keys/bridge/ws`);
1508
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1509
- url.searchParams.set("host", os.hostname());
1510
- if (config.token !== null)
1511
- url.searchParams.set("token", config.token);
1512
- /**
1513
- * One complete agent exchange against the local agent: fresh connection,
1514
- * write the framed request verbatim, read one framed response. A hardware
1515
- * key blocks here until the touch — hence the generous timeout.
1516
- */
1517
- const askLocalAgent = (payload) => new Promise((resolve, reject) => {
1518
- const connection = net.connect(agentSock, () => {
1519
- connection.write(payload);
1520
- });
1521
- const timer = setTimeout(() => {
1522
- connection.destroy();
1523
- reject(new Error("the agent did not answer within 60s — touch missed?"));
1524
- }, 60_000);
1525
- let pending = Buffer.alloc(0);
1526
- connection.on("data", (chunk) => {
1527
- pending = Buffer.concat([pending, chunk]);
1528
- if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
1529
- clearTimeout(timer);
1530
- connection.end();
1531
- resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
1532
- }
1533
- });
1534
- connection.on("error", (error) => {
1535
- clearTimeout(timer);
1536
- reject(error);
1537
- });
1538
- });
1539
- // Strictly one request at a time, in arrival order — the agent protocol is
1540
- // request/response and a hardware key cannot answer two touches at once.
1541
- let chain = Promise.resolve();
1542
- const SSH_AGENTC_SIGN_REQUEST = 13;
1543
- const SSH_AGENTC_REQUEST_IDENTITIES = 11;
1544
- let attempt = 0;
1545
- for (;;) {
1546
- const ws = new WebSocket(url);
1547
- const closed = new Promise((resolve) => {
1548
- ws.addEventListener("close", () => resolve(), { once: true });
1549
- });
1550
- const opened = await new Promise((resolve) => {
1551
- ws.addEventListener("open", () => resolve(true), { once: true });
1552
- ws.addEventListener("error", () => resolve(false), { once: true });
1553
- });
1554
- if (opened) {
1555
- attempt = 0;
1556
- say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
1557
- say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
1558
- ws.addEventListener("message", (event) => {
1559
- const text = typeof event.data === "string"
1560
- ? event.data
1561
- : Buffer.from(event.data).toString("utf8");
1562
- let frame;
1563
- try {
1564
- frame = JSON.parse(text);
1565
- }
1566
- 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`));
1567
1514
  return;
1568
- }
1569
- if (frame.t !== "req" ||
1570
- typeof frame.id !== "number" ||
1571
- typeof frame.payload !== "string") {
1515
+ case "disconnected":
1516
+ say(dim(`not connected — retrying in ${Math.round(event.retryMs / 1000)}s (Ctrl-C stops)`));
1572
1517
  return;
1573
- }
1574
- const id = frame.id;
1575
- const payload = Buffer.from(frame.payload, "base64");
1576
- const type = payload[4];
1577
- const context = typeof frame.context === "string" ? frame.context : "mend";
1578
- chain = chain.then(async () => {
1579
- const started = Date.now();
1580
- if (type === SSH_AGENTC_SIGN_REQUEST) {
1581
- say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
1582
- say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
1583
- }
1584
- else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
1585
- say(dim(` identities requested (${context})`));
1586
- }
1587
- try {
1588
- const response = await askLocalAgent(payload);
1589
- if (ws.readyState !== WebSocket.OPEN)
1590
- return null;
1591
- ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
1592
- if (type === SSH_AGENTC_SIGN_REQUEST) {
1593
- say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
1594
- }
1595
- }
1596
- catch (error) {
1597
- const message = error instanceof Error ? error.message : String(error);
1598
- if (ws.readyState === WebSocket.OPEN) {
1599
- ws.send(JSON.stringify({ t: "err", id, message }));
1600
- }
1601
- if (type === SSH_AGENTC_SIGN_REQUEST)
1602
- say(`${amber("✗")} not signed — ${message}`);
1603
- }
1604
- return null;
1605
- });
1606
- });
1607
- }
1608
- await closed;
1609
- attempt += 1;
1610
- const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
1611
- say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
1612
- 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;
1613
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)"));
1614
1563
  };
1615
1564
  const keysCommand = async (config, args) => {
1616
1565
  const [verb] = args;
@@ -1619,6 +1568,8 @@ const keysCommand = async (config, args) => {
1619
1568
  return keysInit(config);
1620
1569
  case "share":
1621
1570
  return keysShare(config);
1571
+ case "mode":
1572
+ return keysMode(config, args.slice(1));
1622
1573
  case "show":
1623
1574
  case undefined:
1624
1575
  return keysShow(config);
@@ -2565,15 +2516,58 @@ const dashboard = async (config) => {
2565
2516
  process.exit(rerun.status ?? 0);
2566
2517
  }
2567
2518
  const { runDashboard } = await import("./dashboard.js");
2568
- await runDashboard({
2569
- config,
2570
- cwd: process.cwd(),
2571
- cwdBranch: gitCurrentBranch(process.cwd()),
2572
- api: (method, route, body) => request(config, method, route, body),
2573
- attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
2574
- });
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
+ }
2575
2533
  };
2576
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
+ };
2577
2571
  /** `mend help [command...]`: the index, a group, or one page. */
2578
2572
  const helpCommand = (words) => {
2579
2573
  if (words.length === 0) {
@@ -2630,13 +2624,13 @@ const main = async () => {
2630
2624
  case "claude":
2631
2625
  case "opencode":
2632
2626
  case "run":
2633
- return launch(config, command, rest);
2627
+ return withAgentShare(config, () => launch(config, command, rest));
2634
2628
  case "attach":
2635
- return attach(config, rest);
2629
+ return withAgentShare(config, () => attach(config, rest));
2636
2630
  case "stop":
2637
2631
  return stopCommand(config, rest);
2638
2632
  case "shell":
2639
- return shellCommand(config, rest);
2633
+ return withAgentShare(config, () => shellCommand(config, rest));
2640
2634
  case "service":
2641
2635
  return serviceCommand(config, rest);
2642
2636
  case "login":
@@ -2671,9 +2665,9 @@ const main = async () => {
2671
2665
  case "continue":
2672
2666
  return continueSession(config, rest);
2673
2667
  case "resume":
2674
- return resumeCommand(config, rest);
2668
+ return withAgentShare(config, () => resumeCommand(config, rest));
2675
2669
  case "rejoin":
2676
- return rejoinCommand(config, rest);
2670
+ return withAgentShare(config, () => rejoinCommand(config, rest));
2677
2671
  case "projects":
2678
2672
  return projectsCommand(config);
2679
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,16 +283,43 @@ 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;
296
307
  /**
297
- * Whether one stdin chunk is exactly Ctrl+V — the raw 0x16 byte, or the
298
- * kitty CSI-u form `ESC [ 118 ; 5 u` (`v` is code 118) on press or repeat.
299
- * Exact, not contained: a paste that happens to carry 0x16 is not a request.
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.
300
311
  */
301
312
  export const isPasteChunk = (data) => {
302
313
  if (data.length === 1 && data[0] === 0x16)
303
314
  return true;
304
- const text = data.toString("latin1");
305
- return text === "\u001b[118;5u" || text === "\u001b[118;5:1u" || text === "\u001b[118;5:2u";
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["));
306
323
  };
307
324
  /**
308
325
  * Follow the remote app's bracketed-paste mode through the bytes it writes:
@@ -1,4 +1,4 @@
1
- .TH MEND-ACCOUNTS 1 "" "mend 0.20.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.20.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.20.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
package/man/mend-codex.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-CODEX 1 "" "mend 0.20.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
@@ -1,4 +1,4 @@
1
- .TH MEND-COMPLETIONS 1 "" "mend 0.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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)
@@ -1,11 +1,11 @@
1
- .TH MEND-KEYS-SHOW 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-KEYS-SHOW 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
- mend\-keys\-show \- print the public key
3
+ mend\-keys\-show \- print your Mend public key
4
4
  .SH SYNOPSIS
5
5
  .B mend keys show
6
6
  .br
7
7
  .SH DESCRIPTION
8
8
  .PP
9
- The key to add as a deploy key on your git host.
9
+ The key to add to your git account's SSH keys, or as a deploy key on one repository.
10
10
  .SH SEE ALSO
11
11
  mend(1), mend\-keys\-init(1)
package/man/mend-login.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-LOGIN 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-LOGIN 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-login \- sign this terminal in through the browser
4
4
  .SH SYNOPSIS
package/man/mend-logout.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-LOGOUT 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-LOGOUT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-logout \- revoke this terminal's device token and forget it
4
4
  .SH SYNOPSIS
package/man/mend-man.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-MAN 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-MAN 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-man \- one command's page, or the whole manual, in man
4
4
  .SH SYNOPSIS
package/man/mend-pair.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-PAIR 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-PAIR 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-pair \- pair a phone or a second machine
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-PROJECTS 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-PROJECTS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-projects \- adopted projects and their live sessions
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-REFRESH 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-REFRESH 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-refresh \- fetch origin's branches into the store
4
4
  .SH SYNOPSIS
package/man/mend-rejoin.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-REJOIN 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-REJOIN 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-rejoin \- attach if live, otherwise resume
4
4
  .SH SYNOPSIS
package/man/mend-resume.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-RESUME 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-RESUME 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-resume \- rejoin a settled session with its state restored
4
4
  .SH SYNOPSIS
package/man/mend-run.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-RUN 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-RUN 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-run \- run any command as a session
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-ADD 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-ADD 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-add \- adopt a port something in the workspace already listens on
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-CONNECT 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-CONNECT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-connect \- bring live Services to this machine's loopback
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-INIT 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-INIT 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-init \- scaffold mend.toml from package.json and compose ports
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-LIST 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-LIST 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-list \- every live service and its observed state
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-LOGS 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-LOGS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-logs \- follow a supervised service's output
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-RESTART 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-RESTART 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-restart \- re\-run a service's recorded command, same URL
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-RUN 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-RUN 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-run \- start and supervise a server in the session's workspace
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SERVICE-STOP 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SERVICE-STOP 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-service\-stop \- stop a service and close its host port
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SESSIONS 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SESSIONS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-sessions \- sessions with their review facts
4
4
  .SH SYNOPSIS
package/man/mend-shell.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-SHELL 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SHELL 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-shell \- open a shell in a live session's workspace
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SKILLS-PUSH 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SKILLS-PUSH 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-skills\-push \- upload ~/.agents/skills into the library
4
4
  .SH SYNOPSIS
package/man/mend-skills.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-SKILLS 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SKILLS 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-skills \- your skill library on the server, or a project's
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-SSH-SETUP 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SSH-SETUP 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-ssh\-setup \- make this machine ready to ssh into workspaces, once
4
4
  .SH SYNOPSIS
package/man/mend-ssh.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-SSH 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-SSH 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-ssh \- workspace SSH status: gateway, registered keys, ssh config
4
4
  .SH SYNOPSIS
package/man/mend-stop.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-STOP 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-STOP 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-stop \- stop the agent; the record and the review remain
4
4
  .SH SYNOPSIS
package/man/mend-ui.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-UI 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-UI 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-ui \- the dashboard: every project and session, live
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-VERSION 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-VERSION 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-version \- this CLI's version, and the server's when it answers
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-WORKTREES 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND-WORKTREES 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-worktrees \- every worktree and the sessions inside it
4
4
  .SH SYNOPSIS
package/man/mend.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND 1 "" "mend 0.20.0" "mend manual"
1
+ .TH MEND 1 "" "mend 0.21.0" "mend manual"
2
2
  .SH NAME
3
3
  mend \- the agent workbench
4
4
  .SH SYNOPSIS
@@ -112,10 +112,13 @@ your dotfiles on the server: repo and synced files
112
112
  capture config files from this machine into your store
113
113
  .TP
114
114
  .B mend keys init
115
- generate this machine's Mend deploy key (ed25519)
115
+ create your Mend key (ed25519) on the server
116
116
  .TP
117
117
  .B mend keys show
118
- print the public key
118
+ print your Mend public key
119
+ .TP
120
+ .B mend keys mode
121
+ how your remotes are reached: mend\-key or bridge
119
122
  .TP
120
123
  .B mend keys share
121
124
  relay this machine's ssh\-agent to the server
@@ -162,4 +165,4 @@ url, token, and device id from mend login
162
165
  .B ~/.config/mend/keys/
163
166
  the deploy key from mend keys init
164
167
  .SH SEE ALSO
165
- mend\-login(1), mend\-connect(1), mend\-adopt(1), mend\-codex(1), mend\-pair(1), mend\-doctor(1), mend\-ui(1), mend\-attach(1), mend\-stop(1), mend\-shell(1), mend\-run(1), mend\-continue(1), mend\-resume(1), mend\-rejoin(1), mend\-sessions(1), mend\-worktrees(1), mend\-projects(1), mend\-refresh(1), mend\-service\-run(1), mend\-service\-add(1), mend\-service\-connect(1), mend\-service\-list(1), mend\-service\-logs(1), mend\-service\-restart(1), mend\-service\-stop(1), mend\-service\-init(1), mend\-env\-show(1), mend\-env\-load(1), mend\-env\-cluster(1), mend\-skills(1), mend\-skills\-push(1), mend\-dotfiles(1), mend\-dotfiles\-sync(1), mend\-keys\-init(1), mend\-keys\-show(1), mend\-keys\-share(1), mend\-ssh(1), mend\-ssh\-setup(1), mend\-accounts(1), mend\-logout(1), mend\-completions(1), mend\-version(1), mend\-help(1), mend\-man(1)
168
+ mend\-login(1), mend\-connect(1), mend\-adopt(1), mend\-codex(1), mend\-pair(1), mend\-doctor(1), mend\-ui(1), mend\-attach(1), mend\-stop(1), mend\-shell(1), mend\-run(1), mend\-continue(1), mend\-resume(1), mend\-rejoin(1), mend\-sessions(1), mend\-worktrees(1), mend\-projects(1), mend\-refresh(1), mend\-service\-run(1), mend\-service\-add(1), mend\-service\-connect(1), mend\-service\-list(1), mend\-service\-logs(1), mend\-service\-restart(1), mend\-service\-stop(1), mend\-service\-init(1), mend\-env\-show(1), mend\-env\-load(1), mend\-env\-cluster(1), mend\-skills(1), mend\-skills\-push(1), mend\-dotfiles(1), mend\-dotfiles\-sync(1), mend\-keys\-init(1), mend\-keys\-show(1), mend\-keys\-mode(1), mend\-keys\-share(1), mend\-ssh(1), mend\-ssh\-setup(1), mend\-accounts(1), mend\-logout(1), mend\-completions(1), mend\-version(1), mend\-help(1), mend\-man(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",