@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.
@@ -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 (existing !== null) {
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 · x stop · ⇧D remove · v review · e rename · o web · q quit";
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
- if (prefix === undefined)
622
- return fail("usage: mend attach <session-id-prefix>");
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
- say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
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 attachOrExit(config, match.id, match.harness);
630
- exitAfterSessionEnd(config, match.id);
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 <session-id-prefix> | --all [--project <p>]");
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 (all)
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("several live sessions — name one: mend shell <session-id-prefix>");
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 <session-id-prefix> reattach this terminal to a running session
2560
- mend stop <session-id-prefix> | --all [--project <p>]
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.16.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",