@sealant/mend 0.16.0 → 0.17.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/dashboard-model.js +8 -0
- package/dist/dashboard.js +12 -3
- package/dist/main.js +54 -16
- package/package.json +1 -1
package/dist/dashboard-model.js
CHANGED
|
@@ -208,6 +208,14 @@ export const foldGroupStatus = (group) => {
|
|
|
208
208
|
* Null = nothing attachable; opening a new shell is then honest.
|
|
209
209
|
*/
|
|
210
210
|
export const liveShellOf = (processes) => processes.findLast((process) => process.kind === "shell" && process.exitedAt === null) ?? null;
|
|
211
|
+
/**
|
|
212
|
+
* The session's live protocol agent — a phone pickup driving the same
|
|
213
|
+
* conversation over stream-json. It holds the workspace with no PTY behind it,
|
|
214
|
+
* so a terminal "get me in" must take it over (hand back to a TUI) rather than
|
|
215
|
+
* report the attach unavailable or open a bare shell. Null = no pickup to take.
|
|
216
|
+
*/
|
|
217
|
+
export const liveProtocolOf = (processes) => processes.find((process) => process.kind === "agent-protocol" && process.exitedAt === null) ??
|
|
218
|
+
null;
|
|
211
219
|
/**
|
|
212
220
|
* The optimistic stop: the row settles AND its live process/service fact
|
|
213
221
|
* lines drop in the same paint — the server's refetch only confirms.
|
package/dist/dashboard.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
|
|
|
3
3
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
4
4
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
6
|
+
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
7
7
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
8
8
|
import { ReviewScreen } from "./review.js";
|
|
9
9
|
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
@@ -261,8 +261,17 @@ const App = ({ ctx, onQuit }) => {
|
|
|
261
261
|
// one when nothing is attachable (stacking a new bash per attempt is
|
|
262
262
|
// how a session ends up held open by orphan shells).
|
|
263
263
|
const detail = await ctx.api("GET", `/sessions/${session.id}`);
|
|
264
|
+
const liveProtocol = liveProtocolOf(detail.processes);
|
|
264
265
|
const existing = liveShellOf(detail.processes);
|
|
265
|
-
if (
|
|
266
|
+
if (liveProtocol !== null) {
|
|
267
|
+
// A phone pickup holds the session in protocol mode — no PTY behind
|
|
268
|
+
// it. Take it over: end the protocol agent, resume the same
|
|
269
|
+
// conversation as a TUI, then attach to that.
|
|
270
|
+
process.stdout.write(`taking over from the protocol session — same conversation…\n\n`);
|
|
271
|
+
await ctx.api("POST", `/sessions/${session.id}/handoff`, { to: "pty" });
|
|
272
|
+
outcome = await ctx.attachTty(session.id, session.harness);
|
|
273
|
+
}
|
|
274
|
+
else if (existing !== null) {
|
|
266
275
|
process.stdout.write(`no live terminal — rejoining the open shell\n\n`);
|
|
267
276
|
outcome = await ctx.attachTty(session.id, "shell", existing.id);
|
|
268
277
|
}
|
|
@@ -997,7 +1006,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
997
1006
|
? " ↑↓ move · enter start · esc cancel"
|
|
998
1007
|
: focus === "projects"
|
|
999
1008
|
? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
|
|
1000
|
-
: " ↑↓ move · enter attach/resume · n new worktree · s session here ·
|
|
1009
|
+
: " ↑↓ move · enter attach/resume · n new worktree · s session here · ⇧K kill · ⇧D remove · v review · e rename · o web · q quit";
|
|
1001
1010
|
const loadFailure = data === undefined && failureReason !== null
|
|
1002
1011
|
? failureReason instanceof Error
|
|
1003
1012
|
? failureReason.message
|
package/dist/main.js
CHANGED
|
@@ -618,16 +618,41 @@ const exitAfterSessionEnd = (config, sessionId) => {
|
|
|
618
618
|
/** Reattach a terminal to a running session (full scrollback replay, then live). */
|
|
619
619
|
const attach = async (config, args) => {
|
|
620
620
|
const prefix = args.find((a) => !a.startsWith("--"));
|
|
621
|
-
|
|
622
|
-
|
|
621
|
+
// No id: the picker IS the selection surface (the resolution `mend shell`
|
|
622
|
+
// already uses), so attaching never demands an id the user must go look up.
|
|
623
|
+
if (prefix === undefined) {
|
|
624
|
+
const picked = await resolveLiveSession(config, undefined, "attach");
|
|
625
|
+
return attachPicked(config, picked);
|
|
626
|
+
}
|
|
623
627
|
const sessions = await api(config, "GET", "/sessions");
|
|
624
628
|
const match = sessions.find((s) => s.id.startsWith(prefix));
|
|
625
629
|
if (match === undefined)
|
|
626
630
|
return fail(`no active session matches "${prefix}"`);
|
|
627
|
-
|
|
631
|
+
return attachPicked(config, match);
|
|
632
|
+
};
|
|
633
|
+
/** Attach to one resolved session — the tail both `mend attach` paths share. */
|
|
634
|
+
const attachPicked = async (config, session) => {
|
|
635
|
+
say(`${green("✓")} attaching to ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))}${detachHint()}`);
|
|
628
636
|
say("");
|
|
629
|
-
await
|
|
630
|
-
|
|
637
|
+
const outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
|
|
638
|
+
handleSignals: true,
|
|
639
|
+
});
|
|
640
|
+
// A live protocol agent (a phone pickup) has no PTY behind it — the attach
|
|
641
|
+
// reports "unavailable" with nothing wrong. Take the session over: end the
|
|
642
|
+
// protocol agent, resume the same conversation as a TUI, then attach to it.
|
|
643
|
+
if (outcome === "unavailable") {
|
|
644
|
+
const detail = await api(config, "GET", `/sessions/${session.id}`);
|
|
645
|
+
if (detail.currentAgent?.kind === "agent-protocol" &&
|
|
646
|
+
agentIsLive(detail.session, detail.currentAgent)) {
|
|
647
|
+
say(`${amber("taking over")} from the protocol session`);
|
|
648
|
+
await withSpinner("reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
|
|
649
|
+
say("");
|
|
650
|
+
await attachOrExit(config, session.id, session.harness);
|
|
651
|
+
return exitAfterSessionEnd(config, session.id);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
await finishAttach(config, session.id, outcome);
|
|
655
|
+
return exitAfterSessionEnd(config, session.id);
|
|
631
656
|
};
|
|
632
657
|
/**
|
|
633
658
|
* Explicit stop — the one intent detaching never carries. Ends the agent; the
|
|
@@ -640,8 +665,15 @@ const stopCommand = async (config, args) => {
|
|
|
640
665
|
? String(args[projectFlag + 1])
|
|
641
666
|
: null;
|
|
642
667
|
const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
|
|
668
|
+
// Neither an id nor --all: pick the session to close, the same resolution
|
|
669
|
+
// `mend attach` uses. --all and --project keep their bulk meaning.
|
|
670
|
+
if (!all && prefix === undefined && projectName === null) {
|
|
671
|
+
const picked = await resolveLiveSession(config, undefined, "stop");
|
|
672
|
+
await stopSessions(config, [picked], false);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
643
675
|
if (!all && prefix === undefined) {
|
|
644
|
-
return fail("usage: mend stop
|
|
676
|
+
return fail("usage: mend stop [session-id-prefix] | --all [--project <p>]");
|
|
645
677
|
}
|
|
646
678
|
const [sessions, projects] = await Promise.all([
|
|
647
679
|
api(config, "GET", "/sessions"),
|
|
@@ -671,19 +703,24 @@ const stopCommand = async (config, args) => {
|
|
|
671
703
|
}
|
|
672
704
|
targets = matches;
|
|
673
705
|
}
|
|
706
|
+
await stopSessions(config, targets, all);
|
|
707
|
+
};
|
|
708
|
+
/** Stop each target and print its record link — the tail every stop path shares. */
|
|
709
|
+
const stopSessions = async (config, targets, summarise) => {
|
|
674
710
|
for (const session of targets) {
|
|
675
711
|
await api(config, "POST", `/sessions/${session.id}/stop`);
|
|
676
712
|
say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
|
|
677
713
|
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
678
714
|
}
|
|
679
|
-
if (
|
|
715
|
+
if (summarise) {
|
|
680
716
|
say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
|
|
717
|
+
}
|
|
681
718
|
};
|
|
682
719
|
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
683
720
|
const pickSessionInteractively = async (rows) => {
|
|
684
721
|
say(dim("more than one live session — pick one:"));
|
|
685
722
|
rows.forEach((row, index) => {
|
|
686
|
-
say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
|
|
723
|
+
say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)} ${dim(row.session.status)}`);
|
|
687
724
|
});
|
|
688
725
|
const readline = await import("node:readline/promises");
|
|
689
726
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -699,7 +736,7 @@ const pickSessionInteractively = async (rows) => {
|
|
|
699
736
|
* the cwd's project narrows, one candidate is taken, several go to the
|
|
700
737
|
* picker, and a non-interactive caller never gets a silent guess.
|
|
701
738
|
*/
|
|
702
|
-
const resolveLiveSession = async (config, prefix) => {
|
|
739
|
+
const resolveLiveSession = async (config, prefix, command) => {
|
|
703
740
|
const sessions = await api(config, "GET", "/sessions?retained=1");
|
|
704
741
|
if (prefix !== undefined) {
|
|
705
742
|
const matches = sessions.filter((s) => s.id.startsWith(prefix));
|
|
@@ -721,7 +758,7 @@ const resolveLiveSession = async (config, prefix) => {
|
|
|
721
758
|
if (candidates.length === 1)
|
|
722
759
|
return only;
|
|
723
760
|
if (process.stdin.isTTY !== true) {
|
|
724
|
-
return fail(
|
|
761
|
+
return fail(`several live sessions — name one: mend ${command} <session-id-prefix>`);
|
|
725
762
|
}
|
|
726
763
|
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
727
764
|
return pickSessionInteractively(candidates.map((session) => ({
|
|
@@ -737,7 +774,7 @@ const resolveLiveSession = async (config, prefix) => {
|
|
|
737
774
|
*/
|
|
738
775
|
const shellCommand = async (config, args) => {
|
|
739
776
|
const prefix = args.find((a) => !a.startsWith("--"));
|
|
740
|
-
const session = await resolveLiveSession(config, prefix);
|
|
777
|
+
const session = await resolveLiveSession(config, prefix, "shell");
|
|
741
778
|
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
742
779
|
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
743
780
|
say("");
|
|
@@ -872,7 +909,7 @@ const serviceAdd = async (config, args) => {
|
|
|
872
909
|
return fail(`"${portRaw}" is not a port`);
|
|
873
910
|
}
|
|
874
911
|
const prefix = positional.find((a) => a !== portRaw);
|
|
875
|
-
const session = await resolveLiveSession(config, prefix);
|
|
912
|
+
const session = await resolveLiveSession(config, prefix, "service add");
|
|
876
913
|
const service = await mutateService(config, "POST", `/sessions/${session.id}/services`, {
|
|
877
914
|
port,
|
|
878
915
|
name,
|
|
@@ -953,7 +990,7 @@ const serviceRun = async (config, args) => {
|
|
|
953
990
|
if (name === undefined)
|
|
954
991
|
return fail(usage);
|
|
955
992
|
const prefix = positionals.length > 1 ? positionals[0] : undefined;
|
|
956
|
-
const session = await resolveLiveSession(config, prefix);
|
|
993
|
+
const session = await resolveLiveSession(config, prefix, "service run");
|
|
957
994
|
const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
|
|
958
995
|
const recipe = recipes.find((entry) => entry.name === name);
|
|
959
996
|
if (recipe === undefined) {
|
|
@@ -996,7 +1033,7 @@ const serviceRun = async (config, args) => {
|
|
|
996
1033
|
if (protocol === "udp" && browserScheme !== null)
|
|
997
1034
|
return fail(usage);
|
|
998
1035
|
const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
|
|
999
|
-
const session = await resolveLiveSession(config, prefix);
|
|
1036
|
+
const session = await resolveLiveSession(config, prefix, "service run");
|
|
1000
1037
|
const service = await withSpinner(protocol === "udp"
|
|
1001
1038
|
? `starting ${name ?? argv[0]} (udp :${port})…`
|
|
1002
1039
|
: `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
@@ -2556,8 +2593,9 @@ everything else
|
|
|
2556
2593
|
mend worktrees [--project <p>] [--json]
|
|
2557
2594
|
every worktree and the sessions inside it (mend sessions
|
|
2558
2595
|
--json stays the v1 flat list; --json=v2 groups like this)
|
|
2559
|
-
mend attach
|
|
2560
|
-
|
|
2596
|
+
mend attach [session-id-prefix] reattach this terminal to a running session
|
|
2597
|
+
(no id: pick one from the live sessions)
|
|
2598
|
+
mend stop [session-id-prefix] | --all [--project <p>]
|
|
2561
2599
|
stop the agent — the workspace harvests and closes; the
|
|
2562
2600
|
record and review remain
|
|
2563
2601
|
mend shell [session-id-prefix] open a shell in a live session's workspace
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|