zcode-acp-server 0.20.1 → 0.21.1

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/repl/run.js CHANGED
@@ -20,8 +20,9 @@ import { queryQuota } from "../quota/index.js";
20
20
  import { sandboxActive } from "../backend/sandbox.js";
21
21
  import { AGENT_INFO, warn } from "../utils.js";
22
22
  import { App, } from "./App.js";
23
- import { applyStatusUpdate, applyUpdate, createReplStatus, createTurnState, finishTurn, formatQuotaLine, handleLocalCommand, parseCommand, parseQuestionForm, seedStatusFromNewSession, selectLabel, } from "./model.js";
23
+ import { applyStatusUpdate, applyUpdate, createReplStatus, createTurnState, finishTurn, formatQuotaLine, handleLocalCommand, parseCommand, parseQuestionForm, planTextFromRawInput, seedStatusFromNewSession, selectLabel, } from "./model.js";
24
24
  import { HISTORY_MAX, historyPath, loadHistory, pushHistory, saveHistory } from "./history.js";
25
+ import { fetchInstances, openHubSocket, resolveHubClient, sameWorkspace, } from "./hub.js";
25
26
  import { createLineEditor, replaceText } from "./input-buffer.js";
26
27
  export async function runRepl() {
27
28
  // Crash containment: an unexpected throw is SURFACED, never fatal — it
@@ -138,6 +139,19 @@ export async function runRepl() {
138
139
  // scrollback with thousands of lines in one burst — a bounded recent tail
139
140
  // gives context without the wall-of-text scroll jump.
140
141
  const RESUME_TAIL_LIMIT = 50;
142
+ // Hub-proxied attach (ADR-0018): budget for the WebSocket open round-trip.
143
+ const REMOTE_OPEN_TIMEOUT_MS = 5000;
144
+ // Non-null while a hub-proxied remote attach is live: the raw socket pair
145
+ // feeding the remote connection. Teardown is client-side only — the owning
146
+ // serve bridge and its session are never touched.
147
+ let remote = null;
148
+ // ACP context of the connection that owns activeSession — the local bridge
149
+ // (assigned once cx exists) or, while attached, the remote bridge.
150
+ // Out-of-band calls (session/cancel) must reach the OWNING connection.
151
+ let activeCx = null;
152
+ // Hub discovery from env; null = no hub configured — the picker then works
153
+ // exactly as before this feature existed.
154
+ const hubRef = resolveHubClient();
141
155
  const sleep = (ms) => new Promise((resolve) => setTimeout(() => resolve(null), ms));
142
156
  // Prompts submitted while another turn runs (or before the session is
143
157
  // ready); drained one per stop, FIFO.
@@ -311,98 +325,122 @@ export async function runRepl() {
311
325
  let swapGen = 0;
312
326
  // --- ACP client connection ---
313
327
  const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
314
- const app = acp
315
- .client({ name: "zcode-acp" })
316
- .onRequest("session/request_permission", async (ctx) => {
317
- // A remote client (mobile/hub) may answer the broadcast request first;
318
- // the bridge then cancels this losing copy and the SDK aborts
319
- // ctx.signal — settle here so our picker dismisses instead of hanging.
320
- const id = await new Promise((resolve) => {
321
- const onAbort = () => {
322
- permissionResolver = null;
323
- permission = null;
328
+ // One ACP client connection with the REPL's full handler chain — used for
329
+ // the local bridge AND, per attach, for hub-proxied remote bridges
330
+ // (ADR-0018): both peers are plain ACP agents, and permission, elicitation,
331
+ // and turn-state handling are identical.
332
+ function buildConnection(acpStream) {
333
+ const app = acp
334
+ .client({ name: "zcode-acp" })
335
+ .onRequest("session/request_permission", async (ctx) => {
336
+ // A remote client (mobile/hub) may answer the broadcast request first;
337
+ // the bridge then cancels this losing copy and the SDK aborts
338
+ // ctx.signal — settle here so our picker dismisses instead of hanging.
339
+ const id = await new Promise((resolve) => {
340
+ const onAbort = () => {
341
+ permissionResolver = null;
342
+ permission = null;
343
+ rerender();
344
+ resolve(null);
345
+ };
346
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
347
+ permissionResolver = (v) => {
348
+ ctx.signal.removeEventListener("abort", onAbort);
349
+ resolve(v);
350
+ };
351
+ const options = ctx.params.options.map((o) => ({ id: o.optionId, name: o.name }));
352
+ // ExitPlanMode arrives as an approve/reject permission pair (see
353
+ // interaction/adapter.ts). The plan text rides toolCall.rawInput
354
+ // ({plan}); the footer popup cannot scroll a long document, so print
355
+ // the full plan into the transcript and keep the popup to a pointer.
356
+ const isPlan = options.length === 2 && options.every((o) => o.id === "approve" || o.id === "reject");
357
+ const raw = ctx.params.toolCall?.rawInput;
358
+ let detail;
359
+ if (isPlan) {
360
+ const plan = planTextFromRawInput(raw);
361
+ if (plan) {
362
+ entries = [...entries, { kind: "plan", text: plan }];
363
+ const firstLine = plan.split("\n").find((l) => l.trim()) ?? "";
364
+ detail = `plan printed above — ${firstLine.trim().slice(0, 80)}`;
365
+ }
366
+ else {
367
+ detail = "(no plan text in the request)";
368
+ }
369
+ }
370
+ else {
371
+ detail = typeof raw === "string" ? raw : undefined;
372
+ }
373
+ permission = {
374
+ heading: isPlan ? "plan approval" : "permission requested",
375
+ title: ctx.params.toolCall?.title ?? "tool call",
376
+ detail,
377
+ options,
378
+ };
324
379
  rerender();
325
- resolve(null);
326
- };
327
- ctx.signal.addEventListener("abort", onAbort, { once: true });
328
- permissionResolver = (v) => {
329
- ctx.signal.removeEventListener("abort", onAbort);
330
- resolve(v);
331
- };
332
- const options = ctx.params.options.map((o) => ({ id: o.optionId, name: o.name }));
333
- // ExitPlanMode arrives as an approve/reject permission pair (see
334
- // interaction/adapter.ts); head it as a plan approval and show the
335
- // plan text so the decision is actually reviewable.
336
- const isPlan = options.length === 2 && options.every((o) => o.id === "approve" || o.id === "reject");
337
- const raw = ctx.params.toolCall?.rawInput;
338
- permission = {
339
- heading: isPlan ? "plan approval" : "permission requested",
340
- title: ctx.params.toolCall?.title ?? "tool call",
341
- detail: typeof raw === "string" ? raw : undefined,
342
- options,
343
- };
380
+ });
381
+ permissionResolver = null;
382
+ permission = null;
344
383
  rerender();
345
- });
346
- permissionResolver = null;
347
- permission = null;
348
- rerender();
349
- return id === null
350
- ? { outcome: { outcome: "cancelled" } }
351
- : { outcome: { outcome: "selected", optionId: id } };
352
- })
353
- .onRequest("elicitation/create", async (ctx) => {
354
- // AskUserQuestion arrives as the bridge's form elicitation. Each
355
- // question walks the picker sequentially; skipped questions omit
356
- // their key (the backend treats them as unanswered).
357
- const forms = parseQuestionForm(ctx.params);
358
- if (!forms)
359
- return { action: "decline", reason: "unsupported elicitation form" };
360
- const content = {};
361
- for (const form of forms) {
362
- const answer = await askQuestion(form, ctx.signal);
363
- // A remote client winning the broadcast aborts our request via
364
- // ctx.signal — stop answering instead of walking remaining questions.
365
- if (ctx.signal.aborted)
366
- return { action: "decline", reason: "cancelled" };
367
- if (answer === null)
368
- continue; // skipped
369
- if (form.multiSelect) {
370
- const merged = [...answer.picked, ...(answer.custom ? [answer.custom] : [])];
371
- if (merged.length > 0)
372
- content[form.key] = merged;
384
+ return id === null
385
+ ? { outcome: { outcome: "cancelled" } }
386
+ : { outcome: { outcome: "selected", optionId: id } };
387
+ })
388
+ .onRequest("elicitation/create", async (ctx) => {
389
+ // AskUserQuestion arrives as the bridge's form elicitation. Each
390
+ // question walks the picker sequentially; skipped questions omit
391
+ // their key (the backend treats them as unanswered).
392
+ const forms = parseQuestionForm(ctx.params);
393
+ if (!forms)
394
+ return { action: "decline", reason: "unsupported elicitation form" };
395
+ const content = {};
396
+ for (const form of forms) {
397
+ const answer = await askQuestion(form, ctx.signal);
398
+ // A remote client winning the broadcast aborts our request via
399
+ // ctx.signal — stop answering instead of walking remaining questions.
400
+ if (ctx.signal.aborted)
401
+ return { action: "decline", reason: "cancelled" };
402
+ if (answer === null)
403
+ continue; // skipped
404
+ if (form.multiSelect) {
405
+ const merged = [...answer.picked, ...(answer.custom ? [answer.custom] : [])];
406
+ if (merged.length > 0)
407
+ content[form.key] = merged;
408
+ }
409
+ else {
410
+ content[form.key] = answer.custom || answer.picked[0] || "";
411
+ }
373
412
  }
374
- else {
375
- content[form.key] = answer.custom || answer.picked[0] || "";
413
+ return { action: "accept", content };
414
+ })
415
+ // Out-of-band turn indicator broadcast by the bridge on every prompt start
416
+ // and end. Drives turns the REPL did NOT start itself: a mobile/second
417
+ // client prompting this session must render here too — without it those
418
+ // updates would be silently dropped (the pump only folds updates while a
419
+ // turn is active).
420
+ .onNotification("$/zcode/turnState", z.object({ sessionId: z.string(), running: z.boolean() }), (ctx) => {
421
+ if (!activeSession || ctx.params.sessionId !== activeSession.sessionId)
422
+ return;
423
+ if (ctx.params.running) {
424
+ // Local prompts already opened the turn in startTurn; idempotent.
425
+ if (!turnActive && !replayMode) {
426
+ turn = createTurnState();
427
+ turnActive = true;
428
+ rerender();
429
+ }
376
430
  }
377
- }
378
- return { action: "accept", content };
379
- })
380
- // Out-of-band turn indicator broadcast by the bridge on every prompt start
381
- // and end. Drives turns the REPL did NOT start itself: a mobile/second
382
- // client prompting this session must render here too — without it those
383
- // updates would be silently dropped (the pump only folds updates while a
384
- // turn is active).
385
- .onNotification("$/zcode/turnState", z.object({ sessionId: z.string(), running: z.boolean() }), (ctx) => {
386
- if (!activeSession || ctx.params.sessionId !== activeSession.sessionId)
387
- return;
388
- if (ctx.params.running) {
389
- // Local prompts already opened the turn in startTurn; idempotent.
390
- if (!turnActive && !replayMode) {
391
- turn = createTurnState();
392
- turnActive = true;
431
+ else if (turnActive && !promptInFlight) {
432
+ // Remote turn finished (no stop message reaches non-initiators).
433
+ entries = [...entries, ...finishTurn(turn ?? createTurnState())];
434
+ turn = null;
435
+ turnActive = false;
436
+ drainQueue();
393
437
  rerender();
394
438
  }
395
- }
396
- else if (turnActive && !promptInFlight) {
397
- // Remote turn finished (no stop message reaches non-initiators).
398
- entries = [...entries, ...finishTurn(turn ?? createTurnState())];
399
- turn = null;
400
- turnActive = false;
401
- drainQueue();
402
- rerender();
403
- }
404
- })
405
- .connect(stream);
439
+ })
440
+ .connect(acpStream);
441
+ return app;
442
+ }
443
+ const app = buildConnection(stream);
406
444
  const cx = app.agent;
407
445
  // Advertise form-elicitation so AskUserQuestion routes here as a
408
446
  // structured form instead of permission-popups labeled "permission
@@ -537,6 +575,16 @@ export async function runRepl() {
537
575
  turn = applyUpdate(turn ?? createTurnState(), msg.update);
538
576
  rerender();
539
577
  }
578
+ else if (msg.update.sessionUpdate === "user_message_chunk") {
579
+ // A remote-initiated prompt's echo lands BEFORE the turnState
580
+ // running notification (separate frames through the hub): open the
581
+ // turn here so the prompt text renders instead of being dropped.
582
+ // The running:true that follows is idempotent; running:false closes
583
+ // this turn exactly like any remote turn.
584
+ turn = applyUpdate(createTurnState(), msg.update);
585
+ turnActive = true;
586
+ rerender();
587
+ }
540
588
  else if (statusChanged) {
541
589
  rerender();
542
590
  }
@@ -633,7 +681,9 @@ export async function runRepl() {
633
681
  if (!activeSession || !turn)
634
682
  return;
635
683
  try {
636
- void cx.notify("session/cancel", { sessionId: activeSession.sessionId });
684
+ // The connection that OWNS the active session — the local bridge, or
685
+ // the hub-proxied remote bridge while attached (ADR-0018).
686
+ void (activeCx ?? cx).notify("session/cancel", { sessionId: activeSession.sessionId });
637
687
  }
638
688
  catch {
639
689
  // best-effort; a second ctrl-c exits outright
@@ -641,8 +691,10 @@ export async function runRepl() {
641
691
  }
642
692
  /**
643
693
  * `/sessions`: list this project's sessions via ACP `session/list` (the
644
- * backend filters by workspace), let the user pick one interactively, then
645
- * resume it with `session/load` (full history replayed into the transcript).
694
+ * backend filters by workspace), merge in sessions currently live on hub
695
+ * instances (ADR-0018, marked `live`), let the user pick one interactively,
696
+ * then attach: through the hub when the session is live somewhere (live
697
+ * updates), otherwise a local `session/load` resume (tail replay).
646
698
  */
647
699
  async function openSessionPicker() {
648
700
  if (turnActive) {
@@ -674,6 +726,45 @@ export async function runRepl() {
674
726
  title: s.title ?? null,
675
727
  updatedAt: s.updatedAt ?? null,
676
728
  }));
729
+ // Live view (ADR-0018): merge sessions currently registered on hub
730
+ // instances for this project. Best-effort — no hub configured, hub down,
731
+ // or auth trouble just means no `live` markers and plain local resumes,
732
+ // exactly like before the feature existed.
733
+ const liveOn = new Map();
734
+ if (hubRef) {
735
+ try {
736
+ for (const inst of await fetchInstances(hubRef, { timeoutMs: 2000 })) {
737
+ if (!sameWorkspace(inst.workspace, process.cwd()))
738
+ continue;
739
+ for (const s of inst.sessions ?? []) {
740
+ liveOn.set(s.sessionId, { inst, running: s.status === "running" });
741
+ }
742
+ }
743
+ }
744
+ catch {
745
+ // hub unreachable — local list only
746
+ }
747
+ }
748
+ for (const [sid, { inst, running }] of liveOn) {
749
+ const existing = items.find((s) => s.sessionId === sid);
750
+ const meta = inst.sessions?.find((s) => s.sessionId === sid);
751
+ if (existing) {
752
+ existing.live = true;
753
+ existing.running = running;
754
+ existing.title = existing.title ?? meta?.title ?? null;
755
+ existing.updatedAt = existing.updatedAt ?? meta?.updatedAt ?? null;
756
+ }
757
+ else {
758
+ items.push({
759
+ sessionId: sid,
760
+ cwd: inst.workspace ?? "",
761
+ title: meta?.title ?? null,
762
+ updatedAt: meta?.updatedAt ?? null,
763
+ live: true,
764
+ running,
765
+ });
766
+ }
767
+ }
677
768
  if (items.length === 0) {
678
769
  entries = [...entries, { kind: "note", text: "no previous sessions in this project yet" }];
679
770
  rerender();
@@ -690,7 +781,11 @@ export async function runRepl() {
690
781
  const picked = items.find((s) => s.sessionId === sid);
691
782
  if (sid && picked) {
692
783
  resumedDuringStartup = true;
693
- await resumeInto(picked);
784
+ const live = liveOn.get(sid);
785
+ if (hubRef && live)
786
+ await attachRemote(picked, live.inst);
787
+ else
788
+ await resumeInto(picked);
694
789
  }
695
790
  }
696
791
  /**
@@ -709,6 +804,10 @@ export async function runRepl() {
709
804
  async function resumeInto(picked) {
710
805
  const gen = ++swapGen;
711
806
  const loaded = cx.attachSession({ sessionId: picked.sessionId });
807
+ // Switching to a LOCAL session tears down any hub attach first: its
808
+ // socket, connection context, and permission-handler slot must not
809
+ // outlive the swap.
810
+ closeRemoteAttach();
712
811
  activeSession?.dispose();
713
812
  activeSession = loaded;
714
813
  replayTurn = createTurnState();
@@ -747,10 +846,20 @@ export async function runRepl() {
747
846
  return "failed";
748
847
  }
749
848
  finally {
750
- loadSettled = true;
849
+ // Same generation guard as attachRemote: a stale resume resolving after
850
+ // a newer swap must not end that swap's replay drain early.
851
+ if (gen === swapGen)
852
+ loadSettled = true;
751
853
  }
752
854
  const title = picked.title?.trim() || picked.sessionId.slice(0, 8);
753
855
  const m = resumeMeta;
856
+ // Loaded mid-turn (e.g. a hub client prompted this local bridge while the
857
+ // REPL was detached): the running flag rides the load response — no start
858
+ // notification will fire for an already-started turn.
859
+ if (m?.turnActive && !turnActive) {
860
+ turn = createTurnState();
861
+ turnActive = true;
862
+ }
754
863
  const truncated = m?.hasMore && typeof m.replayedMessages === "number" && typeof m.totalMessages === "number"
755
864
  ? ` — showing last ${m.replayedMessages} of ${m.totalMessages} messages`
756
865
  : "";
@@ -761,6 +870,193 @@ export async function runRepl() {
761
870
  rerender();
762
871
  return "loaded";
763
872
  }
873
+ /**
874
+ * Client-side teardown of a hub-proxied attach. Never touches the owning
875
+ * bridge: closing the proxied socket just removes this client from the
876
+ * owner's broadcast registry — the session (and any turn on it) continues.
877
+ */
878
+ function closeRemoteAttach() {
879
+ if (!remote)
880
+ return;
881
+ try {
882
+ remote.ws.close();
883
+ }
884
+ catch {
885
+ // ignore
886
+ }
887
+ remote = null;
888
+ activeCx = null;
889
+ }
890
+ /**
891
+ * Attach to a session live on ANOTHER bridge instance through the hub's WS
892
+ * proxy (ADR-0018): the REPL becomes a second ACP client at the owner, so
893
+ * replay and live stream are native client fan-out — the only way to watch
894
+ * a session another process is driving (the CLI's own backend holds just a
895
+ * frozen resident snapshot of those). Any failure falls back to the plain
896
+ * local resume.
897
+ */
898
+ async function attachRemote(picked, inst) {
899
+ if (!hubRef)
900
+ return;
901
+ const gen = ++swapGen;
902
+ let socket = null;
903
+ // A remote client can race a prompt into the CURRENT session during the
904
+ // attach's await windows; swapping then would orphan that turn (same
905
+ // discipline as /new) — bail out and stay put instead.
906
+ const racedTurn = () => {
907
+ if (gen !== swapGen) {
908
+ socket?.ws.close();
909
+ return true;
910
+ }
911
+ if (turnActive) {
912
+ socket?.ws.close();
913
+ entries = [
914
+ ...entries,
915
+ { kind: "note", text: "a turn started while attaching — staying in the current session" },
916
+ ];
917
+ rerender();
918
+ return true;
919
+ }
920
+ return false;
921
+ };
922
+ try {
923
+ socket = await openHubSocket(hubRef, inst.id, REMOTE_OPEN_TIMEOUT_MS);
924
+ const sock = socket;
925
+ if (racedTurn())
926
+ return;
927
+ const rapp = buildConnection(acp.ndJsonStream(Writable.toWeb(sock.duplex), Readable.toWeb(sock.duplex)));
928
+ const rcx = rapp.agent;
929
+ // The bridge answers only initialize until the handshake completes —
930
+ // same requirement as the local connection's startup.
931
+ await rcx.request("initialize", {
932
+ protocolVersion: 1,
933
+ clientCapabilities: {
934
+ fs: { readTextFile: false, writeTextFile: false },
935
+ elicitation: { form: {} },
936
+ },
937
+ clientInfo: { name: "zcode-acp", title: "zcode-acp REPL", version: AGENT_INFO.version },
938
+ });
939
+ if (racedTurn())
940
+ return;
941
+ // Attach BEFORE the load request goes out — same ordering constraint as
942
+ // the local resume, or every replayed update is dropped by the SDK's
943
+ // update router.
944
+ const loaded = rcx.attachSession({ sessionId: picked.sessionId });
945
+ // Loss detection: the hub or the owning bridge going away must not leave
946
+ // a frozen "live" view (or the pump spinning on a dead read).
947
+ sock.ws.once("close", () => {
948
+ // Recovery only when THIS socket is still the active attach. The
949
+ // generation is deliberately not consulted: if a second attach is
950
+ // still mid-flight, the first attach's session IS the live view, so
951
+ // its loss must recover; once another attach (or /new) owns the slot,
952
+ // the ws identity check is what makes this handler inert.
953
+ if (remote?.ws !== sock.ws)
954
+ return;
955
+ remote = null;
956
+ activeCx = null;
957
+ // The remote stream is dead — no stop or turnState will ever arrive,
958
+ // so any open turn UI is force-closed here rather than left spinning.
959
+ if (turnActive) {
960
+ entries = [
961
+ ...entries,
962
+ ...finishTurn(turn ?? createTurnState(), "remote connection lost"),
963
+ ];
964
+ turn = null;
965
+ turnActive = false;
966
+ promptInFlight = false;
967
+ }
968
+ entries = [
969
+ ...entries,
970
+ {
971
+ kind: "note",
972
+ text: "remote attach lost — starting a fresh local session (use /sessions to reload)",
973
+ },
974
+ ];
975
+ rerender();
976
+ void startFreshSession();
977
+ });
978
+ // Any previous attach is torn down first — its socket, its connection
979
+ // context, and its permission-handler slot all go with it.
980
+ closeRemoteAttach();
981
+ activeSession?.dispose();
982
+ activeSession = loaded;
983
+ activeCx = rcx;
984
+ remote = { ws: sock.ws, duplex: sock.duplex };
985
+ replayTurn = createTurnState();
986
+ replayMode = true;
987
+ loadSettled = false;
988
+ rerender();
989
+ let resp;
990
+ try {
991
+ resp = (await rcx.request("session/load", {
992
+ sessionId: picked.sessionId,
993
+ cwd: process.cwd(),
994
+ mcpServers: [],
995
+ // Tail replay (ADR-0003), same bound as the local resume.
996
+ _meta: { zcode: { limit: RESUME_TAIL_LIMIT } },
997
+ }));
998
+ }
999
+ finally {
1000
+ // The whole backlog is queued once the load resolves — without this
1001
+ // the pump waits for a quiet window that never opens. Generation
1002
+ // guarded: a stale load resolving after a newer swap (which owns the
1003
+ // flag now) must not end that swap's replay drain early.
1004
+ if (gen === swapGen)
1005
+ loadSettled = true;
1006
+ }
1007
+ if (gen !== swapGen)
1008
+ return; // stale — another swap owns the state now
1009
+ status = seedStatusFromNewSession(status, resp);
1010
+ const m = resp.replayMeta ?? null;
1011
+ // A turn already running at attach time (the mobile mid-turn — the main
1012
+ // scenario) never sends a start notification we could hear; this flag in
1013
+ // the load response is the only signal.
1014
+ if (m?.turnActive && !turnActive) {
1015
+ turn = createTurnState();
1016
+ turnActive = true;
1017
+ }
1018
+ const title = picked.title?.trim() || picked.sessionId.slice(0, 8);
1019
+ const truncated = m?.hasMore && typeof m.replayedMessages === "number" && typeof m.totalMessages === "number"
1020
+ ? ` — showing last ${m.replayedMessages} of ${m.totalMessages} messages`
1021
+ : "";
1022
+ entries = [
1023
+ ...entries,
1024
+ {
1025
+ kind: "note",
1026
+ text: `attached live "${title}" via hub (${inst.origin ?? "remote"} instance ${inst.id.slice(0, 8)})${truncated}`,
1027
+ },
1028
+ ];
1029
+ rerender();
1030
+ }
1031
+ catch (err) {
1032
+ const why = err instanceof Error ? err.message : String(err);
1033
+ if (gen !== swapGen)
1034
+ return; // another swap owns the state now
1035
+ try {
1036
+ socket?.ws.close();
1037
+ }
1038
+ catch {
1039
+ // ignore
1040
+ }
1041
+ // Only this attach's own state may be cleared here: a failure BEFORE
1042
+ // installation leaves the previous attach's socket live in `remote`,
1043
+ // and clearing it would orphan that ws (its permission fan-out would
1044
+ // keep reaching this REPL with nothing left to close it).
1045
+ if (socket && remote?.ws === socket.ws) {
1046
+ remote = null;
1047
+ activeCx = null;
1048
+ }
1049
+ entries = [
1050
+ ...entries,
1051
+ { kind: "note", text: `remote attach failed (${why}) — loading locally instead` },
1052
+ ];
1053
+ rerender();
1054
+ // Fall back to the plain local load. If the swap had already completed,
1055
+ // resumeInto disposes the (now stream-less) remote session itself; if it
1056
+ // hadn't, it disposes the previous session as any resume would.
1057
+ await resumeInto(picked);
1058
+ }
1059
+ }
764
1060
  /** Fresh welcome panel entry; reused by startup and `/new`. */
765
1061
  function welcomeEntry() {
766
1062
  return {
@@ -825,6 +1121,9 @@ export async function runRepl() {
825
1121
  rerender();
826
1122
  return;
827
1123
  }
1124
+ // `/new` means a fresh LOCAL session: a hub-proxied attach is torn down
1125
+ // first (client-side only — the owner keeps running).
1126
+ closeRemoteAttach();
828
1127
  activeSession?.dispose();
829
1128
  activeSession = fresh;
830
1129
  status = seedStatusFromNewSession(status, fresh.newSessionResponse);
@@ -896,6 +1195,14 @@ export async function runRepl() {
896
1195
  catch {
897
1196
  // ignore
898
1197
  }
1198
+ try {
1199
+ // Closing the proxied socket only detaches this client — the owning
1200
+ // serve bridge and its session are never touched from here.
1201
+ closeRemoteAttach();
1202
+ }
1203
+ catch {
1204
+ // ignore
1205
+ }
899
1206
  try {
900
1207
  ink.unmount();
901
1208
  }