@sealant/mend 0.22.0 → 0.23.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 (80) hide show
  1. package/README.md +133 -6
  2. package/dist/chunk-YPM6TYQR.js +27888 -0
  3. package/dist/dashboard-LR5LHV7O.js +2951 -0
  4. package/dist/main.js +6917 -2580
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +4 -4
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-sync.1 +1 -1
  14. package/man/mend-dotfiles.1 +1 -1
  15. package/man/mend-env-cluster.1 +1 -1
  16. package/man/mend-env-load.1 +1 -1
  17. package/man/mend-env-show.1 +1 -1
  18. package/man/mend-help.1 +1 -1
  19. package/man/mend-keys-init.1 +1 -1
  20. package/man/mend-keys-mode.1 +1 -1
  21. package/man/mend-keys-share.1 +1 -1
  22. package/man/mend-keys-show.1 +1 -1
  23. package/man/mend-login.1 +1 -1
  24. package/man/mend-logout.1 +1 -1
  25. package/man/mend-man.1 +1 -1
  26. package/man/mend-pair.1 +1 -1
  27. package/man/mend-projects.1 +1 -1
  28. package/man/mend-refresh.1 +1 -1
  29. package/man/mend-rejoin.1 +1 -1
  30. package/man/mend-resume.1 +1 -1
  31. package/man/mend-run.1 +1 -1
  32. package/man/mend-server-logs.1 +15 -0
  33. package/man/mend-server-restart.1 +15 -0
  34. package/man/mend-server-setup.1 +64 -0
  35. package/man/mend-server-start.1 +15 -0
  36. package/man/mend-server-status.1 +11 -0
  37. package/man/mend-server-stop.1 +11 -0
  38. package/man/mend-server-upgrade.1 +30 -0
  39. package/man/mend-server.1 +11 -0
  40. package/man/mend-service-add.1 +1 -1
  41. package/man/mend-service-connect.1 +1 -1
  42. package/man/mend-service-init.1 +1 -1
  43. package/man/mend-service-list.1 +1 -1
  44. package/man/mend-service-logs.1 +1 -1
  45. package/man/mend-service-restart.1 +1 -1
  46. package/man/mend-service-run.1 +1 -1
  47. package/man/mend-service-stop.1 +1 -1
  48. package/man/mend-sessions.1 +1 -1
  49. package/man/mend-shell.1 +1 -1
  50. package/man/mend-skills-push.1 +1 -1
  51. package/man/mend-skills.1 +1 -1
  52. package/man/mend-ssh-setup.1 +6 -3
  53. package/man/mend-ssh.1 +1 -1
  54. package/man/mend-stop.1 +1 -1
  55. package/man/mend-ui.1 +1 -1
  56. package/man/mend-version.1 +1 -1
  57. package/man/mend-worktrees.1 +1 -1
  58. package/man/mend.1 +26 -2
  59. package/package.json +6 -2
  60. package/dist/agent-share.js +0 -150
  61. package/dist/clipboard.js +0 -73
  62. package/dist/dashboard-model.js +0 -308
  63. package/dist/dashboard.js +0 -1200
  64. package/dist/doctor.js +0 -233
  65. package/dist/dotfiles.js +0 -90
  66. package/dist/env.js +0 -17
  67. package/dist/help.js +0 -839
  68. package/dist/login.js +0 -240
  69. package/dist/pair.js +0 -83
  70. package/dist/review-model.js +0 -212
  71. package/dist/review-workflow.js +0 -8
  72. package/dist/review.js +0 -690
  73. package/dist/service-init.js +0 -227
  74. package/dist/shared.js +0 -338
  75. package/dist/skills.js +0 -134
  76. package/dist/ssh-setup.js +0 -181
  77. package/dist/terminal.js +0 -8
  78. package/dist/tui-theme.js +0 -41
  79. package/dist/version.js +0 -48
  80. package/dist/workbench-events.js +0 -72
@@ -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
- };
@@ -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
- };