@sealant/mend 0.12.2 → 0.13.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.js +4 -2
- package/dist/main.js +240 -21
- package/dist/shared.js +30 -2
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -275,9 +275,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
275
275
|
}
|
|
276
276
|
say(outcome === "unavailable"
|
|
277
277
|
? "attach unavailable — could not connect"
|
|
278
|
-
: outcome === "detached"
|
|
278
|
+
: outcome === "detached" || outcome === "interrupted"
|
|
279
279
|
? `detached — ${short} keeps running`
|
|
280
|
-
:
|
|
280
|
+
: outcome === "dropped"
|
|
281
|
+
? `disconnected — ${short} keeps running`
|
|
282
|
+
: `session settled · ${short}`);
|
|
281
283
|
refetch();
|
|
282
284
|
};
|
|
283
285
|
/** Converge on the server's truth once, after the LAST in-flight mutation. */
|
package/dist/main.js
CHANGED
|
@@ -227,6 +227,24 @@ const findProject = async (config, explicit, adoptCwd = false) => {
|
|
|
227
227
|
say(`${green("✓")} adopted · ${adopted.name} · ${dim(adopted.storePath)}`);
|
|
228
228
|
return adopted;
|
|
229
229
|
};
|
|
230
|
+
/**
|
|
231
|
+
* Cascade for the launch lifecycle: per-launch flag → project stance → global
|
|
232
|
+
* setting. Any read failure (an older server, a blip) stays background — a
|
|
233
|
+
* network hiccup must never flip launch semantics to foreground.
|
|
234
|
+
*/
|
|
235
|
+
const resolvedBackgroundSessions = async (config, project) => {
|
|
236
|
+
if (project.backgroundSessions === "on")
|
|
237
|
+
return true;
|
|
238
|
+
if (project.backgroundSessions === "off")
|
|
239
|
+
return false;
|
|
240
|
+
try {
|
|
241
|
+
const settings = await request(config, "GET", "/settings");
|
|
242
|
+
return settings.backgroundSessions !== false;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
230
248
|
const launch = async (config, harness, args) => {
|
|
231
249
|
const parsed = parseLaunchArgs(args);
|
|
232
250
|
if (parsed.error !== null)
|
|
@@ -239,6 +257,9 @@ const launch = async (config, harness, args) => {
|
|
|
239
257
|
if (harness === "run" && structured) {
|
|
240
258
|
return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
|
|
241
259
|
}
|
|
260
|
+
if (harness === "run" && (parsed.detach || parsed.foreground)) {
|
|
261
|
+
return fail("mend run takes no lifecycle flags — it tails the record; Ctrl+C stops watching, not the command");
|
|
262
|
+
}
|
|
242
263
|
const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
|
|
243
264
|
if (argv.length === 0) {
|
|
244
265
|
return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
|
|
@@ -247,11 +268,38 @@ const launch = async (config, harness, args) => {
|
|
|
247
268
|
// Say which project the cwd resolved to before anything is created — a
|
|
248
269
|
// wrong guess should be visible here, not discovered in the tree later.
|
|
249
270
|
say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
|
|
271
|
+
const lifecycle = harness === "run"
|
|
272
|
+
? "background"
|
|
273
|
+
: parsed.detach
|
|
274
|
+
? "detach"
|
|
275
|
+
: parsed.foreground
|
|
276
|
+
? "foreground"
|
|
277
|
+
: (await resolvedBackgroundSessions(config, project))
|
|
278
|
+
? "background"
|
|
279
|
+
: "foreground";
|
|
280
|
+
// Foreground holds from the moment the session exists: a signal between
|
|
281
|
+
// create and attach stops it too. attachTty owns signals while attached.
|
|
282
|
+
let createdSessionId = null;
|
|
283
|
+
let attachOwnsSignals = false;
|
|
284
|
+
const onLaunchSignal = () => {
|
|
285
|
+
if (attachOwnsSignals)
|
|
286
|
+
return;
|
|
287
|
+
const id = createdSessionId;
|
|
288
|
+
if (id === null)
|
|
289
|
+
process.exit(1);
|
|
290
|
+
void stopSessionQuickly(config, id).then((stopped) => process.exit(stopped ? 0 : 1));
|
|
291
|
+
};
|
|
292
|
+
if (lifecycle === "foreground") {
|
|
293
|
+
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
|
294
|
+
process.on(signal, onLaunchSignal);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
250
297
|
const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
|
|
251
298
|
harness,
|
|
252
299
|
label: null,
|
|
253
300
|
base: parsed.base,
|
|
254
301
|
});
|
|
302
|
+
createdSessionId = session.id;
|
|
255
303
|
say(`${green("✓")} worktree ${session.worktree} ${dim(`· branch ${session.branch}`)}`);
|
|
256
304
|
const baseWord = session.baseRef === null ? "" : `${session.baseRef} `;
|
|
257
305
|
say(`${green("✓")} base ${baseWord}${dim(session.baseSha.slice(0, 12))} · session ${dim(session.id.slice(0, 8))}`);
|
|
@@ -274,12 +322,17 @@ const launch = async (config, harness, args) => {
|
|
|
274
322
|
}
|
|
275
323
|
: { argv };
|
|
276
324
|
await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, launchBody));
|
|
325
|
+
if (lifecycle === "detach") {
|
|
326
|
+
say(`${green("✓ recording")} · running detached`);
|
|
327
|
+
say(`${cobalt(" attach")} · mend attach ${session.id.slice(0, 8)}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
277
330
|
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
278
331
|
say("");
|
|
279
|
-
|
|
332
|
+
attachOwnsSignals = true;
|
|
333
|
+
await attachOrExit(config, session.id, session.harness, lifecycle);
|
|
280
334
|
exitAfterSessionEnd(config, session.id);
|
|
281
335
|
};
|
|
282
|
-
// ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
|
|
283
336
|
/**
|
|
284
337
|
* Attach this terminal to the session's PTY through the Mend server over ONE
|
|
285
338
|
* WebSocket: binary frames are PTY bytes both ways, text frames carry control
|
|
@@ -288,7 +341,9 @@ const launch = async (config, harness, args) => {
|
|
|
288
341
|
* Ctrl+] detaches — the session keeps running and can be reattached from
|
|
289
342
|
* anywhere. Resolves when the session settles or the user detaches; the
|
|
290
343
|
* caller decides what each outcome means (commands exit, the dashboard
|
|
291
|
-
* resumes).
|
|
344
|
+
* resumes). With `handleSignals`, a terminal-window close (SIGHUP) or kill
|
|
345
|
+
* resolves `interrupted` through the same restore path instead of leaving
|
|
346
|
+
* raw mode pushed — the handler never exits the process itself.
|
|
292
347
|
*/
|
|
293
348
|
const attachTty = async (config, sessionId, harness, from, processId, options) => {
|
|
294
349
|
const url = parseMendUrl(`${config.url}/api/tty`);
|
|
@@ -336,6 +391,16 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
336
391
|
const rawTty = process.stdin.isTTY === true;
|
|
337
392
|
let rawModeEnabled = false;
|
|
338
393
|
let detached = false;
|
|
394
|
+
let sawEnd = false;
|
|
395
|
+
let interrupted = false;
|
|
396
|
+
const onSignal = () => {
|
|
397
|
+
// Resolve the attach instead of exiting: the shared `finally` restores raw
|
|
398
|
+
// mode and closes the socket, then the caller decides what the signal means.
|
|
399
|
+
interrupted = true;
|
|
400
|
+
ws.close();
|
|
401
|
+
finishAttachment?.();
|
|
402
|
+
};
|
|
403
|
+
const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
339
404
|
const onTtyFrame = (event) => {
|
|
340
405
|
if (typeof event.data !== "string") {
|
|
341
406
|
process.stdout.write(Buffer.from(event.data));
|
|
@@ -349,6 +414,7 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
349
414
|
// Session lifecycle is authoritative. Start detaching immediately instead
|
|
350
415
|
// of holding the user's terminal for the later close handshake and Mend's
|
|
351
416
|
// settle/checkpoint/harvest work.
|
|
417
|
+
sawEnd = true;
|
|
352
418
|
ws.close();
|
|
353
419
|
finishAttachment?.();
|
|
354
420
|
}
|
|
@@ -371,6 +437,9 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
371
437
|
try {
|
|
372
438
|
sendResize();
|
|
373
439
|
process.stdout.on("resize", onWinch);
|
|
440
|
+
if (options?.handleSignals === true)
|
|
441
|
+
for (const signal of signals)
|
|
442
|
+
process.on(signal, onSignal);
|
|
374
443
|
// Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
|
|
375
444
|
// socket drops, and the process inside keeps running untouched.
|
|
376
445
|
if (options?.readOnly !== true) {
|
|
@@ -383,9 +452,11 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
383
452
|
}
|
|
384
453
|
ws.addEventListener("message", onTtyFrame);
|
|
385
454
|
await finished;
|
|
386
|
-
return detached ? "detached" : "ended";
|
|
455
|
+
return detached ? "detached" : interrupted ? "interrupted" : sawEnd ? "ended" : "dropped";
|
|
387
456
|
}
|
|
388
457
|
finally {
|
|
458
|
+
for (const signal of signals)
|
|
459
|
+
process.off(signal, onSignal);
|
|
389
460
|
process.stdin.off("data", onKeys);
|
|
390
461
|
process.stdout.off("resize", onWinch);
|
|
391
462
|
ws.removeEventListener("message", onTtyFrame);
|
|
@@ -398,18 +469,81 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
398
469
|
stopHerdrHint();
|
|
399
470
|
}
|
|
400
471
|
};
|
|
401
|
-
/**
|
|
402
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Best-effort stop under a signal's short grace window — a SIGHUP handler
|
|
474
|
+
* cannot afford the ordinary retry path. True when the server accepted.
|
|
475
|
+
*/
|
|
476
|
+
const stopSessionQuickly = async (config, sessionId) => {
|
|
477
|
+
const headers = { "content-type": "application/json" };
|
|
478
|
+
if (config.token !== null)
|
|
479
|
+
headers["authorization"] = `Bearer ${config.token}`;
|
|
480
|
+
try {
|
|
481
|
+
const response = await fetch(`${config.url}/api/sessions/${sessionId}/stop`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers,
|
|
484
|
+
signal: AbortSignal.timeout(2500),
|
|
485
|
+
});
|
|
486
|
+
return response.ok;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
/** Foreground exit: stop the session, or say honestly that it may still run. */
|
|
493
|
+
const stopAndExit = async (config, sessionId) => {
|
|
494
|
+
const id8 = sessionId.slice(0, 8);
|
|
495
|
+
if (await stopSessionQuickly(config, sessionId)) {
|
|
496
|
+
say(`${green("✓")} stopped · ${id8}`);
|
|
497
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
|
|
498
|
+
process.exit(0);
|
|
499
|
+
}
|
|
500
|
+
say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* How every one-shot command handles an attach outcome: detach says so and
|
|
505
|
+
* always leaves the session running; a signal or a dropped socket answers to
|
|
506
|
+
* the lifecycle mode; a settled session returns so the caller prints its facts.
|
|
507
|
+
*/
|
|
508
|
+
const finishAttach = async (config, sessionId, outcome, mode = "background") => {
|
|
509
|
+
const id8 = sessionId.slice(0, 8);
|
|
403
510
|
if (outcome === "unavailable") {
|
|
404
511
|
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
405
512
|
}
|
|
406
513
|
if (outcome === "detached") {
|
|
407
514
|
say("");
|
|
408
|
-
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${
|
|
515
|
+
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
if (outcome === "interrupted") {
|
|
519
|
+
say("");
|
|
520
|
+
if (mode === "foreground")
|
|
521
|
+
return stopAndExit(config, sessionId);
|
|
522
|
+
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
523
|
+
process.exit(0);
|
|
524
|
+
}
|
|
525
|
+
if (outcome === "dropped") {
|
|
526
|
+
say("");
|
|
527
|
+
if (mode === "foreground") {
|
|
528
|
+
// The drop may be the server settling the session — verify before stopping.
|
|
529
|
+
let live;
|
|
530
|
+
try {
|
|
531
|
+
const detail = await request(config, "GET", `/sessions/${sessionId}`);
|
|
532
|
+
live = agentIsLive(detail.session, detail.currentAgent);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
if (!live)
|
|
539
|
+
return; // settled as the socket closed — the caller prints the end facts
|
|
540
|
+
return stopAndExit(config, sessionId);
|
|
541
|
+
}
|
|
542
|
+
say(`${amber("disconnected")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
409
543
|
process.exit(0);
|
|
410
544
|
}
|
|
411
545
|
};
|
|
412
|
-
const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
|
|
546
|
+
const attachOrExit = async (config, sessionId, harness, mode = "background") => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n, undefined, { handleSignals: true }), mode);
|
|
413
547
|
/** Return terminal control as soon as the terminal reports the observed end. */
|
|
414
548
|
const exitAfterSessionEnd = (config, sessionId) => {
|
|
415
549
|
say("");
|
|
@@ -431,6 +565,56 @@ const attach = async (config, args) => {
|
|
|
431
565
|
await attachOrExit(config, match.id, match.harness);
|
|
432
566
|
exitAfterSessionEnd(config, match.id);
|
|
433
567
|
};
|
|
568
|
+
/**
|
|
569
|
+
* Explicit stop — the one intent detaching never carries. Ends the agent; the
|
|
570
|
+
* workspace harvests and closes; the record and review remain.
|
|
571
|
+
*/
|
|
572
|
+
const stopCommand = async (config, args) => {
|
|
573
|
+
const all = args.includes("--all");
|
|
574
|
+
const projectFlag = args.indexOf("--project");
|
|
575
|
+
const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
|
|
576
|
+
? String(args[projectFlag + 1])
|
|
577
|
+
: null;
|
|
578
|
+
const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
|
|
579
|
+
if (!all && prefix === undefined) {
|
|
580
|
+
return fail("usage: mend stop <session-id-prefix> | --all [--project <p>]");
|
|
581
|
+
}
|
|
582
|
+
const [sessions, projects] = await Promise.all([
|
|
583
|
+
api(config, "GET", "/sessions"),
|
|
584
|
+
api(config, "GET", "/projects"),
|
|
585
|
+
]);
|
|
586
|
+
let scoped = sessions;
|
|
587
|
+
if (projectName !== null) {
|
|
588
|
+
const project = projects.find((p) => p.name === projectName);
|
|
589
|
+
if (project === undefined)
|
|
590
|
+
return fail(`no project named "${projectName}"`);
|
|
591
|
+
scoped = sessions.filter((s) => s.projectId === project.id);
|
|
592
|
+
}
|
|
593
|
+
let targets;
|
|
594
|
+
if (all) {
|
|
595
|
+
targets = scoped;
|
|
596
|
+
if (targets.length === 0) {
|
|
597
|
+
say("no active sessions");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
const matches = scoped.filter((s) => s.id.startsWith(prefix ?? ""));
|
|
603
|
+
if (matches.length === 0)
|
|
604
|
+
return fail(`no active session matches "${prefix}"`);
|
|
605
|
+
if (matches.length > 1) {
|
|
606
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
607
|
+
}
|
|
608
|
+
targets = matches;
|
|
609
|
+
}
|
|
610
|
+
for (const session of targets) {
|
|
611
|
+
await api(config, "POST", `/sessions/${session.id}/stop`);
|
|
612
|
+
say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
|
|
613
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
614
|
+
}
|
|
615
|
+
if (all)
|
|
616
|
+
say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
|
|
617
|
+
};
|
|
434
618
|
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
435
619
|
const pickSessionInteractively = async (rows) => {
|
|
436
620
|
say(dim("more than one live session — pick one:"));
|
|
@@ -493,12 +677,14 @@ const shellCommand = async (config, args) => {
|
|
|
493
677
|
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
494
678
|
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
495
679
|
say("");
|
|
496
|
-
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id
|
|
680
|
+
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id, {
|
|
681
|
+
handleSignals: true,
|
|
682
|
+
});
|
|
497
683
|
if (outcome === "unavailable") {
|
|
498
684
|
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
499
685
|
}
|
|
500
686
|
say("");
|
|
501
|
-
if (outcome === "detached") {
|
|
687
|
+
if (outcome === "detached" || outcome === "interrupted" || outcome === "dropped") {
|
|
502
688
|
say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
|
|
503
689
|
process.exit(0);
|
|
504
690
|
}
|
|
@@ -1571,7 +1757,8 @@ _mend() {
|
|
|
1571
1757
|
'adopt:adopt a repository into the store'
|
|
1572
1758
|
'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
|
|
1573
1759
|
'run:new session + arbitrary command'
|
|
1574
|
-
'attach:reattach to a running session' '
|
|
1760
|
+
'attach:reattach to a running session' 'stop:stop the agent — record and review remain'
|
|
1761
|
+
'shell:open a shell in a live session workspace'
|
|
1575
1762
|
'service:reachable ports — add, list, stop'
|
|
1576
1763
|
'keys:the machine Mend deploy key — init, show, share'
|
|
1577
1764
|
'accounts:your connected accounts on the platform'
|
|
@@ -1587,7 +1774,7 @@ _mend() {
|
|
|
1587
1774
|
return
|
|
1588
1775
|
fi
|
|
1589
1776
|
case $words[2] in
|
|
1590
|
-
shell|attach|continue|resume|rejoin)
|
|
1777
|
+
shell|attach|stop|continue|resume|rejoin)
|
|
1591
1778
|
local -a sessions
|
|
1592
1779
|
sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
|
|
1593
1780
|
(( \${#sessions} )) && _describe 'session' sessions
|
|
@@ -1599,11 +1786,11 @@ _mend "$@"
|
|
|
1599
1786
|
const BASH_COMPLETIONS = `_mend() {
|
|
1600
1787
|
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1601
1788
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1602
|
-
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1789
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1603
1790
|
return
|
|
1604
1791
|
fi
|
|
1605
1792
|
case \${COMP_WORDS[1]} in
|
|
1606
|
-
shell|attach|continue|resume|rejoin)
|
|
1793
|
+
shell|attach|stop|continue|resume|rejoin)
|
|
1607
1794
|
COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
|
|
1608
1795
|
;;
|
|
1609
1796
|
esac
|
|
@@ -1782,7 +1969,17 @@ const resumeCommand = async (config, args) => {
|
|
|
1782
1969
|
}
|
|
1783
1970
|
say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
|
|
1784
1971
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${match.id}`);
|
|
1785
|
-
|
|
1972
|
+
// A formerly-protocol session (a phone pickup) resumed from a terminal must
|
|
1973
|
+
// come back as a TUI — the handoff verb routes it there; a plain resume
|
|
1974
|
+
// would re-enter protocol mode with nothing to attach.
|
|
1975
|
+
const priorAgent = detail.annotations.find((annotation) => annotation.sessionId === match.id)?.currentAgent ??
|
|
1976
|
+
null;
|
|
1977
|
+
const protocolPrior = priorAgent?.kind === "agent-protocol" && withHarness === null;
|
|
1978
|
+
await withSpinner(protocolPrior
|
|
1979
|
+
? "reopening as a terminal — same conversation…"
|
|
1980
|
+
: "resuming — a fresh workspace restores the saved session state…", protocolPrior
|
|
1981
|
+
? api(config, "POST", `/sessions/${match.id}/handoff`, { to: "pty" })
|
|
1982
|
+
: api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
|
|
1786
1983
|
say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
|
|
1787
1984
|
say("");
|
|
1788
1985
|
await attachOrExit(config, match.id, withHarness ?? match.harness);
|
|
@@ -1840,22 +2037,37 @@ const rejoinCommand = async (config, args) => {
|
|
|
1840
2037
|
say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
|
|
1841
2038
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
|
|
1842
2039
|
let restored = false;
|
|
1843
|
-
|
|
2040
|
+
const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
|
|
2041
|
+
null;
|
|
2042
|
+
if (currentAgent?.kind === "agent-protocol") {
|
|
2043
|
+
// A protocol agent (a phone pickup) has no PTY to attach. Hand the session
|
|
2044
|
+
// off to a terminal: the TUI resumes the same provider conversation, with
|
|
2045
|
+
// the phone-authored turns in its scrollback.
|
|
2046
|
+
await withSpinner(alreadyLive
|
|
2047
|
+
? "taking over from the protocol session — same conversation…"
|
|
2048
|
+
: "reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
|
|
2049
|
+
restored = true;
|
|
2050
|
+
}
|
|
2051
|
+
else if (!alreadyLive) {
|
|
1844
2052
|
restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
|
|
1845
2053
|
}
|
|
1846
2054
|
say(restored
|
|
1847
2055
|
? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
|
|
1848
2056
|
: `${green("✓ recording")} · attached to the live session${detachHint()}`);
|
|
1849
2057
|
say("");
|
|
1850
|
-
let outcome = await attachTty(config, session.id, session.harness, 0n
|
|
2058
|
+
let outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
|
|
2059
|
+
handleSignals: true,
|
|
2060
|
+
});
|
|
1851
2061
|
if (outcome === "unavailable") {
|
|
1852
2062
|
const refreshed = await api(config, "GET", `/sessions/${session.id}`);
|
|
1853
2063
|
if (!agentIsLive(refreshed.session, refreshed.currentAgent)) {
|
|
1854
2064
|
await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
|
|
1855
2065
|
}
|
|
1856
|
-
outcome = await attachTty(config, session.id, session.harness, 0n
|
|
2066
|
+
outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
|
|
2067
|
+
handleSignals: true,
|
|
2068
|
+
});
|
|
1857
2069
|
}
|
|
1858
|
-
finishAttach(config, session.id, outcome);
|
|
2070
|
+
await finishAttach(config, session.id, outcome);
|
|
1859
2071
|
exitAfterSessionEnd(config, session.id);
|
|
1860
2072
|
};
|
|
1861
2073
|
// ─── projects · sessions: the workbench at a glance ─────────────────────────
|
|
@@ -2051,11 +2263,13 @@ start
|
|
|
2051
2263
|
adopt a repository into the store (default: cwd; any git
|
|
2052
2264
|
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
2053
2265
|
mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
|
|
2054
|
-
[--base <ref>] [--ask] [--fast]
|
|
2266
|
+
[--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
|
|
2055
2267
|
new session worktree + launch the harness in it; a quoted
|
|
2056
2268
|
prompt becomes its first message (and names the session),
|
|
2057
2269
|
--ask restores the harness's permission prompts, --fast
|
|
2058
|
-
requests priority processing (codex service tier)
|
|
2270
|
+
requests priority processing (codex service tier),
|
|
2271
|
+
--detach launches without attaching (reattach anywhere),
|
|
2272
|
+
--foreground stops the session when this CLI exits
|
|
2059
2273
|
mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
|
|
2060
2274
|
the URL to reach this server (one device, once, 10 minutes)
|
|
2061
2275
|
mend doctor read-only checklist of this machine's setup — one line per
|
|
@@ -2077,6 +2291,9 @@ everything else
|
|
|
2077
2291
|
hardware keys sign here; Ctrl-C stops sharing)
|
|
2078
2292
|
mend run -- <command...> same, with an arbitrary command
|
|
2079
2293
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
2294
|
+
mend stop <session-id-prefix> | --all [--project <p>]
|
|
2295
|
+
stop the agent — the workspace harvests and closes; the
|
|
2296
|
+
record and review remain
|
|
2080
2297
|
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
2081
2298
|
mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
|
|
2082
2299
|
start + supervise a server in the session workspace
|
|
@@ -2123,6 +2340,8 @@ const main = async () => {
|
|
|
2123
2340
|
return launch(config, command, rest);
|
|
2124
2341
|
case "attach":
|
|
2125
2342
|
return attach(config, rest);
|
|
2343
|
+
case "stop":
|
|
2344
|
+
return stopCommand(config, rest);
|
|
2126
2345
|
case "shell":
|
|
2127
2346
|
return shellCommand(config, rest);
|
|
2128
2347
|
case "service":
|
package/dist/shared.js
CHANGED
|
@@ -36,11 +36,14 @@ const LAUNCH_ERROR = {
|
|
|
36
36
|
base: null,
|
|
37
37
|
ask: false,
|
|
38
38
|
fast: false,
|
|
39
|
+
detach: false,
|
|
40
|
+
foreground: false,
|
|
39
41
|
custom: [],
|
|
40
42
|
};
|
|
41
43
|
/**
|
|
42
44
|
* `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
|
|
43
|
-
* [--base <ref>] [--ask] [--project <p>]`, plus
|
|
45
|
+
* [--base <ref>] [--ask] [--detach|-d] [--foreground] [--project <p>]`, plus
|
|
46
|
+
* `mend run … -- <command...>`.
|
|
44
47
|
* The first non-flag positional is the prompt; a second one is an error so a
|
|
45
48
|
* forgotten quote fails loudly instead of launching with half a sentence.
|
|
46
49
|
*/
|
|
@@ -55,6 +58,8 @@ export const parseLaunchArgs = (args) => {
|
|
|
55
58
|
let base = null;
|
|
56
59
|
let ask = false;
|
|
57
60
|
let fast = false;
|
|
61
|
+
let detach = false;
|
|
62
|
+
let foreground = false;
|
|
58
63
|
for (let index = 0; index < flagArgs.length; index += 1) {
|
|
59
64
|
const arg = flagArgs[index] ?? "";
|
|
60
65
|
if (arg === "--ask") {
|
|
@@ -65,6 +70,14 @@ export const parseLaunchArgs = (args) => {
|
|
|
65
70
|
fast = true;
|
|
66
71
|
continue;
|
|
67
72
|
}
|
|
73
|
+
if (arg === "--detach" || arg === "-d") {
|
|
74
|
+
detach = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg === "--foreground") {
|
|
78
|
+
foreground = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
68
81
|
if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
|
|
69
82
|
const value = flagArgs[index + 1];
|
|
70
83
|
if (value === undefined)
|
|
@@ -99,7 +112,22 @@ export const parseLaunchArgs = (args) => {
|
|
|
99
112
|
if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
|
|
100
113
|
return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
|
|
101
114
|
}
|
|
102
|
-
|
|
115
|
+
if (detach && foreground) {
|
|
116
|
+
return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
project,
|
|
120
|
+
prompt,
|
|
121
|
+
model,
|
|
122
|
+
effort,
|
|
123
|
+
base,
|
|
124
|
+
ask,
|
|
125
|
+
fast,
|
|
126
|
+
detach,
|
|
127
|
+
foreground,
|
|
128
|
+
custom,
|
|
129
|
+
error: null,
|
|
130
|
+
};
|
|
103
131
|
};
|
|
104
132
|
export const LIVE_STATUSES = new Set([
|
|
105
133
|
"starting",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|