@sealant/mend 0.14.0 → 0.15.2

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.
@@ -0,0 +1,297 @@
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
+ export const deriveWorktrees = (data, projectId) => {
96
+ if (data === undefined || projectId === null)
97
+ return [];
98
+ const detail = data.details.get(projectId);
99
+ if (detail === undefined)
100
+ return [];
101
+ const item = toSessionItem(data, detail);
102
+ const sorted = detail.sessions.toSorted(bySessionRecency);
103
+ const groups = [];
104
+ if (detail.worktrees !== undefined) {
105
+ const claimed = new Set();
106
+ for (const worktree of detail.worktrees) {
107
+ const members = sorted.filter((session) => session.worktreeId === worktree.id);
108
+ for (const member of members)
109
+ claimed.add(member.id);
110
+ groups.push({
111
+ key: worktree.id,
112
+ id: worktree.id,
113
+ name: worktree.name.startsWith("wt-")
114
+ ? (members.find((m) => m.label !== null)?.label ??
115
+ (members[0] === undefined ? worktree.name : `session ${members[0].id.slice(0, 8)}`))
116
+ : worktree.name,
117
+ branch: worktree.branch,
118
+ baseRef: worktree.baseRef,
119
+ createdAt: worktree.createdAt,
120
+ sessions: members.map(item),
121
+ live: members.filter((session) => LIVE_STATUSES.has(session.status)).length,
122
+ annotation: detail.annotations.find((a) => members.some((m) => m.id === a.sessionId)),
123
+ });
124
+ }
125
+ // Optimistic pending rows have no worktree yet — each is its own group.
126
+ for (const session of sorted) {
127
+ if (claimed.has(session.id))
128
+ continue;
129
+ groups.push(pseudoGroup(session, item));
130
+ }
131
+ }
132
+ else {
133
+ for (const session of sorted)
134
+ groups.push(pseudoGroup(session, item));
135
+ }
136
+ return groups.toSorted((a, b) => {
137
+ if (a.live > 0 !== b.live > 0)
138
+ return a.live > 0 ? -1 : 1;
139
+ const aNewest = a.sessions[0]?.session.createdAt ?? a.createdAt;
140
+ const bNewest = b.sessions[0]?.session.createdAt ?? b.createdAt;
141
+ return bNewest.localeCompare(aNewest);
142
+ });
143
+ };
144
+ export const pseudoGroup = (session, item) => ({
145
+ key: session.id,
146
+ id: null,
147
+ name: worktreeDisplayName(session),
148
+ branch: session.branch,
149
+ baseRef: session.baseRef,
150
+ createdAt: session.createdAt,
151
+ sessions: [item(session)],
152
+ live: LIVE_STATUSES.has(session.status) ? 1 : 0,
153
+ annotation: item(session).annotation,
154
+ });
155
+ export const deriveRows = (groups) => groups.flatMap((group) => {
156
+ const only = group.sessions.length === 1 ? group.sessions[0] : undefined;
157
+ if (only !== undefined)
158
+ return [{ kind: "session", group, item: only }];
159
+ return [
160
+ { kind: "worktree", group },
161
+ ...group.sessions.map((item) => ({ kind: "session", group, item })),
162
+ ];
163
+ });
164
+ export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
165
+ /** The picker's rows: resume offers the same harness first, then the crossings. */
166
+ export const deriveHarnesses = (resuming) => {
167
+ if (resuming !== null) {
168
+ const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
169
+ return [
170
+ {
171
+ harness: null,
172
+ label: resuming.harness,
173
+ hint: "same harness — native resume, conversation intact",
174
+ },
175
+ ...others.map((harness) => ({
176
+ harness,
177
+ label: harness,
178
+ hint: harness === "shell"
179
+ ? "a bash in the worktree — resume either agent from inside"
180
+ : "the conversation crosses as a distilled prompt",
181
+ })),
182
+ ];
183
+ }
184
+ return Object.keys(HARNESS_COMMANDS).map((harness) => ({
185
+ harness,
186
+ label: harness,
187
+ hint: harness === "shell"
188
+ ? "a plain bash session — new worktree, recorded"
189
+ : `mend ${harness} — new worktree, recorded session`,
190
+ }));
191
+ };
192
+ /** The worktree's folded status word: waiting wins, then running, then idle. */
193
+ export const foldGroupStatus = (group) => {
194
+ const statuses = group.sessions.map((item) => item.session.status);
195
+ if (statuses.includes("waiting"))
196
+ return "waiting";
197
+ if (statuses.includes("running") || statuses.includes("starting"))
198
+ return "running";
199
+ if (statuses.includes("idle"))
200
+ return "idle";
201
+ return statuses[0] ?? "idle";
202
+ };
203
+ // ─── attach + verb helpers ──────────────────────────────────────────────────
204
+ /**
205
+ * Where "get me in" should land when the session has no live agent terminal:
206
+ * the newest LIVE shell, so repeated attaches rejoin the same one instead of
207
+ * stacking a fresh bash per attempt (the five-orphan-shells failure mode).
208
+ * Null = nothing attachable; opening a new shell is then honest.
209
+ */
210
+ export const liveShellOf = (processes) => processes.findLast((process) => process.kind === "shell" && process.exitedAt === null) ?? null;
211
+ /**
212
+ * The optimistic stop: the row settles AND its live process/service fact
213
+ * lines drop in the same paint — the server's refetch only confirms.
214
+ */
215
+ export const markSessionStopped = (data, sessionId) => {
216
+ const processesBySession = new Map(data.processesBySession);
217
+ processesBySession.delete(sessionId);
218
+ const servicesBySession = new Map(data.servicesBySession);
219
+ servicesBySession.delete(sessionId);
220
+ return {
221
+ ...mapWorkbenchSessions(data, (session) => session.id === sessionId ? { ...session, status: "stopped" } : session),
222
+ processesBySession,
223
+ servicesBySession,
224
+ };
225
+ };
226
+ /** The optimistic removal: the whole group leaves the list before the server answers. */
227
+ export const removeWorktreeGroup = (data, projectId, group) => {
228
+ const memberIds = new Set(group.sessions.map((item) => item.session.id));
229
+ const detail = data.details.get(projectId);
230
+ if (detail === undefined)
231
+ return data;
232
+ const details = new Map(data.details);
233
+ details.set(projectId, {
234
+ ...detail,
235
+ sessions: detail.sessions.filter((session) => !memberIds.has(session.id)),
236
+ ...(detail.worktrees === undefined
237
+ ? {}
238
+ : { worktrees: detail.worktrees.filter((worktree) => worktree.id !== group.id) }),
239
+ });
240
+ return { ...data, details };
241
+ };
242
+ /**
243
+ * Subsequence fuzzy score, fzf-flavored: every query character must appear in
244
+ * order; consecutive hits and segment starts (after `/`, `-`, `_`, `.`) score
245
+ * higher; earlier matches beat later ones. Null = no match. Case-insensitive.
246
+ */
247
+ export const fuzzyScore = (query, candidate) => {
248
+ if (query === "")
249
+ return 0;
250
+ const q = query.toLowerCase();
251
+ const c = candidate.toLowerCase();
252
+ let score = 0;
253
+ let last = -1;
254
+ for (const char of q) {
255
+ const at = c.indexOf(char, last + 1);
256
+ if (at === -1)
257
+ return null;
258
+ if (at === last + 1)
259
+ score += 3;
260
+ else if (at === 0 || "/-_.".includes(c[at - 1] ?? ""))
261
+ score += 2;
262
+ else
263
+ score += 1;
264
+ score -= (at - last - 1) * 0.01;
265
+ last = at;
266
+ }
267
+ return score;
268
+ };
269
+ /**
270
+ * The picker's rows for one query: fuzzy-filtered, best score first, ties by
271
+ * most recent commit; an empty query lists everything, default branch on top.
272
+ */
273
+ export const filterBranches = (branches, query) => {
274
+ const scored = branches.flatMap((branch) => {
275
+ const score = fuzzyScore(query, branch.name);
276
+ return score === null ? [] : [{ branch, score }];
277
+ });
278
+ return scored
279
+ .toSorted((a, b) => {
280
+ if (query === "" && a.branch.isDefault !== b.branch.isDefault) {
281
+ return a.branch.isDefault ? -1 : 1;
282
+ }
283
+ if (a.score !== b.score)
284
+ return b.score - a.score;
285
+ return b.branch.committedAt.localeCompare(a.branch.committedAt);
286
+ })
287
+ .map((entry) => entry.branch);
288
+ };
289
+ /** Commit the base step: the highlighted branch (default branch = null base). */
290
+ export const advanceFromBase = (current) => {
291
+ const chosen = filterBranches(current.branches ?? [], current.query)[current.baseIndex];
292
+ return {
293
+ ...current,
294
+ base: chosen === undefined || chosen.isDefault ? null : chosen.name,
295
+ step: "harness",
296
+ };
297
+ };