@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.
- package/dist/agent-share.js +150 -0
- package/dist/clipboard.js +73 -0
- package/dist/dashboard-model.js +4 -9
- package/dist/dashboard.js +26 -17
- package/dist/help.js +23 -8
- package/dist/main.js +153 -128
- package/dist/shared.js +54 -11
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +3 -3
- package/man/mend-attach.1 +3 -1
- package/man/mend-codex.1 +3 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +4 -4
- package/man/mend-keys-mode.1 +11 -0
- package/man/mend-keys-share.1 +4 -2
- package/man/mend-keys-show.1 +3 -3
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +1 -1
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +7 -4
- 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
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
3
|
+
/** The first image type this reader accepts from a clipboard's offered list. */
|
|
4
|
+
export const pickImageType = (offered) => {
|
|
5
|
+
const types = offered.split(/\s+/).map((type) => type.trim().toLowerCase());
|
|
6
|
+
for (const type of IMAGE_TYPES)
|
|
7
|
+
if (types.includes(type))
|
|
8
|
+
return type;
|
|
9
|
+
return null;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* AppleScript's `«data PNGf…»` literal: the PNG bytes as hex, from
|
|
13
|
+
* `the clipboard as «class PNGf»`. Null when the clipboard held no image.
|
|
14
|
+
*/
|
|
15
|
+
export const parsePngfHex = (output) => {
|
|
16
|
+
const match = /«data PNGf([0-9A-Fa-f]+)»/.exec(output.trim());
|
|
17
|
+
if (match?.[1] === undefined || match[1].length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
return Buffer.from(match[1], "hex");
|
|
20
|
+
};
|
|
21
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
22
|
+
/** One clipboard tool call; null when the tool is missing, fails, or times out. */
|
|
23
|
+
const run = (command, args) => new Promise((resolve) => {
|
|
24
|
+
execFile(command, [...args], { encoding: "buffer", maxBuffer: MAX_BYTES, timeout: 3000 }, (error, stdout) => {
|
|
25
|
+
resolve(error === null ? stdout : null);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const fromWayland = async () => {
|
|
29
|
+
const offered = await run("wl-paste", ["--list-types"]);
|
|
30
|
+
if (offered === null)
|
|
31
|
+
return null;
|
|
32
|
+
const mediaType = pickImageType(offered.toString("utf8"));
|
|
33
|
+
if (mediaType === null)
|
|
34
|
+
return null;
|
|
35
|
+
const bytes = await run("wl-paste", ["--no-newline", "--type", mediaType]);
|
|
36
|
+
return bytes === null || bytes.length === 0 ? null : { bytes, mediaType };
|
|
37
|
+
};
|
|
38
|
+
const fromX11 = async () => {
|
|
39
|
+
const offered = await run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]);
|
|
40
|
+
if (offered === null)
|
|
41
|
+
return null;
|
|
42
|
+
const mediaType = pickImageType(offered.toString("utf8"));
|
|
43
|
+
if (mediaType === null)
|
|
44
|
+
return null;
|
|
45
|
+
const bytes = await run("xclip", ["-selection", "clipboard", "-t", mediaType, "-o"]);
|
|
46
|
+
return bytes === null || bytes.length === 0 ? null : { bytes, mediaType };
|
|
47
|
+
};
|
|
48
|
+
const fromMac = async () => {
|
|
49
|
+
const output = await run("osascript", ["-e", "the clipboard as «class PNGf»"]);
|
|
50
|
+
if (output === null)
|
|
51
|
+
return null;
|
|
52
|
+
const bytes = parsePngfHex(output.toString("utf8"));
|
|
53
|
+
return bytes === null ? null : { bytes, mediaType: "image/png" };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Read an image off the clipboard: wl-paste on Wayland, xclip on X11,
|
|
57
|
+
* osascript on macOS. Null when there is no image or no way to ask — the
|
|
58
|
+
* caller then forwards the keystroke as it was.
|
|
59
|
+
*/
|
|
60
|
+
export const readClipboardImage = async (platform = process.platform, env = process.env) => {
|
|
61
|
+
if (platform === "darwin")
|
|
62
|
+
return fromMac();
|
|
63
|
+
if (platform !== "linux" && platform !== "freebsd" && platform !== "openbsd")
|
|
64
|
+
return null;
|
|
65
|
+
if (env["WAYLAND_DISPLAY"] !== undefined && env["WAYLAND_DISPLAY"] !== "") {
|
|
66
|
+
const image = await fromWayland();
|
|
67
|
+
if (image !== null)
|
|
68
|
+
return image;
|
|
69
|
+
}
|
|
70
|
+
if (env["DISPLAY"] !== undefined && env["DISPLAY"] !== "")
|
|
71
|
+
return fromX11();
|
|
72
|
+
return null;
|
|
73
|
+
};
|
package/dist/dashboard-model.js
CHANGED
|
@@ -160,15 +160,10 @@ export const pseudoGroup = (session, item) => ({
|
|
|
160
160
|
live: LIVE_STATUSES.has(session.status) ? 1 : 0,
|
|
161
161
|
annotation: item(session).annotation,
|
|
162
162
|
});
|
|
163
|
-
export const deriveRows = (groups) => groups.flatMap((group) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return [
|
|
168
|
-
{ kind: "worktree", group },
|
|
169
|
-
...group.sessions.map((item) => ({ kind: "session", group, item })),
|
|
170
|
-
];
|
|
171
|
-
});
|
|
163
|
+
export const deriveRows = (groups) => groups.flatMap((group) => [
|
|
164
|
+
{ kind: "worktree", group },
|
|
165
|
+
...group.sessions.map((item) => ({ kind: "session", group, item })),
|
|
166
|
+
]);
|
|
172
167
|
export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
|
|
173
168
|
/** The picker's rows: resume offers the same harness first, then the crossings. */
|
|
174
169
|
export const deriveHarnesses = (resuming) => {
|
package/dist/dashboard.js
CHANGED
|
@@ -2,8 +2,8 @@ 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";
|
|
6
|
-
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus,
|
|
5
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
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";
|
|
9
9
|
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
@@ -58,26 +58,15 @@ const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { c
|
|
|
58
58
|
: process.kind === "agent-external"
|
|
59
59
|
? " · external"
|
|
60
60
|
: "", ` ${process.status}`] })] }) }, process.id))), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
|
|
61
|
-
/**
|
|
62
|
-
* A lone-session worktree, combined into one row (today's density): the
|
|
63
|
-
* worktree name leads, everything live inside hangs underneath as facts.
|
|
64
|
-
*/
|
|
65
|
-
const CombinedRow = ({ group, item, selected, }) => {
|
|
66
|
-
const { session, annotation, services, processes } = item;
|
|
67
|
-
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
68
|
-
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
69
|
-
const trailingLabel = labelIsIdentity(session) ? "" : (session.label ?? "");
|
|
70
|
-
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: FAINT, children: trailingLabel.slice(0, 40) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
|
|
71
|
-
};
|
|
72
|
-
/** A shared worktree's header: the place, its folded status, its member facts. */
|
|
61
|
+
/** A worktree's header: the place, its folded status, its member facts. */
|
|
73
62
|
const WorktreeHeaderRow = ({ group, selected, }) => {
|
|
74
63
|
const folded = foldGroupStatus(group);
|
|
75
64
|
const color = group.live > 0 ? (STATUS_COLOR[folded] ?? MUTED) : FAINT;
|
|
76
65
|
const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(" ago", "");
|
|
77
66
|
const open = group.annotation?.openComments ?? 0;
|
|
78
|
-
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: `${group.sessions.length} sessions` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
|
|
67
|
+
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: `${group.sessions.length} session${group.sessions.length === 1 ? "" : "s"}` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
|
|
79
68
|
};
|
|
80
|
-
/** One conversation inside a
|
|
69
|
+
/** One conversation inside a worktree — the label is its identity. */
|
|
81
70
|
const SessionChildRow = ({ item, selected, }) => {
|
|
82
71
|
const { session, services, processes } = item;
|
|
83
72
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
@@ -135,6 +124,8 @@ const keepSpanVisible = (scroll, top, height) => {
|
|
|
135
124
|
};
|
|
136
125
|
const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
|
|
137
126
|
const PROJECTS_PANE_WIDTH = 30;
|
|
127
|
+
/** The header's share fact when no share runs. */
|
|
128
|
+
const noShare = () => "off";
|
|
138
129
|
const App = ({ ctx, onQuit }) => {
|
|
139
130
|
const renderer = useRenderer();
|
|
140
131
|
const queryClient = useQueryClient();
|
|
@@ -154,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
154
145
|
const [busy, setBusy] = useState(null);
|
|
155
146
|
const [busyStarted, setBusyStarted] = useState(0);
|
|
156
147
|
const [status, setStatus] = useState(null);
|
|
148
|
+
const shareState = useSyncExternalStore((onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => { }), ctx.agentShare === null ? noShare : ctx.agentShare.snapshot);
|
|
157
149
|
const [editing, setEditing] = useState(null);
|
|
158
150
|
/** Session id a stop is armed against; the second press fires it. */
|
|
159
151
|
const [stopArmed, setStopArmed] = useState(null);
|
|
@@ -208,6 +200,23 @@ const App = ({ ctx, onQuit }) => {
|
|
|
208
200
|
const selectSession = (id) => setSessionKey(`s:${id}`);
|
|
209
201
|
const say = (text) => setStatus({ text, at: Date.now() });
|
|
210
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]);
|
|
211
220
|
// A repo underfoot that the store has never met: raise the adopt offer the
|
|
212
221
|
// first time the workbench answers and no project matches the cwd.
|
|
213
222
|
useEffect(() => {
|
|
@@ -1062,7 +1071,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
1062
1071
|
refetch();
|
|
1063
1072
|
}, onQuit: onQuit }));
|
|
1064
1073
|
}
|
|
1065
|
-
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))) :
|
|
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) => {
|
|
1066
1075
|
if (typeof value === "string")
|
|
1067
1076
|
submitRename(editing, value);
|
|
1068
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.
|
|
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: "
|
|
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" },
|
|
@@ -87,6 +87,7 @@ export const COMMANDS = [
|
|
|
87
87
|
"mend codex, mend claude, and mend opencode are the same command with a different harness. The session runs in a workspace on the platform, in its own git worktree, and everything it does is recorded. This terminal attaches to it.",
|
|
88
88
|
"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.",
|
|
89
89
|
"Detach with Ctrl+] and the session keeps running. Reattach from any terminal with mend attach, or from the phone.",
|
|
90
|
+
"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.",
|
|
90
91
|
],
|
|
91
92
|
options: [
|
|
92
93
|
{ flag: "--name <worktree>", text: "name the worktree; an existing name joins it" },
|
|
@@ -156,6 +157,7 @@ export const COMMANDS = [
|
|
|
156
157
|
description: [
|
|
157
158
|
sessionArg,
|
|
158
159
|
"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.",
|
|
160
|
+
"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.",
|
|
159
161
|
],
|
|
160
162
|
see: ["stop", "rejoin", "shell"],
|
|
161
163
|
},
|
|
@@ -469,21 +471,33 @@ export const COMMANDS = [
|
|
|
469
471
|
{
|
|
470
472
|
name: "keys init",
|
|
471
473
|
section: "project setup",
|
|
472
|
-
summary: "
|
|
474
|
+
summary: "create your Mend key (ed25519) on the server",
|
|
473
475
|
synopsis: [],
|
|
474
476
|
description: [
|
|
475
|
-
"
|
|
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.",
|
|
476
478
|
],
|
|
477
|
-
see: ["keys show", "adopt"],
|
|
479
|
+
see: ["keys show", "keys mode", "adopt"],
|
|
478
480
|
},
|
|
479
481
|
{
|
|
480
482
|
name: "keys show",
|
|
481
483
|
section: "project setup",
|
|
482
|
-
summary: "print
|
|
484
|
+
summary: "print your Mend public key",
|
|
483
485
|
synopsis: [],
|
|
484
|
-
description: [
|
|
486
|
+
description: [
|
|
487
|
+
"The key to add to your git account's SSH keys, or as a deploy key on one repository.",
|
|
488
|
+
],
|
|
485
489
|
see: ["keys init"],
|
|
486
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
|
+
},
|
|
487
501
|
{
|
|
488
502
|
name: "keys share",
|
|
489
503
|
section: "project setup",
|
|
@@ -491,8 +505,9 @@ export const COMMANDS = [
|
|
|
491
505
|
synopsis: [],
|
|
492
506
|
description: [
|
|
493
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.",
|
|
494
509
|
],
|
|
495
|
-
see: ["adopt"],
|
|
510
|
+
see: ["keys mode", "adopt"],
|
|
496
511
|
},
|
|
497
512
|
// ── this machine ───────────────────────────────────────────────────────
|
|
498
513
|
{
|