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/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +57 -40
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/repl/App.d.ts.map +1 -1
- package/dist/repl/App.js +10 -1
- package/dist/repl/App.js.map +1 -1
- package/dist/repl/hub.d.ts +87 -0
- package/dist/repl/hub.d.ts.map +1 -0
- package/dist/repl/hub.js +188 -0
- package/dist/repl/hub.js.map +1 -0
- package/dist/repl/model.d.ts +16 -0
- package/dist/repl/model.d.ts.map +1 -1
- package/dist/repl/model.js +41 -0
- package/dist/repl/model.js.map +1 -1
- package/dist/repl/run.d.ts.map +1 -1
- package/dist/repl/run.js +399 -92
- package/dist/repl/run.js.map +1 -1
- package/docs/REMOTE-CLIENTS.md +15 -8
- package/docs/REMOTE.md +10 -0
- package/package.json +1 -1
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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),
|
|
645
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|