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