@sealant/mend 0.22.0 → 0.24.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/README.md +133 -6
- package/dist/chunk-4MX7ZC34.js +27894 -0
- package/dist/dashboard-YEU7SDYB.js +2951 -0
- package/dist/main.js +6917 -2580
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +4 -4
- package/man/mend-attach.1 +1 -1
- package/man/mend-codex.1 +1 -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 +1 -1
- package/man/mend-keys-mode.1 +1 -1
- package/man/mend-keys-share.1 +1 -1
- package/man/mend-keys-show.1 +1 -1
- 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-server-logs.1 +15 -0
- package/man/mend-server-restart.1 +15 -0
- package/man/mend-server-setup.1 +64 -0
- package/man/mend-server-start.1 +15 -0
- package/man/mend-server-status.1 +11 -0
- package/man/mend-server-stop.1 +11 -0
- package/man/mend-server-upgrade.1 +30 -0
- package/man/mend-server.1 +11 -0
- 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 +6 -3
- 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 +26 -2
- package/package.json +7 -3
- package/dist/agent-share.js +0 -150
- package/dist/clipboard.js +0 -73
- package/dist/dashboard-model.js +0 -308
- package/dist/dashboard.js +0 -1200
- package/dist/doctor.js +0 -233
- package/dist/dotfiles.js +0 -90
- package/dist/env.js +0 -17
- package/dist/help.js +0 -839
- package/dist/login.js +0 -240
- package/dist/pair.js +0 -83
- package/dist/review-model.js +0 -212
- package/dist/review-workflow.js +0 -8
- package/dist/review.js +0 -690
- package/dist/service-init.js +0 -227
- package/dist/shared.js +0 -338
- package/dist/skills.js +0 -134
- package/dist/ssh-setup.js +0 -181
- package/dist/terminal.js +0 -8
- package/dist/tui-theme.js +0 -41
- package/dist/version.js +0 -48
- package/dist/workbench-events.js +0 -72
package/dist/agent-share.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import * as net from "node:net";
|
|
2
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
-
/** The bridge endpoint for a server url: ws(s), this machine named, the token as a query. */
|
|
4
|
-
export const bridgeUrlOf = (serverUrl, token, host) => {
|
|
5
|
-
const url = new URL(`${serverUrl}/api/keys/bridge/ws`);
|
|
6
|
-
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
7
|
-
url.searchParams.set("host", host);
|
|
8
|
-
if (token !== null)
|
|
9
|
-
url.searchParams.set("token", token);
|
|
10
|
-
return url;
|
|
11
|
-
};
|
|
12
|
-
/** Reconnect backoff: 1s, 2s, … capped at 30s. */
|
|
13
|
-
export const retryDelayMs = (attempt) => Math.min(30_000, 1000 * 2 ** Math.min(Math.max(attempt - 1, 0), 5));
|
|
14
|
-
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
15
|
-
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
16
|
-
/**
|
|
17
|
-
* One complete agent exchange against the local agent: fresh connection,
|
|
18
|
-
* write the framed request verbatim, read one framed response. A hardware
|
|
19
|
-
* key blocks here until the touch — hence the generous timeout.
|
|
20
|
-
*/
|
|
21
|
-
const askLocalAgent = (agentSock, payload) => new Promise((resolve, reject) => {
|
|
22
|
-
const connection = net.connect(agentSock, () => {
|
|
23
|
-
connection.write(payload);
|
|
24
|
-
});
|
|
25
|
-
const timer = setTimeout(() => {
|
|
26
|
-
connection.destroy();
|
|
27
|
-
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
28
|
-
}, 60_000);
|
|
29
|
-
let pending = Buffer.alloc(0);
|
|
30
|
-
connection.on("data", (chunk) => {
|
|
31
|
-
pending = Buffer.concat([pending, chunk]);
|
|
32
|
-
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
33
|
-
clearTimeout(timer);
|
|
34
|
-
connection.end();
|
|
35
|
-
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
connection.on("error", (error) => {
|
|
39
|
-
clearTimeout(timer);
|
|
40
|
-
reject(error);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
/** An abortable wait; an abort simply ends it early. */
|
|
44
|
-
const sleep = (ms, signal) => delay(ms, undefined, { signal }).then(() => undefined, () => undefined);
|
|
45
|
-
/** Share until the signal aborts; every connect, drop, and signature is an event. */
|
|
46
|
-
export const shareAgent = async (options) => {
|
|
47
|
-
const { url, agentSock, signal, onEvent } = options;
|
|
48
|
-
// Requests are answered one at a time: the agent protocol has one in flight.
|
|
49
|
-
let chain = Promise.resolve();
|
|
50
|
-
let attempt = 0;
|
|
51
|
-
while (!signal.aborted) {
|
|
52
|
-
const ws = new WebSocket(url);
|
|
53
|
-
const onAbort = () => ws.close();
|
|
54
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
55
|
-
const closed = new Promise((resolve) => {
|
|
56
|
-
ws.addEventListener("close", () => resolve(), { once: true });
|
|
57
|
-
});
|
|
58
|
-
const opened = await new Promise((resolve) => {
|
|
59
|
-
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
60
|
-
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
61
|
-
});
|
|
62
|
-
if (opened) {
|
|
63
|
-
attempt = 0;
|
|
64
|
-
onEvent({ kind: "connected" });
|
|
65
|
-
ws.addEventListener("message", (event) => {
|
|
66
|
-
const text = typeof event.data === "string"
|
|
67
|
-
? event.data
|
|
68
|
-
: Buffer.from(event.data).toString("utf8");
|
|
69
|
-
let frame;
|
|
70
|
-
try {
|
|
71
|
-
frame = JSON.parse(text);
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
if (frame.t !== "req" ||
|
|
77
|
-
typeof frame.id !== "number" ||
|
|
78
|
-
typeof frame.payload !== "string") {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const id = frame.id;
|
|
82
|
-
const payload = Buffer.from(frame.payload, "base64");
|
|
83
|
-
const type = payload[4];
|
|
84
|
-
const context = typeof frame.context === "string" ? frame.context : "mend";
|
|
85
|
-
chain = chain.then(async () => {
|
|
86
|
-
const started = Date.now();
|
|
87
|
-
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
88
|
-
onEvent({ kind: "sign-requested", context });
|
|
89
|
-
else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
90
|
-
onEvent({ kind: "identities-requested", context });
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const response = await askLocalAgent(agentSock, payload);
|
|
94
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
95
|
-
return null;
|
|
96
|
-
ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
|
|
97
|
-
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
98
|
-
onEvent({ kind: "signed", context, seconds: (Date.now() - started) / 1000 });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
104
|
-
ws.send(JSON.stringify({ t: "err", id, message }));
|
|
105
|
-
}
|
|
106
|
-
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
107
|
-
onEvent({ kind: "not-signed", context, message });
|
|
108
|
-
}
|
|
109
|
-
return null;
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
await closed;
|
|
114
|
-
signal.removeEventListener("abort", onAbort);
|
|
115
|
-
if (signal.aborted)
|
|
116
|
-
return;
|
|
117
|
-
attempt += 1;
|
|
118
|
-
const retryMs = retryDelayMs(attempt);
|
|
119
|
-
onEvent({ kind: "disconnected", retryMs });
|
|
120
|
-
await sleep(retryMs, signal);
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
/** Start sharing in the background; `stop` ends it. */
|
|
124
|
-
export const startAgentShare = (options) => {
|
|
125
|
-
const controller = new AbortController();
|
|
126
|
-
const listeners = new Set();
|
|
127
|
-
let state = "connecting";
|
|
128
|
-
void shareAgent({
|
|
129
|
-
...options,
|
|
130
|
-
signal: controller.signal,
|
|
131
|
-
onEvent: (event) => {
|
|
132
|
-
if (event.kind === "connected")
|
|
133
|
-
state = "connected";
|
|
134
|
-
else if (event.kind === "disconnected")
|
|
135
|
-
state = "connecting";
|
|
136
|
-
for (const listener of listeners)
|
|
137
|
-
listener(event);
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
return {
|
|
141
|
-
snapshot: () => state,
|
|
142
|
-
subscribe: (listener) => {
|
|
143
|
-
listeners.add(listener);
|
|
144
|
-
return () => {
|
|
145
|
-
listeners.delete(listener);
|
|
146
|
-
};
|
|
147
|
-
},
|
|
148
|
-
stop: () => controller.abort(),
|
|
149
|
-
};
|
|
150
|
-
};
|
package/dist/clipboard.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
import { HARNESS_COMMANDS, LIVE_STATUSES } from "./shared.js";
|
|
2
|
-
/**
|
|
3
|
-
* The worktree IS the session's identity (one worktree per session): its
|
|
4
|
-
* branch, with the noisy default prefix receded. What you are working on
|
|
5
|
-
* leads every row; the harness is a fact about it, not its name.
|
|
6
|
-
*/
|
|
7
|
-
export const worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
|
|
8
|
-
/**
|
|
9
|
-
* What to call an UNNAMED worktree: its branch is `mend/session/<uuid>` —
|
|
10
|
-
* noise nobody recognizes — so the auto-name label stands in, and before one
|
|
11
|
-
* lands, the short session id. A named worktree is always its own name.
|
|
12
|
-
*/
|
|
13
|
-
export const worktreeDisplayName = (session) => {
|
|
14
|
-
const name = worktreeName(session.branch);
|
|
15
|
-
if (!name.startsWith("session/"))
|
|
16
|
-
return name;
|
|
17
|
-
return session.label ?? `session ${session.id.slice(0, 8)}`;
|
|
18
|
-
};
|
|
19
|
-
/** True when the label already IS the row's identity — don't repeat it. */
|
|
20
|
-
export const labelIsIdentity = (session) => worktreeName(session.branch).startsWith("session/") && session.label !== null;
|
|
21
|
-
export const WORKBENCH_KEY = ["workbench"];
|
|
22
|
-
export const fetchWorkbench = async (ctx) => {
|
|
23
|
-
const projects = await ctx.api("GET", "/projects");
|
|
24
|
-
const [fetched, services] = await Promise.all([
|
|
25
|
-
Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
|
|
26
|
-
ctx.api("GET", "/services"),
|
|
27
|
-
]);
|
|
28
|
-
const servicesBySession = new Map();
|
|
29
|
-
for (const service of services) {
|
|
30
|
-
const bucket = servicesBySession.get(service.sessionId) ?? [];
|
|
31
|
-
bucket.push(service);
|
|
32
|
-
servicesBySession.set(service.sessionId, bucket);
|
|
33
|
-
}
|
|
34
|
-
// What lives in each LIVE worktree: the agent and shells come from the
|
|
35
|
-
// session detail (settled sessions have nothing live — no fetch for them).
|
|
36
|
-
const liveSessions = fetched.flatMap((detail) => detail.sessions.filter((session) => LIVE_STATUSES.has(session.status)));
|
|
37
|
-
const detailed = await Promise.all(liveSessions.map(async (session) => {
|
|
38
|
-
try {
|
|
39
|
-
return await ctx.api("GET", `/sessions/${session.id}`);
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}));
|
|
45
|
-
const processesBySession = new Map();
|
|
46
|
-
for (const detail of detailed) {
|
|
47
|
-
if (detail === null)
|
|
48
|
-
continue;
|
|
49
|
-
processesBySession.set(detail.session.id, detail.processes.filter((process) => process.exitedAt === null && process.kind !== "service"));
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
projects,
|
|
53
|
-
details: new Map(fetched.map((detail) => [detail.project.id, detail])),
|
|
54
|
-
servicesBySession,
|
|
55
|
-
processesBySession,
|
|
56
|
-
};
|
|
57
|
-
};
|
|
58
|
-
// ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
|
|
59
|
-
/** Apply `f` to every session row in every project detail. */
|
|
60
|
-
export const mapWorkbenchSessions = (data, f) => ({
|
|
61
|
-
...data,
|
|
62
|
-
details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
|
|
63
|
-
});
|
|
64
|
-
export const mapProjectSessions = (data, projectId, f) => {
|
|
65
|
-
const detail = data.details.get(projectId);
|
|
66
|
-
if (detail === undefined)
|
|
67
|
-
return data;
|
|
68
|
-
const details = new Map(data.details);
|
|
69
|
-
details.set(projectId, { ...detail, sessions: f(detail.sessions) });
|
|
70
|
-
return { ...data, details };
|
|
71
|
-
};
|
|
72
|
-
export const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
|
|
73
|
-
export const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
|
|
74
|
-
export const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
|
|
75
|
-
export const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
|
|
76
|
-
const detail = data?.details.get(project.id);
|
|
77
|
-
const sessions = detail?.sessions ?? [];
|
|
78
|
-
const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
79
|
-
const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
|
|
80
|
-
return { project, total: sessions.length, live, open };
|
|
81
|
-
});
|
|
82
|
-
export const bySessionRecency = (a, b) => {
|
|
83
|
-
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
84
|
-
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
85
|
-
if (aLive !== bLive)
|
|
86
|
-
return bLive - aLive;
|
|
87
|
-
return b.createdAt.localeCompare(a.createdAt);
|
|
88
|
-
};
|
|
89
|
-
export const toSessionItem = (data, detail) => (session) => ({
|
|
90
|
-
session,
|
|
91
|
-
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
92
|
-
services: data.servicesBySession.get(session.id) ?? [],
|
|
93
|
-
processes: data.processesBySession.get(session.id) ?? [],
|
|
94
|
-
});
|
|
95
|
-
/**
|
|
96
|
-
* A settled session that never had a conversation — no transcript captured, none in the harness
|
|
97
|
-
* home — cannot be resumed or handed off. The dashboard hides it; `mend sessions --all` still
|
|
98
|
-
* lists it, and removing the worktree takes it along.
|
|
99
|
-
*/
|
|
100
|
-
export const isDeadEnd = (session) => !LIVE_STATUSES.has(session.status) && session.hasTranscript === false;
|
|
101
|
-
export const deriveWorktrees = (data, projectId) => {
|
|
102
|
-
if (data === undefined || projectId === null)
|
|
103
|
-
return [];
|
|
104
|
-
const detail = data.details.get(projectId);
|
|
105
|
-
if (detail === undefined)
|
|
106
|
-
return [];
|
|
107
|
-
const item = toSessionItem(data, detail);
|
|
108
|
-
const sorted = detail.sessions
|
|
109
|
-
.filter((session) => !isDeadEnd(session))
|
|
110
|
-
.toSorted(bySessionRecency);
|
|
111
|
-
const groups = [];
|
|
112
|
-
if (detail.worktrees !== undefined) {
|
|
113
|
-
const claimed = new Set();
|
|
114
|
-
for (const worktree of detail.worktrees) {
|
|
115
|
-
const members = sorted.filter((session) => session.worktreeId === worktree.id);
|
|
116
|
-
for (const member of members)
|
|
117
|
-
claimed.add(member.id);
|
|
118
|
-
groups.push({
|
|
119
|
-
key: worktree.id,
|
|
120
|
-
id: worktree.id,
|
|
121
|
-
name: worktree.name.startsWith("wt-")
|
|
122
|
-
? (members.find((m) => m.label !== null)?.label ??
|
|
123
|
-
(members[0] === undefined ? worktree.name : `session ${members[0].id.slice(0, 8)}`))
|
|
124
|
-
: worktree.name,
|
|
125
|
-
branch: worktree.branch,
|
|
126
|
-
baseRef: worktree.baseRef,
|
|
127
|
-
createdAt: worktree.createdAt,
|
|
128
|
-
sessions: members.map(item),
|
|
129
|
-
live: members.filter((session) => LIVE_STATUSES.has(session.status)).length,
|
|
130
|
-
annotation: detail.annotations.find((a) => members.some((m) => m.id === a.sessionId)),
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
// Optimistic pending rows have no worktree yet — each is its own group.
|
|
134
|
-
for (const session of sorted) {
|
|
135
|
-
if (claimed.has(session.id))
|
|
136
|
-
continue;
|
|
137
|
-
groups.push(pseudoGroup(session, item));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
for (const session of sorted)
|
|
142
|
-
groups.push(pseudoGroup(session, item));
|
|
143
|
-
}
|
|
144
|
-
return groups.toSorted((a, b) => {
|
|
145
|
-
if (a.live > 0 !== b.live > 0)
|
|
146
|
-
return a.live > 0 ? -1 : 1;
|
|
147
|
-
const aNewest = a.sessions[0]?.session.createdAt ?? a.createdAt;
|
|
148
|
-
const bNewest = b.sessions[0]?.session.createdAt ?? b.createdAt;
|
|
149
|
-
return bNewest.localeCompare(aNewest);
|
|
150
|
-
});
|
|
151
|
-
};
|
|
152
|
-
export const pseudoGroup = (session, item) => ({
|
|
153
|
-
key: session.id,
|
|
154
|
-
id: null,
|
|
155
|
-
name: worktreeDisplayName(session),
|
|
156
|
-
branch: session.branch,
|
|
157
|
-
baseRef: session.baseRef,
|
|
158
|
-
createdAt: session.createdAt,
|
|
159
|
-
sessions: [item(session)],
|
|
160
|
-
live: LIVE_STATUSES.has(session.status) ? 1 : 0,
|
|
161
|
-
annotation: item(session).annotation,
|
|
162
|
-
});
|
|
163
|
-
export const deriveRows = (groups) => groups.flatMap((group) => [
|
|
164
|
-
{ kind: "worktree", group },
|
|
165
|
-
...group.sessions.map((item) => ({ kind: "session", group, item })),
|
|
166
|
-
]);
|
|
167
|
-
export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
|
|
168
|
-
/** The picker's rows: resume offers the same harness first, then the crossings. */
|
|
169
|
-
export const deriveHarnesses = (resuming) => {
|
|
170
|
-
if (resuming !== null) {
|
|
171
|
-
const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
|
|
172
|
-
return [
|
|
173
|
-
{
|
|
174
|
-
harness: null,
|
|
175
|
-
label: resuming.harness,
|
|
176
|
-
hint: "same harness — native resume, conversation intact",
|
|
177
|
-
},
|
|
178
|
-
...others.map((harness) => ({
|
|
179
|
-
harness,
|
|
180
|
-
label: harness,
|
|
181
|
-
hint: harness === "shell"
|
|
182
|
-
? "a bash in the worktree — resume either agent from inside"
|
|
183
|
-
: "the conversation crosses as a distilled prompt",
|
|
184
|
-
})),
|
|
185
|
-
];
|
|
186
|
-
}
|
|
187
|
-
return Object.keys(HARNESS_COMMANDS).map((harness) => ({
|
|
188
|
-
harness,
|
|
189
|
-
label: harness,
|
|
190
|
-
hint: harness === "shell"
|
|
191
|
-
? "a plain bash session — new worktree, recorded"
|
|
192
|
-
: `mend ${harness} — new worktree, recorded session`,
|
|
193
|
-
}));
|
|
194
|
-
};
|
|
195
|
-
/** The worktree's folded status word: waiting wins, then running, then idle. */
|
|
196
|
-
export const foldGroupStatus = (group) => {
|
|
197
|
-
const statuses = group.sessions.map((item) => item.session.status);
|
|
198
|
-
if (statuses.includes("waiting"))
|
|
199
|
-
return "waiting";
|
|
200
|
-
if (statuses.includes("running") || statuses.includes("starting"))
|
|
201
|
-
return "running";
|
|
202
|
-
if (statuses.includes("idle"))
|
|
203
|
-
return "idle";
|
|
204
|
-
return statuses[0] ?? "idle";
|
|
205
|
-
};
|
|
206
|
-
// ─── attach + verb helpers ──────────────────────────────────────────────────
|
|
207
|
-
/**
|
|
208
|
-
* Where "get me in" should land when the session has no live agent terminal:
|
|
209
|
-
* the newest LIVE shell, so repeated attaches rejoin the same one instead of
|
|
210
|
-
* stacking a fresh bash per attempt (the five-orphan-shells failure mode).
|
|
211
|
-
* Null = nothing attachable; opening a new shell is then honest.
|
|
212
|
-
*/
|
|
213
|
-
export const liveShellOf = (processes) => processes.findLast((process) => process.kind === "shell" && process.exitedAt === null) ?? null;
|
|
214
|
-
/**
|
|
215
|
-
* The session's live protocol agent — a phone pickup driving the same
|
|
216
|
-
* conversation over stream-json. It holds the workspace with no PTY behind it,
|
|
217
|
-
* so a terminal "get me in" must take it over (hand back to a TUI) rather than
|
|
218
|
-
* report the attach unavailable or open a bare shell. Null = no pickup to take.
|
|
219
|
-
*/
|
|
220
|
-
export const liveProtocolOf = (processes) => processes.find((process) => process.kind === "agent-protocol" && process.exitedAt === null) ??
|
|
221
|
-
null;
|
|
222
|
-
/**
|
|
223
|
-
* The optimistic stop: the row settles AND its live process/service fact
|
|
224
|
-
* lines drop in the same paint — the server's refetch only confirms.
|
|
225
|
-
*/
|
|
226
|
-
export const markSessionStopped = (data, sessionId) => {
|
|
227
|
-
const processesBySession = new Map(data.processesBySession);
|
|
228
|
-
processesBySession.delete(sessionId);
|
|
229
|
-
const servicesBySession = new Map(data.servicesBySession);
|
|
230
|
-
servicesBySession.delete(sessionId);
|
|
231
|
-
return {
|
|
232
|
-
...mapWorkbenchSessions(data, (session) => session.id === sessionId ? { ...session, status: "stopped" } : session),
|
|
233
|
-
processesBySession,
|
|
234
|
-
servicesBySession,
|
|
235
|
-
};
|
|
236
|
-
};
|
|
237
|
-
/** The optimistic removal: the whole group leaves the list before the server answers. */
|
|
238
|
-
export const removeWorktreeGroup = (data, projectId, group) => {
|
|
239
|
-
const memberIds = new Set(group.sessions.map((item) => item.session.id));
|
|
240
|
-
const detail = data.details.get(projectId);
|
|
241
|
-
if (detail === undefined)
|
|
242
|
-
return data;
|
|
243
|
-
const details = new Map(data.details);
|
|
244
|
-
details.set(projectId, {
|
|
245
|
-
...detail,
|
|
246
|
-
sessions: detail.sessions.filter((session) => !memberIds.has(session.id)),
|
|
247
|
-
...(detail.worktrees === undefined
|
|
248
|
-
? {}
|
|
249
|
-
: { worktrees: detail.worktrees.filter((worktree) => worktree.id !== group.id) }),
|
|
250
|
-
});
|
|
251
|
-
return { ...data, details };
|
|
252
|
-
};
|
|
253
|
-
/**
|
|
254
|
-
* Subsequence fuzzy score, fzf-flavored: every query character must appear in
|
|
255
|
-
* order; consecutive hits and segment starts (after `/`, `-`, `_`, `.`) score
|
|
256
|
-
* higher; earlier matches beat later ones. Null = no match. Case-insensitive.
|
|
257
|
-
*/
|
|
258
|
-
export const fuzzyScore = (query, candidate) => {
|
|
259
|
-
if (query === "")
|
|
260
|
-
return 0;
|
|
261
|
-
const q = query.toLowerCase();
|
|
262
|
-
const c = candidate.toLowerCase();
|
|
263
|
-
let score = 0;
|
|
264
|
-
let last = -1;
|
|
265
|
-
for (const char of q) {
|
|
266
|
-
const at = c.indexOf(char, last + 1);
|
|
267
|
-
if (at === -1)
|
|
268
|
-
return null;
|
|
269
|
-
if (at === last + 1)
|
|
270
|
-
score += 3;
|
|
271
|
-
else if (at === 0 || "/-_.".includes(c[at - 1] ?? ""))
|
|
272
|
-
score += 2;
|
|
273
|
-
else
|
|
274
|
-
score += 1;
|
|
275
|
-
score -= (at - last - 1) * 0.01;
|
|
276
|
-
last = at;
|
|
277
|
-
}
|
|
278
|
-
return score;
|
|
279
|
-
};
|
|
280
|
-
/**
|
|
281
|
-
* The picker's rows for one query: fuzzy-filtered, best score first, ties by
|
|
282
|
-
* most recent commit; an empty query lists everything, default branch on top.
|
|
283
|
-
*/
|
|
284
|
-
export const filterBranches = (branches, query) => {
|
|
285
|
-
const scored = branches.flatMap((branch) => {
|
|
286
|
-
const score = fuzzyScore(query, branch.name);
|
|
287
|
-
return score === null ? [] : [{ branch, score }];
|
|
288
|
-
});
|
|
289
|
-
return scored
|
|
290
|
-
.toSorted((a, b) => {
|
|
291
|
-
if (query === "" && a.branch.isDefault !== b.branch.isDefault) {
|
|
292
|
-
return a.branch.isDefault ? -1 : 1;
|
|
293
|
-
}
|
|
294
|
-
if (a.score !== b.score)
|
|
295
|
-
return b.score - a.score;
|
|
296
|
-
return b.branch.committedAt.localeCompare(a.branch.committedAt);
|
|
297
|
-
})
|
|
298
|
-
.map((entry) => entry.branch);
|
|
299
|
-
};
|
|
300
|
-
/** Commit the base step: the highlighted branch (default branch = null base). */
|
|
301
|
-
export const advanceFromBase = (current) => {
|
|
302
|
-
const chosen = filterBranches(current.branches ?? [], current.query)[current.baseIndex];
|
|
303
|
-
return {
|
|
304
|
-
...current,
|
|
305
|
-
base: chosen === undefined || chosen.isDefault ? null : chosen.name,
|
|
306
|
-
step: "harness",
|
|
307
|
-
};
|
|
308
|
-
};
|