@sealant/mend 0.19.0 → 0.20.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/clipboard.js +73 -0
  2. package/dist/dashboard-model.js +4 -9
  3. package/dist/dashboard.js +5 -16
  4. package/dist/help.js +2 -0
  5. package/dist/main.js +33 -2
  6. package/dist/shared.js +26 -0
  7. package/man/mend-accounts.1 +1 -1
  8. package/man/mend-adopt.1 +1 -1
  9. package/man/mend-attach.1 +3 -1
  10. package/man/mend-codex.1 +3 -1
  11. package/man/mend-completions.1 +1 -1
  12. package/man/mend-connect.1 +1 -1
  13. package/man/mend-continue.1 +1 -1
  14. package/man/mend-doctor.1 +1 -1
  15. package/man/mend-dotfiles-sync.1 +1 -1
  16. package/man/mend-dotfiles.1 +1 -1
  17. package/man/mend-env-cluster.1 +1 -1
  18. package/man/mend-env-load.1 +1 -1
  19. package/man/mend-env-show.1 +1 -1
  20. package/man/mend-help.1 +1 -1
  21. package/man/mend-keys-init.1 +1 -1
  22. package/man/mend-keys-share.1 +1 -1
  23. package/man/mend-keys-show.1 +1 -1
  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 +1 -1
  52. package/package.json +1 -1
@@ -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
+ };
@@ -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
- const only = group.sessions.length === 1 ? group.sessions[0] : undefined;
165
- if (only !== undefined)
166
- return [{ kind: "session", group, item: only }];
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
@@ -3,7 +3,7 @@ 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
5
  import { useEffect, useRef, useState } from "react";
6
- import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
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 shared worktree — the label is its identity. */
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;
@@ -1062,7 +1051,7 @@ const App = ({ ctx, onQuit }) => {
1062
1051
  refetch();
1063
1052
  }, onQuit: onQuit }));
1064
1053
  }
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))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, 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) => {
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) => {
1066
1055
  if (typeof value === "string")
1067
1056
  submitRename(editing, value);
1068
1057
  } }) })) : 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
@@ -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
  },
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 { readClipboardImage } from "./clipboard.js";
8
9
  import { doctorCommand } from "./doctor.js";
9
10
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
10
11
  import { formatLoadReport } from "./env.js";
@@ -12,7 +13,7 @@ import { findCommand, manFileName, renderCommand, renderGroup, renderIndex, rend
12
13
  import { loginCommand } from "./login.js";
13
14
  import { pairCommand, qrCommand } from "./pair.js";
14
15
  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";
16
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, isPasteChunk, LIVE_STATUSES, pasteBytes, trackBracketedPaste, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
16
17
  import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
17
18
  import { sshCommand } from "./ssh-setup.js";
18
19
  import { cliVersion, fetchServerVersion, versionLines } from "./version.js";
@@ -452,9 +453,12 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
452
453
  finishAttachment?.();
453
454
  };
454
455
  const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
456
+ let bracketedPaste = false;
455
457
  const onTtyFrame = (event) => {
456
458
  if (typeof event.data !== "string") {
457
- process.stdout.write(Buffer.from(event.data));
459
+ const bytes = Buffer.from(event.data);
460
+ bracketedPaste = trackBracketedPaste(bytes, bracketedPaste);
461
+ process.stdout.write(bytes);
458
462
  return;
459
463
  }
460
464
  try {
@@ -484,9 +488,36 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
484
488
  }
485
489
  if (ws.readyState !== WebSocket.OPEN)
486
490
  return;
491
+ if (isPasteChunk(data)) {
492
+ // Ctrl+V — the agent's own clipboard is empty (the workspace has no
493
+ // display), so an image on THIS machine's clipboard goes up to Mend and
494
+ // its workspace path is pasted instead; codex and claude read the path.
495
+ // No image, or no way to ask: the keystroke goes through untouched.
496
+ void pasteClipboardImage(data);
497
+ return;
498
+ }
487
499
  // Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
488
500
  ws.send(new Uint8Array(data).buffer);
489
501
  };
502
+ const forward = (bytes) => {
503
+ if (ws.readyState === WebSocket.OPEN)
504
+ ws.send(new Uint8Array(bytes).buffer);
505
+ };
506
+ const pasteClipboardImage = async (keystroke) => {
507
+ const image = await readClipboardImage();
508
+ if (image === null) {
509
+ forward(keystroke);
510
+ return;
511
+ }
512
+ try {
513
+ const stored = await request(config, "POST", `/sessions/${sessionId}/images`, { contentsBase64: image.bytes.toString("base64") });
514
+ forward(pasteBytes(stored.path, bracketedPaste));
515
+ }
516
+ catch {
517
+ // The TUI owns the screen; a bell is the only honest signal left.
518
+ process.stdout.write("\x07");
519
+ }
520
+ };
490
521
  try {
491
522
  sendResize();
492
523
  process.stdout.on("resize", onWinch);
package/dist/shared.js CHANGED
@@ -293,3 +293,29 @@ export const normalizeProjectName = (raw) => {
293
293
  .replace(/-+$/, "");
294
294
  return slug === "" ? "project" : slug.slice(0, 64);
295
295
  };
296
+ /**
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.
300
+ */
301
+ export const isPasteChunk = (data) => {
302
+ if (data.length === 1 && data[0] === 0x16)
303
+ return true;
304
+ const text = data.toString("latin1");
305
+ return text === "\u001b[118;5u" || text === "\u001b[118;5:1u" || text === "\u001b[118;5:2u";
306
+ };
307
+ /**
308
+ * Follow the remote app's bracketed-paste mode through the bytes it writes:
309
+ * the last `ESC [ ? 2004 h|l` in a chunk wins; a chunk with neither keeps the
310
+ * current state. A pasted image path is wrapped only when the app asked.
311
+ */
312
+ export const trackBracketedPaste = (output, current) => {
313
+ const text = output.toString("latin1");
314
+ const on = text.lastIndexOf("\u001b[?2004h");
315
+ const off = text.lastIndexOf("\u001b[?2004l");
316
+ if (on === -1 && off === -1)
317
+ return current;
318
+ return on > off;
319
+ };
320
+ /** The bytes that paste `text` into the remote app, bracketed when it asked. */
321
+ 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.20.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.20.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-adopt \- adopt a repository into the store
4
4
  .SH SYNOPSIS
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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-help \- this index, or one command's page
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-KEYS-INIT 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-KEYS-INIT 1 "" "mend 0.20.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-keys\-init \- generate this machine's Mend deploy key (ed25519)
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-KEYS-SHARE 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-KEYS-SHARE 1 "" "mend 0.20.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
@@ -1,4 +1,4 @@
1
- .TH MEND-KEYS-SHOW 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-KEYS-SHOW 1 "" "mend 0.20.0" "mend manual"
2
2
  .SH NAME
3
3
  mend\-keys\-show \- print the public key
4
4
  .SH SYNOPSIS
package/man/mend-login.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-LOGIN 1 "" "mend 0.19.0" "mend manual"
1
+ .TH MEND-LOGIN 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-LOGOUT 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-MAN 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-PAIR 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-PROJECTS 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-REFRESH 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-REJOIN 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-RESUME 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-RUN 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-ADD 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-CONNECT 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-INIT 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-LIST 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-LOGS 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-RESTART 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-RUN 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SERVICE-STOP 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SESSIONS 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SHELL 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SKILLS-PUSH 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SKILLS 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SSH-SETUP 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-SSH 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-STOP 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-UI 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-VERSION 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND-WORKTREES 1 "" "mend 0.20.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.19.0" "mend manual"
1
+ .TH MEND 1 "" "mend 0.20.0" "mend manual"
2
2
  .SH NAME
3
3
  mend \- the agent workbench
4
4
  .SH SYNOPSIS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",