@cotal-ai/cli 0.137.0 → 0.138.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.
Files changed (57) hide show
  1. package/dist/chat/chat-view.d.ts +1 -1
  2. package/dist/chat/chat-view.d.ts.map +1 -1
  3. package/dist/chat/controller.d.ts +1 -1
  4. package/dist/chat/controller.d.ts.map +1 -1
  5. package/dist/chat/frontend.d.ts +1 -1
  6. package/dist/chat/frontend.d.ts.map +1 -1
  7. package/dist/chat/picker.d.ts +1 -1
  8. package/dist/chat/picker.d.ts.map +1 -1
  9. package/dist/chat/plain.d.ts +1 -1
  10. package/dist/chat/plain.d.ts.map +1 -1
  11. package/dist/chat/stdio-io.d.ts +1 -1
  12. package/dist/chat/stdio-io.d.ts.map +1 -1
  13. package/dist/chat/stdio.d.ts +1 -1
  14. package/dist/chat/stdio.d.ts.map +1 -1
  15. package/dist/chat/view.d.ts +1 -1
  16. package/dist/chat/view.d.ts.map +1 -1
  17. package/dist/commands/agents.d.ts +7 -149
  18. package/dist/commands/agents.d.ts.map +1 -1
  19. package/dist/commands/agents.js +15 -723
  20. package/dist/commands/agents.js.map +1 -1
  21. package/dist/commands/channels.js +1 -1
  22. package/dist/commands/channels.js.map +1 -1
  23. package/dist/commands/chat.d.ts +1 -24
  24. package/dist/commands/chat.d.ts.map +1 -1
  25. package/dist/commands/chat.js +3 -15
  26. package/dist/commands/chat.js.map +1 -1
  27. package/dist/console/app.js +1 -1
  28. package/dist/console/app.js.map +1 -1
  29. package/package.json +5 -6
  30. package/dist/chat/engine.d.ts +0 -111
  31. package/dist/chat/engine.d.ts.map +0 -1
  32. package/dist/chat/engine.js +0 -225
  33. package/dist/chat/engine.js.map +0 -1
  34. package/dist/chat/native-session.d.ts +0 -120
  35. package/dist/chat/native-session.d.ts.map +0 -1
  36. package/dist/chat/native-session.js +0 -487
  37. package/dist/chat/native-session.js.map +0 -1
  38. package/dist/chat/open-session.d.ts +0 -9
  39. package/dist/chat/open-session.d.ts.map +0 -1
  40. package/dist/chat/open-session.js +0 -120
  41. package/dist/chat/open-session.js.map +0 -1
  42. package/dist/chat/session.d.ts +0 -230
  43. package/dist/chat/session.d.ts.map +0 -1
  44. package/dist/chat/session.js +0 -2
  45. package/dist/chat/session.js.map +0 -1
  46. package/dist/chat/static-session.d.ts +0 -62
  47. package/dist/chat/static-session.d.ts.map +0 -1
  48. package/dist/chat/static-session.js +0 -236
  49. package/dist/chat/static-session.js.map +0 -1
  50. package/dist/chat/user-session.d.ts +0 -22
  51. package/dist/chat/user-session.d.ts.map +0 -1
  52. package/dist/chat/user-session.js +0 -180
  53. package/dist/chat/user-session.js.map +0 -1
  54. package/dist/lib/attach-client.d.ts +0 -112
  55. package/dist/lib/attach-client.d.ts.map +0 -1
  56. package/dist/lib/attach-client.js +0 -528
  57. package/dist/lib/attach-client.js.map +0 -1
@@ -1,19 +1,18 @@
1
- import { DEV_OWNER, assertLifecycleToken, dialerFor, mintCreds, newIdentity, openSessionRail, peerLabel, standaloneConnectOpts } from "@cotal-ai/core";
2
- import { codeSpan, CommandFailure, ConnectRefusal, divergentCwdAnchor, findMesh, isCommandFailure, isConnectRefusal, isWorkspaceTargetError, jsonFlag, localStackRoot, oneLine, renderReport, plainFlag, presentationOf, resolveSeatControlTarget, renderWorkspaceError, targetFlags, userSessionAuth, writeResult, recordOperationSpace, resolveControlTarget, } from "@cotal-ai/workspace";
1
+ import { DEV_OWNER, assertLifecycleToken, peerLabel } from "@cotal-ai/core";
2
+ import { codeSpan, CommandFailure, findMesh, isCommandFailure, isConnectRefusal, jsonFlag, localStackRoot, oneLine, renderReport, plainFlag, presentationOf, resolveSeatControlTarget, targetFlags, writeResult, recordOperationSpace, resolveControlTarget, } from "@cotal-ai/workspace";
3
3
  import * as prompts from "../lib/prompts.js";
4
4
  import { canPrompt } from "./meshes-wizard.js";
5
- import {} from "@nats-io/transport-node";
6
- import { c, presenceFacts } from "../ui.js";
5
+ import { presenceFacts } from "../ui.js";
7
6
  import { cliControl, onInstance, onFlag } from "../lib/control.js";
8
7
  import { AgentManagerUnsettled, managerPin, noLiveManager } from "../lib/manager-choice.js";
9
8
  import { openTransient } from "../lib/transient.js";
10
- import { attachClient, detachKey, holdTerminal, isDetachPress, isTransportEnd, meshSessionTransport } from "../lib/attach-client.js";
9
+ import { attachSeat as attachSeatWith, detachKey } from "@cotal-ai/workspace/attach";
11
10
  import { completingFlagValue } from "../lib/completion.js";
12
11
  import { displayCmd } from "../lib/self-exec.js";
13
12
  import { shellWord } from "../lib/endpoint-errors.js";
14
13
  import { runningStackManagerPid, stackManagerAbsent, stackManagerStarting } from "../lib/stack-journal.js";
15
14
  import { connectorHarnesses } from "../lib/status.js";
16
- import { askManager, scatterManager, managerFailure, readFailure, refuseUnregisteredManager, unansweredDescribeRefusal, managersByInstance, askYourManagers, locateSeat, managerReadFailure, refusedManagers, seatMissRefusal, seatNotFoundMessage } from "@cotal-ai/workspace/control";
15
+ import { askManager, scatterManager, managerFailure, readFailure, refuseUnregisteredManager, unansweredDescribeRefusal, managersByInstance, askYourManagers, locateSeat, managerReadFailure, refusedManagers, seatMissRefusal } from "@cotal-ai/workspace/control";
17
16
  /**
18
17
  * The manager's operator clients — thin control-plane request/reply commands (`stop`/`ps`/
19
18
  * `attach`) that drive a RUNNING manager. Moved from `@cotal-ai/manager` in stage 2a of the CLI
@@ -765,727 +764,20 @@ async function readYourManagers(v, command, managers) {
765
764
  throw new NoManagerAnswered(new CommandFailure("refused", "TARGET_UNAVAILABLE", `none of the ${managers.length} managers the exchange counts live answered`, { target: { space: space ?? null, scope: "your-managers" } }));
766
765
  return seatCensus({ space, scope: "your-managers" }, instances, command, mode);
767
766
  }
768
- /** Re-establishment backoff, in order; the last value is the cap and repeats forever. A seat that
769
- * exists is worth waiting for — the operator asked to be attached to it, and the alternative is
770
- * the terminal they walked away from being gone when they come back. */
771
- const RECONNECT_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
772
- /** How long any single wait on a LINK may take before the loop stops waiting on it. Neither
773
- * `flush()` nor `drain()` carries a deadline of its own, and `connect`'s `timeout` covers only the
774
- * dial: a link that half-opens AFTER connect (the exact slow death this command exists for) would
775
- * otherwise pin the loop, and the detach key with it, until the connection's own heartbeat gave up
776
- * around two minutes later. */
777
- const LINK_DEADLINE_MS = 5_000;
778
- /** Race a link round trip against a deadline, and answer `no` if the deadline wins. The timer is
779
- * deliberately NOT unref'd: it is the only thing keeping the process alive while we wait on a
780
- * socket that will never answer, and an unref'd one lets node empty its loop and abort the whole
781
- * command on a pending await instead of finishing the wait and printing why. It is cleared as soon
782
- * as either side settles, so a healthy link is never held open for the remainder of the deadline. */
783
- export const withDeadline = async (work, ms) => {
784
- let timer;
785
- const deadline = new Promise((r) => { timer = setTimeout(() => r(false), ms); });
786
- try {
787
- return await Promise.race([work, deadline]);
788
- }
789
- finally {
790
- clearTimeout(timer);
791
- }
792
- };
793
- /** Did a flush RETURN inside the deadline? That, and not the publish, is what says a frame left:
794
- * publishing is a local buffer write. A rejection and a timeout are the same answer here, `no`. */
795
- const flushed = (nc) => withDeadline(nc.flush().then(() => true, () => false), LINK_DEADLINE_MS);
796
- /** Give a connection back, bounded. Draining is another flush, so a link that has already missed
797
- * its deadline is closed outright instead of being asked to drain through the same dead socket.
798
- * A drain that REJECTS has closed nothing either: it gives up inside that same flush, before the
799
- * close it would have done, so a rejection means close it here or leak the socket. */
800
- const closeLink = async (nc) => {
801
- const drained = await withDeadline(nc.drain().then(() => true, () => false), LINK_DEADLINE_MS);
802
- // The close gets the same deadline as everything else on this path. Tearing a socket down is
803
- // local work and should not be able to wait at all, but "should not" is what the drain above was
804
- // supposed to be too, and a bound that covers every wait but one is a bound with a footnote.
805
- if (!drained)
806
- await withDeadline(nc.close().then(() => true, () => true), LINK_DEADLINE_MS);
807
- };
808
- /** A session that ran at least this long counts as one that WORKED, whether or not the seat spoke.
809
- * Below it, a link that comes up for the control round trip and dies again is a flap, and resetting
810
- * the backoff on those re-mints a grant, a credential and a hand-back connection every second. */
811
- const SESSION_WORKED_MS = 5_000;
812
- /** `inspect` found durable nonterminal state after the live seat disappeared. The manager owns the
813
- * detail shape; the CLI keys only on its namespaced discriminator so the manager's rich diagnostic
814
- * remains intact for operator commands while reconnect can act on the one fact it needs. */
815
- const STATIC_SLOT_OBSERVATION_DETAIL = "ai.cotal.manager.static-slot-observation";
816
- /** The manager's marker for an attach refused because the seat's runtime opens no terminal stream.
817
- * Same arrangement as the detail above: the manager owns the shape and its guidance wording, and
818
- * the CLI keys only on the namespaced discriminator. */
819
- const RUNTIME_NO_TERMINAL_DETAIL = "ai.cotal.manager.runtime-no-terminal";
820
- /**
821
- * Classify a refusal on the manager's structured error, never on its wording. The wording is
822
- * operator copy and changes; the code and namespaced detail are the contract. Getting this wrong in
823
- * either direction is a real failure: reading `permission-denied` as transient turns a refusal into
824
- * an infinite retry loop against a manager that has already said no, and reading `not-found` or an
825
- * `inspect` static-slot observation as transient does the same to a seat that no longer exists.
826
- * Other `failed-precondition` refusals remain transient, including the session ceiling.
827
- */
828
- export function attachRefusal(code, details) {
829
- if (code === "permission-denied")
830
- return "denied";
831
- if (code === "not-found")
832
- return "gone";
833
- // `unimplemented` is the manager saying this runtime does not do attach at all - a seat on an
834
- // external terminal multiplexer or IDE, or a control instance that spawns nothing. Asking again
835
- // cannot change it, so a reconnecting attach must stop rather than retry it forever.
836
- if (code === "unimplemented")
837
- return "denied";
838
- if (code === "failed-precondition" && details?.some((detail) => detail.kind === STATIC_SLOT_OBSERVATION_DETAIL))
839
- return "gone";
840
- return "transient";
841
- }
842
- /**
843
- * A re-establishment that threw, classified. A connect refusal that asking again cannot fix (a
844
- * missing seed, refused credentials, a user-auth mesh offered static creds) is `fatal`, so the loop
845
- * exits with the refusal's own sentence and hint instead of retrying it in silence. The control
846
- * resolver's mode peek rethrows a broken mesh record as a raw target error before the connect
847
- * helper can wrap it, and it only rethrows the codes that are not "no mesh found", so that is fatal
848
- * too. Anything else, including a broker that is not reachable yet, is transient.
849
- */
850
- class SeatAttachCallerUnavailable extends Error {
851
- constructor() { super("the launching seat could not re-establish its own caller; no operator identity was substituted"); }
852
- }
853
- function thrownEstablishment(e) {
854
- if (e instanceof SeatAttachCallerUnavailable)
855
- return { ok: false, kind: "fatal", message: e.message };
856
- if (e instanceof ConnectRefusal && e.kind === "permanent")
857
- return { ok: false, kind: "fatal", message: [e.rendered.replace(/^✗ /, ""), e.hint].filter(Boolean).join("\n") };
858
- if (isWorkspaceTargetError(e))
859
- return { ok: false, kind: "fatal", message: renderWorkspaceError({ kind: "target", error: e }).replace(/^✗ /, "") };
860
- return { ok: false, kind: "transient", message: e.message };
861
- }
862
- /**
863
- * What a re-establishment TELLS the operator while it keeps trying, and what it keeps to itself.
864
- * Returns the line to print, or undefined for silence.
865
- *
866
- * Only the MANAGER's own refusal is relayed, and only when it changes. An operator watching
867
- * `reconnecting` for ten minutes cannot otherwise tell a sleeping laptop from a manager that is at
868
- * its session ceiling, and the ceiling is the one a person can act on. Repeating a steady refusal
869
- * on every attempt would bury it.
870
- *
871
- * A LOCAL failure to reach anything is deliberately silent: it is already what `connection lost,
872
- * reconnecting` says, and the copy on this path is written for someone who just typed a command
873
- * ("no mesh running at X - run `cotal up`"), which is the wrong thing to tell someone whose wifi
874
- * dropped.
875
- */
876
- export function reconnectNotice(est, alreadySaid) {
877
- if (!est.fromManager || est.message === alreadySaid)
878
- return undefined;
879
- return `[cotal: ${est.message}]`;
880
- }
881
- /**
882
- * The line an exit owes an operator about sessions this attach could not hand back, or nothing.
883
- *
884
- * Suppressed on `gone`, and that is not tidiness. That verdict exists only when the manager proves
885
- * the live seat is absent: either `not-found`, or the namespaced durable static-slot observation
886
- * after its process is gone. Freeing a seat ends every session bound to it (`freeSlot` into
887
- * `endForTarget`, on despawn, self-stop, reap and natural exit alike). The sessions this would name
888
- * have already been collected, so naming them would be inventing work for someone who is about to
889
- * close their terminal.
890
- */
891
- export function heldSessionNotice(pending, verdict) {
892
- if (pending < 1 || verdict === "gone")
893
- return undefined;
894
- const what = pending === 1 ? "a session" : `${pending} sessions`;
895
- return `[cotal: the manager still holds ${what} from the lost link; each frees when the seat or the manager ends it]`;
896
- }
897
- /**
898
- * Ask the manager for a session and open it: a FRESH grant, a FRESH per-session caller credential,
899
- * a FRESH connection, every time. Nothing is carried over from a previous attempt, so a reconnect
900
- * re-runs the manager's whole authorization path (`serveGated` → `authorizeNamed` → a one-use
901
- * holder-bound grant with its own TTL) exactly as the first attach did. A revoked or expired grant
902
- * cannot be kept alive by reconnecting, because no grant is ever presented twice.
903
- */
904
- async function establishAttachSession(v, on, reconnect, firstAttempt) {
905
- // A reconnect must never cross a path that can END THE PROCESS. The mesh resolve and its
906
- // preflight are written to do exactly that ("no mesh running at X - run `cotal up`"), which is
907
- // the right answer for a person who just typed a command and the wrong one for a link that is
908
- // coming back. So a RE-ESTABLISHMENT asks for the throwing form, and the loop retries a refusal
909
- // whose kind is transient and stops on a permanent one.
910
- //
911
- // Keyed off `first`, NOT off the reconnect flag, and the difference is user-visible. A refusal
912
- // that escapes as an exception is rendered by the dispatcher's generic handler, which prints
913
- // `✗ ${message}` over a sentence that already opens with `✗` and drops the refusal's hint line.
914
- // The reconnect flag is on by DEFAULT, so keying on it sent the very first attach down that path
915
- // and turned today's "no mesh running at X - run `cotal up`" into a double mark with no remedy.
916
- // The first attempt is not a re-establishment and has nothing to survive for.
917
- //
918
- // `pinForTarget` is deliberately not crossed here: seat locality is resolved ONCE, before the
919
- // loop, and the pin is carried in. It is the other step on this path that exits.
920
- // Same reach routing as stop: static mesh → any-mode on the `control-caller-admin` instrument;
921
- // user mesh → the operator's own bearer with owner reach, the manager deciding (own owner-domain
922
- // passes with "spawn", cross-owner needs ledger "admin").
923
- // The console asks for the throwing form on the FIRST attempt as well (`onRefusal` on the
924
- // target): inside a TUI a printed sentence and a `process.exit` would blank the operator's screen
925
- // mid-session, and the refusal becomes a status-line notice instead. So "first" here means the
926
- // first attempt of a command that wants the exit.
927
- const first = firstAttempt && (v.onRefusal ?? "exit") === "exit";
928
- const seat = v.asSeat ? await resolveSeatControlTarget(v, on) : undefined;
929
- if (v.asSeat && !seat)
930
- throw new SeatAttachCallerUnavailable();
931
- const t = seat ?? await resolveControlTarget(v, "control-caller-admin", on, first ? {} : { onRefusal: "throw" });
932
- recordOperationSpace(t.space);
933
- const reach = t.auth.bearer ? "owner" : "any";
934
- // A refusal's report names the mesh in its heading, so this block names it once, in a field.
935
- if (first)
936
- console.error(renderReport({ sections: [{ title: "Attach", blocks: [{ kind: "fields", rows: [["Space", t.space], ["Agent", v.name], ["Manager", on ?? t.auth.managerInstanceId ?? "native selection"]] }] }] }, "rich", process.stderr));
937
- const reply = await askManager(cliControl, t.space, t.server, "attach", { name: v.name }, t.auth, reach, undefined, { instanceId: on, expectedTarget: v.expectedTarget });
938
- if (!reply.ok) {
939
- // The one refusal no attach can work around: this seat's runtime streams no terminal. It is
940
- // said once, with the runtime's own guidance for watching the agent where it really runs, and
941
- // the Next names what does reach the seat - never "inspect and reattach", which cannot succeed
942
- // here and is the advice this refusal exists to replace.
943
- const noTerminal = reply.details?.some((detail) => detail.kind === RUNTIME_NO_TERMINAL_DETAIL) === true;
944
- if (first && noTerminal)
945
- throw new CommandFailure("refused", "UNSUPPORTED", reply.error ? oneLine(reply.error) : "the seat's runtime streams no terminal, so there is none to attach", {
946
- target: { space: t.space, agent: v.name, managerInstanceId: reply.managerInstanceId ?? on ?? null },
947
- native: { code: reply.code, outcome: reply.nativeOutcome },
948
- remedy: `nothing was attached and the agent was not touched; follow the runtime's own guidance above, or reach the seat over the mesh with ${codeSpan(`${displayCmd()} send -t ${shellWord(v.name)} "<message>"`)} and ${codeSpan(`${displayCmd()} watch --space ${shellWord(t.space)}`)}`,
949
- });
950
- if (first && (reply.preSubmitRefusal || reply.nativeOutcome === "not-executed"))
951
- throw managerFailure(reply, "terminal attach", { space: t.space, agent: v.name, managerInstanceId: reply.managerInstanceId ?? on ?? null });
952
- return { ok: false, kind: attachRefusal(reply.code, reply.details), message: reply.error ?? "error", fromManager: true, code: reply.code, ...(noTerminal ? { noTerminal: true } : {}) };
953
- }
954
- // P2 item 6: the reply is the holder-bound §13.6 session GRANT (no ws:// URL). Redeem it over the
955
- // mesh. A static-auth mesh mints a per-session, rails-only caller cred from the resolved root's
956
- // seed. A registered open mesh has no seed: the same bare connection `askManagerEp` already used
957
- // for the control round trip opens the caller rail. USER mesh (bearer, no local seed): refuse LOUD
958
- // — the 2-step user-mode redemption callout is the #29 follow-up, deliberately not wired here.
959
- const { grant, resumedFrom } = reply.data;
960
- // The trust material the TARGET resolved, not a second answer walked up from the cwd. This used
961
- // to be `loadSpaceAuth(authDir(findCotalRoot()), t.space)`, which is the defect behind issue #722:
962
- // resolution had already picked a root from the registry and connected with it, and then this line
963
- // asked the current directory the same question and got a different answer. On any machine that is
964
- // not a hypothetical: `~/.cotal` exists because the registry lives there, `findCotalRoot` accepts
965
- // any directory merely NAMED `.cotal`, so every cwd under $HOME outside a project resolves here to
966
- // the home directory - whose trust chain for the same space differs from the resolved root's in
967
- // account, signing, sys and operator material. The result was a credential minted from a mesh the
968
- // broker never trusted, surfacing as a bare `Authorization Violation` that named nothing.
969
- // `control.ts` already stated the rule this restores: trust is carried forward rather than
970
- // re-loaded, because a second load is a second answer to "which space's seed" for one command.
971
- // A cwd anchor holding a DIFFERENT chain for this space is reported, never obeyed. It cannot
972
- // change what this command does any more - the seed above comes from the resolution, not the walk
973
- // - but staying silent is how issue #722 stayed a mystery: the operator had no way to learn that
974
- // the directory they were standing in carried another mesh's trust. Reported on the way past, so
975
- // it is said whether or not the attach then succeeds.
976
- const shadow = t.root === undefined ? undefined : divergentCwdAnchor(t.root, t.space);
977
- if (shadow)
978
- console.error(`! this directory resolves to ${shadow.cwdRoot}, whose .cotal/auth holds a DIFFERENT trust chain for space "${t.space}".\n` +
979
- ` attach used ${t.root}, the root this mesh resolved to. The other one is not being used, and is worth a look.`);
980
- const tls = t.auth.tls === true;
981
- const id = newIdentity();
982
- // WHICH CONTRACT redeems this grant is decided ONCE, by the recorded mesh mode, and the answer is
983
- // a value this function then carries. It is never re-derived from the absence of a seed: that
984
- // inference is issue #1205 itself, where an open mesh (which holds no seed BY DESIGN) was read as
985
- // a static mesh whose seed had gone missing, and every open attach was refused.
986
- const material = attachSessionMaterial(t);
987
- if (material.kind === "fatal")
988
- return { ok: false, kind: "fatal", message: material.message };
989
- let link;
990
- if (material.kind === "redeem") {
991
- // #2312, USER mesh: present this login's identity together with the grant to the identity
992
- // plane, which decides whether this principal holds the session and answers with a bearer the
993
- // callout turns into the SAME session-caller rows and grant expiry the static arm mints. No seed
994
- // is read or written, and nothing on this side decides who may hold the session.
995
- let auth;
996
- try {
997
- auth = await userSessionAuth(t, grant);
998
- }
999
- catch (e) {
1000
- return { ok: false, kind: "fatal", message: e instanceof Error ? e.message : String(e) };
1001
- }
1002
- link = { mode: "session-bearer", tls, ...auth };
1003
- }
1004
- else
1005
- link =
1006
- material.kind === "bare"
1007
- // An open mesh has no credential system: the same bare connection the control round trip
1008
- // already used opens the caller rail. Nothing is minted and nothing is invented.
1009
- ? { mode: "bare", tls }
1010
- : {
1011
- mode: "session-caller",
1012
- tls,
1013
- creds: await mintCreds(material.auth, id, "session-caller", {
1014
- sessionCaller: { endpoint: grant.endpoint, sessionId: grant.sessionId, epoch: grant.serving.epoch },
1015
- expiresAt: Math.floor(grant.exp / 1000), // grant.exp is ms (now+ttlMs); the JWT exp is seconds
1016
- }),
1017
- };
1018
- // maxReconnectAttempts is the whole difference between the two modes, and it is deliberate.
1019
- // ONE-SHOT keeps the old -1: the NATS layer redials forever under a single session.
1020
- // RECONNECTING uses 0, because that redial is what breaks the attach rather than saving it — the
1021
- // session is already dead by the time the link comes back (the serving rail either kept
1022
- // advancing `seq` into a subject nobody was subscribed to, so the restored subscription faults
1023
- // with `gap`, or it stalled out and closed while this side was away, so nothing ever arrives
1024
- // again). Owning re-establishment here means the link coming back produces a real session with a
1025
- // real repaint, instead of a restored socket over a session that ended without us.
1026
- const nc = await dialerFor(t.server)({
1027
- servers: t.server,
1028
- inboxPrefix: `_INBOX_${id.id}`,
1029
- // After the default: a bearer link's callout scopes the inbox on its own connect nonce.
1030
- ...redeemConnectOpts(link),
1031
- maxReconnectAttempts: reconnect ? 0 : -1,
1032
- // Detection latency is part of the defect, not a detail of it. A laptop waking from sleep does
1033
- // not always get a socket error: the connection can sit half-open, and on the stock two-minute
1034
- // ping with two misses the client would not learn its link was dead for about four minutes,
1035
- // with the terminal frozen the whole time. Ten seconds puts that in tens of seconds. Only on
1036
- // the reconnecting path, so `--no-reconnect` keeps today's timing along with today's exit.
1037
- //
1038
- // NOT MEASURED by this change's suite, and said so rather than implied: the smoke severs the
1039
- // link by destroying sockets, so every number it prints is close detection. A fault model that
1040
- // half-opens a link is what would grade this line, and it does not exist here yet.
1041
- ...(reconnect ? { pingInterval: 10_000 } : {}),
1042
- });
1043
- return { ok: true, nc, grant, link, inbox: id.id, server: t.server, ...(resumedFrom ? { resumedFrom } : {}) };
1044
- }
1045
- /** The ONE place a {@link RedeemLink} becomes NATS connect options. Both connections on the redeem
1046
- * path (the session link and the abandoned-session hand-back) go through here, so neither can
1047
- * invent a credential for an open mesh or drop one on a sealed mesh, and a third caller gets the
1048
- * same two arms for free. */
1049
- export function redeemConnectOpts(link) {
1050
- if (link.mode === "session-bearer")
1051
- return standaloneConnectOpts({ bearer: link.bearer, sentinelCreds: link.sentinelCreds, tls: link.tls });
1052
- return link.mode === "bare"
1053
- ? standaloneConnectOpts({ tls: link.tls })
1054
- : standaloneConnectOpts({ creds: link.creds, tls: link.tls });
1055
- }
1056
- /**
1057
- * Which redeem contract this target gets: a bare open-mesh connection, a seed-backed mint, or a
1058
- * loud refusal. Graded by `smoke:attach-open-mode`, whose mutations flip each arm in turn.
1059
- *
1060
- * EXHAUSTIVE over the three recorded modes on purpose, with `user` named rather than reached by
1061
- * fallthrough. A `user` entry holds no local seed by design and its two-step redemption is not
1062
- * wired, so it must refuse even on the day a user-mode root happens to carry static material on
1063
- * disk; letting it fall through to the seed test would silently mint on the wrong identity plane.
1064
- * An UNRECORDED mode (a raw off-registry connection) is the conservative arm: it is not known to be
1065
- * open, so it takes the sealed test and refuses without a seed.
1066
- */
1067
- export function attachSessionMaterial(t) {
1068
- switch (t.mode) {
1069
- // An OPEN mesh: no credential system at all, so there is no seed to be missing and nothing to
1070
- // mint. This is the arm issue #1205 did not have.
1071
- case "open":
1072
- return { kind: "bare" };
1073
- // A USER mesh: the identity plane redeems the grant for the bearer (#2312), EVEN IF a seed is
1074
- // present, because the bearer plane is the control surface there and static material would be
1075
- // the wrong identity. `spaceAuth` is never consulted on this arm.
1076
- case "user":
1077
- return { kind: "redeem" };
1078
- // SEALED (`auth`) and unrecorded: the seed is required, and its absence is a refusal. A sealed
1079
- // mesh must never be rescued by the open arm — that is the failure a fix for #1205 is most
1080
- // likely to introduce, and `smoke:attach-open-mode` asserts the refusal by name against a live
1081
- // sealed broker.
1082
- default:
1083
- return t.spaceAuth ? { kind: "mint", auth: t.spaceAuth } : { kind: "fatal", message: attachNoSeedMessage(t) };
1084
- }
1085
- }
1086
- /** Why attach cannot redeem a session grant, said in terms of what the command actually resolved.
1087
- *
1088
- * Three distinct states, kept distinct because the remedy differs and a single sentence covering
1089
- * all three is the defect issue #722 opened on (its old text named an internal work item and no
1090
- * root at all). A USER-AUTH mesh holds no local seed by design. A REGISTERED static mesh has a
1091
- * root and it is named, so an operator can see which directory the command used rather than
1092
- * guessing at their cwd.
1093
- *
1094
- * The third arm is an INVARIANT, not advice, and is written that way on purpose. A connection with
1095
- * no resolved root is what the type allows, and no supported route reaches redemption in that
1096
- * state: both off-registry routes are refused earlier, which `smoke:attach-auth-root` measures
1097
- * rather than assumes. So that arm says what its own existence would mean instead of offering a
1098
- * remedy for a situation that cannot currently arise. */
1099
- function attachNoSeedMessage(t) {
1100
- const shadow = t.root === undefined ? undefined : divergentCwdAnchor(t.root, t.space);
1101
- const shadowLine = shadow
1102
- ? `\n NOTE this directory resolves to ${shadow.cwdRoot}, which holds a DIFFERENT trust chain for "${t.space}"; it was NOT used.`
1103
- : "";
1104
- const head = `mesh attach needs this space's local seed to redeem the session grant, and the mesh resolved for "${t.space}" does not provide one.`;
1105
- if (t.root === undefined)
1106
- return `${head}\n broker ${t.server}\n This connection resolved NO checkout root, and no supported route reaches redemption in that state: an off-registry \`--server\` on an unregistered space is refused for missing credentials, and \`--creds\` is refused at the control surface, both before a session grant is asked for. Reaching this sentence means a route now exists that skips both refusals.${shadowLine}`;
1107
- return `${head}\n broker ${t.server}\n resolved root ${t.root}\n This is a static-auth mesh. Attach still needs this space's seed under ${t.root}/.cotal/auth. Restore the seed at that checkout. This mesh is not open, so attach will not connect without the seed.${shadowLine}`;
1108
- }
1109
- /**
1110
- * Tell the manager a session is over, so it gets its slot back.
1111
- *
1112
- * NOTHING on the serving side reaps a session whose caller went away while the seat is quiet: the
1113
- * rail's stall watchdog only arms once the send window FILLS (`endpoint-session-rail.ts`), an idle
1114
- * seat never fills it, and the bridge has no expiry timer of its own. Measured against a manager
1115
- * with an idle seat: 45s of dead link left the live-session count at 1, and the reconnect took it
1116
- * to 2 — one slot per outage, held until the seat or the manager ends it, against a ceiling of 64.
1117
- *
1118
- * The mechanism is the advisory close frame the rail already defines; the manager's bridge ends on
1119
- * it. On a static-auth mesh it has to be published with THIS session's caller credential, the only
1120
- * one scoped to this session's subjects, so a re-establishment cannot send it on the abandoned
1121
- * session's behalf. On a registered open mesh there is no session-caller seed: the same bare
1122
- * connection the session used hands the slot back. Hence a short-lived connection minted from the
1123
- * abandoned session's own material, never an invented seed.
1124
- */
1125
- async function releaseAbandonedSession(s) {
1126
- const nc = await dialerFor(s.server)({
1127
- servers: s.server,
1128
- inboxPrefix: `_INBOX_${s.inbox}`,
1129
- ...redeemConnectOpts(s.link),
1130
- maxReconnectAttempts: 0,
1131
- timeout: LINK_DEADLINE_MS,
1132
- // The same short ping the reconnecting session uses. This connection exists BECAUSE a link died,
1133
- // so it is the last one that should be left waiting on the stock two-minute heartbeat to find
1134
- // out that the replacement died too.
1135
- pingInterval: 10_000,
1136
- });
1137
- try {
1138
- // The rail is opened only because `close()` is the public way to speak the framing; no timers,
1139
- // and it tears itself down on the same call.
1140
- openSessionRail({ nc, grant: s.grant, role: "caller", onData: () => { }, idleCreditMs: 0, stallTimeoutMs: 0 }).close();
1141
- // Throwing is what keeps the record: the caller holds the session until a flush RETURNS.
1142
- if (!(await flushed(nc)))
1143
- throw new Error(`the close frame for session ${s.grant.sessionId} never left`);
1144
- }
1145
- finally {
1146
- await closeLink(nc);
1147
- }
1148
- }
1149
- /** Watch stdin for the detach key while there is NO session reading it. Without this the key is
1150
- * dead for as long as a reconnect takes, which is exactly when an operator is most likely to give
1151
- * up and press it. Non-detach keystrokes are dropped: there is no seat to send them to, and
1152
- * buffering them would replay a burst into the agent on reconnect. */
1153
- function watchDetachKey(byte) {
1154
- const stdin = process.stdin;
1155
- let fire;
1156
- let pressedYet = false;
1157
- const pressed = new Promise((r) => { fire = r; });
1158
- const hit = () => { pressedYet = true; fire(); };
1159
- // The whole chunk must be exactly one press of the detach key: the legacy control byte, or the
1160
- // kitty keyboard protocol / xterm modifyOtherKeys encoding of the same press (see
1161
- // `isDetachPress`, #598). A chunk carrying it alongside anything else is NOT a keypress:
1162
- // measured on a pty, a real keypress arrives in a read of its own even at 3ms spacing, and the
1163
- // only two ways the byte arrives with company are a paste and a reader that was not reading.
1164
- // Treating a paste that happens to contain the byte (or the encoded sequence) as a detach would
1165
- // turn data into a control action on input nobody typed; the reader that was not reading is the
1166
- // defect this watcher's new lifetime fixes, not a matching problem.
1167
- // Under the console, Ink has called `stdin.setEncoding("utf8")`, and an encoding persists on the
1168
- // stream after Ink releases raw mode, so data arrives as a STRING there (the standalone command
1169
- // gets Buffers). Normalize first: a one-character string is not the number 0x1d, and the compare
1170
- // below would otherwise never match, making the key dead for as long as a reconnect takes.
1171
- const onData = (d) => { if (isDetachPress(d, byte))
1172
- hit(); };
1173
- const onChunk = (data) => onData(Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8"));
1174
- stdin.on("data", onChunk);
1175
- stdin.resume();
1176
- // `pause: false` hands the stream to a session's own reader, which resumes it synchronously right
1177
- // after; pausing there and resuming a line later would be a window where a byte has no owner,
1178
- // which is the whole defect.
1179
- // `hit` as well as `pressed`, because the press has TWO consumers and one of them cannot await:
1180
- // the handoff to a session's reader is synchronous, and it has to know whether the byte it is
1181
- // taking the stream over from has already arrived.
1182
- return { pressed, hit: () => pressedYet, stop: ({ pause = true } = {}) => { stdin.off("data", onChunk); if (pause)
1183
- stdin.pause(); } };
767
+ /** The remedy for a seat whose runtime streams no terminal, naming what does reach it in this
768
+ * CLI's own commands. */
769
+ function noTerminalRemedy({ space, name }) {
770
+ return `nothing was attached and the agent was not touched; follow the runtime's own guidance above, or reach the seat over the mesh with ${codeSpan(`${displayCmd()} send -t ${shellWord(name)} "<message>"`)} and ${codeSpan(`${displayCmd()} watch --space ${shellWord(space)}`)}`;
1184
771
  }
1185
772
  /**
1186
- * The attach loop. One session at a time; when the LINK breaks and the operator did not detach, it
1187
- * re-establishes with backoff and keeps going, because the seat is still there and the terminal the
1188
- * operator walked away from should still be theirs when they come back.
1189
- */
1190
- async function runAttachLoop(vv, pin, reconnect, key, hold) {
1191
- let first = true;
1192
- let attempt = 0; // consecutive re-establishment attempts since the last live session
1193
- // Sessions the manager still counts, waiting to be told. A LIST rather than one slot: a second
1194
- // link death before the first hand-back lands would overwrite the first and leak it with nothing
1195
- // said. It cannot grow without bound, because every entry needed a successful establishment, and
1196
- // successful establishments are exactly what the manager's own session ceiling counts.
1197
- const abandoned = [];
1198
- let saidWhy = ""; // the last transient refusal printed, so a steady reason prints once, not per attempt
1199
- // ONE reader owns stdin for the whole time there is no session: the backoff wait, the round trip
1200
- // that hands the old session back and opens the new one, and the gap in between. It used to exist
1201
- // only for the waits, which left the establishment unread: `stdin` was paused, the stream
1202
- // buffered, and `attachClient`'s `stdin.resume()` flushed whatever had been typed at a terminal
1203
- // with no session straight into the seat's pty. Measured on the code before this change, with the
1204
- // bytes graded at the SEAT rather than on screen: typed during a wait, dropped; during an attempt
1205
- // that failed, dropped; during an attempt that SUCCEEDED, delivered, and a 0x03 typed there
1206
- // arrived as SIGINT at the agent and killed it (the seat recorded the signal and its pid was
1207
- // gone). With the reader installed for the whole period those bytes are read and dropped, which
1208
- // is what the loop always claimed to do, and the detach key works during an attempt as well as
1209
- // during a wait.
1210
- let idle;
1211
- // A TERMINAL only, and the gate lives HERE rather than at the call sites, because there are three
1212
- // of them and a gate written at one is a promise the other two break silently. `printf 'ls\n' |
1213
- // cotal attach` writes into a PIPE, and those bytes are a script's input rather than an operator
1214
- // giving up on a frozen screen: a reader would eat them with no fault anywhere in sight. That is
1215
- // true in EVERY window, not only before the first session, so a pipe keeps the old contract
1216
- // throughout, paused between sessions and flushed into the next one. Measured with the gate at the
1217
- // first call site alone: a piped attach buffered correctly until its first reconnect and then had
1218
- // the reader installed on it by the backoff, so `tail -f log | cotal attach` lost its feed across a
1219
- // link blip. Cells J and I are the first-session halves, cell L is the reconnect one.
1220
- const ownStdin = () => (process.stdin.isTTY ? (idle ??= watchDetachKey(key.byte)) : undefined);
1221
- // Returns whether the detach key had ALREADY been pressed, which is the one thing the session's
1222
- // own reader cannot find out for itself once this reader is gone.
1223
- const releaseStdin = (opts) => {
1224
- const pressed = idle?.hit() ?? false;
1225
- idle?.stop(opts);
1226
- idle = undefined;
1227
- return pressed;
1228
- };
1229
- // Give the manager its slots back. Oldest first, and one failure ends the round rather than
1230
- // skipping ahead, because the reason is the link and the link is the same for all of them.
1231
- const releaseAbandoned = async () => {
1232
- while (abandoned.length > 0) {
1233
- try {
1234
- await releaseAbandonedSession(abandoned[0]);
1235
- abandoned.shift();
1236
- }
1237
- catch {
1238
- break;
1239
- }
1240
- }
1241
- };
1242
- // Every exit runs through here, so the one thing an operator cannot see for themselves is never
1243
- // swallowed. It TRIES first: an operator who detaches during the backoff may well be doing it
1244
- // because the link came back, and returning straight out would leave a slot held that one dial
1245
- // would have freed. The attempt is bounded exactly like every other, and what it could not
1246
- // deliver is what gets said. What to say, and when to say nothing, is `heldSessionNotice`.
1247
- const done = async (v) => {
1248
- // Give the stream back on the way out, on EVERY exit, because this reader resumed it: a resumed
1249
- // stdin is a ref'd handle, and one left behind holds the command open after it has said it
1250
- // detached. That is the same shape as the abandoned backoff timer, and it is why this lives
1251
- // here rather than at the call sites.
1252
- releaseStdin();
1253
- // And drop the process's own claim on the stream, which a PIPE keeps even after a pause. This is
1254
- // the exit path by definition, so nothing here reads stdin again. MEASURED, on a piped attach
1255
- // with the reader correctly not installed: the detach was honoured, the manager freed the slot,
1256
- // `detached from` printed, and the process then sat past 30s with its three stdio pipes as the
1257
- // only resources keeping the loop alive. A pause was not enough on its own.
1258
- //
1259
- // ONLY THE SOCKET KINDS HAVE `unref`, and this is a test of the stream rather than a fallback.
1260
- // Measured: at a terminal stdin is a `tty.ReadStream` and at a pipe a `net.Socket`, both of which
1261
- // have it; given a FILE (`cotal attach < seed.txt`) or a parent that spawns with stdio "ignore",
1262
- // stdin is an `fs.ReadStream` and has no `unref` at all. Calling it there is a TypeError, which
1263
- // is how `smoke:cli-on-instance` caught this: it spawns attach with stdin ignored, and the
1264
- // unguarded call turned a pinned-rail deadline into `process.stdin.unref is not a function`. The
1265
- // same measurement says the other branch needs nothing: a file-backed stdin ends at EOF instead
1266
- // of staying open like a socket, and a process that takes one and releases it exits at once with
1267
- // an empty resource list, where the pipe leaves a PipeWrap behind. So the release exists exactly
1268
- // where there is something to release. Cell N of `smoke:attach-stdin` is the file-backed route.
1269
- if (typeof process.stdin.unref === "function")
1270
- process.stdin.unref();
1271
- if (v.kind !== "gone")
1272
- await releaseAbandoned();
1273
- const notice = heldSessionNotice(abandoned.length, v.kind);
1274
- if (notice)
1275
- console.error(c.dim(notice));
1276
- return v;
1277
- };
1278
- // The FIRST establishment is an attach with no session too, and it is the path every attach takes.
1279
- // Own the keyboard from here rather than from the first reconnect, so a key struck while the
1280
- // command is still resolving the mesh is read and dropped instead of buffered and flushed into the
1281
- // seat when the session opens. Measured before this line existed: a nonce typed at that prompt
1282
- // arrived at the agent.
1283
- //
1284
- // `ownStdin` is a no-op at a pipe; both halves are cells (I and J), so neither is an assumption.
1285
- if (reconnect)
1286
- ownStdin();
1287
- for (;;) {
1288
- if (!first) {
1289
- // Back off BEFORE the attempt, and stay interruptible: the detach key must work while we
1290
- // wait, not only while a session is up. The timer is CLEARED rather than left to the race,
1291
- // because losing a `Promise.race` does not stop a `setTimeout`: the detach is honoured at
1292
- // once, the terminal comes back and `detached from` prints, and then node holds the process
1293
- // open until the abandoned timer fires. On the 30s rung that is half a minute of a shell that
1294
- // has already said it detached and will not give the prompt back. Measured before this line
1295
- // existed: press to first output 0.1s, press to EXIT 27.0s with the next attempt 26.9s away,
1296
- // and 8.3s with it 8.1s away, tracking the rung rather than the work.
1297
- const ms = RECONNECT_BACKOFF_MS[Math.min(attempt, RECONNECT_BACKOFF_MS.length - 1)];
1298
- const watch = ownStdin(); // and it STAYS installed through the attempt this wait precedes
1299
- let timer;
1300
- const waited = new Promise((r) => { timer = setTimeout(() => r(false), ms); });
1301
- // No watcher at a pipe, and nothing to replace it with: a detach byte a script writes here
1302
- // buffers and reaches the next session's own reader, which is exactly what it did before this
1303
- // change. Racing a promise that can never settle would be the same thing written longer.
1304
- const detached = watch ? await Promise.race([waited, watch.pressed.then(() => true)]) : await waited;
1305
- clearTimeout(timer);
1306
- if (detached)
1307
- return await done({ kind: "ended", reason: "detached" });
1308
- attempt++;
1309
- }
1310
- // Give the manager its slots back BEFORE claiming another one. The order is load-bearing at the
1311
- // ceiling: a client that claims first is refused by the very sessions it abandoned. A failure
1312
- // is retried on the next attempt and accounted for by `done` if it never lands.
1313
- await releaseAbandoned();
1314
- let est;
1315
- const attempting = establishAttachSession(vv, pin, reconnect, first);
1316
- // A press DURING the round trip ends the attach now, rather than when the round trip returns.
1317
- // The in-flight establishment is still awaited: it is bounded like every other one, and a
1318
- // session that lands after we have stopped looking is a slot the manager still counts, so it
1319
- // goes on the abandoned list and `done` hands it back over the link that is by then up.
1320
- //
1321
- // `idle` is also set on the FIRST establishment at a terminal, where what it buys is the same
1322
- // thing it buys everywhere else: what gets typed at a prompt that has not come up yet is read
1323
- // and dropped rather than delivered later, which is what cell I grades. It is not a second way
1324
- // to abort. Raw mode is entered in `attachClient`'s `onReady` (attach-client.ts), so before the
1325
- // first session this process has not touched the terminal's mode at all, and Ctrl-C at a cooked
1326
- // tty is how an operator leaves a command that has not attached.
1327
- if (idle) {
1328
- const raced = await Promise.race([
1329
- attempting.then((e) => ({ est: e }), () => ({ est: undefined })),
1330
- idle.pressed.then(() => ({ detached: true })),
1331
- ]);
1332
- if ("detached" in raced) {
1333
- releaseStdin();
1334
- const late = await attempting.catch(() => undefined);
1335
- if (late?.ok) {
1336
- abandoned.push({ grant: late.grant, link: late.link, inbox: late.inbox, server: late.server });
1337
- // And CLOSE the link that session came up on. Nothing here will ever read it, and an open
1338
- // NATS connection is a ref'd handle: measured, the attach printed `detached from` and then
1339
- // sat there forever, because the hand-back mints its own short-lived connection from the
1340
- // abandoned session's own credential and never touches this one. Same shape as the
1341
- // abandoned backoff timer, one layer down.
1342
- await closeLink(late.nc);
1343
- }
1344
- return await done({ kind: "ended", reason: "detached" });
1345
- }
1346
- }
1347
- try {
1348
- est = await attempting;
1349
- }
1350
- catch (e) {
1351
- // The FIRST attempt throws exactly as it always has — the CLI's top-level handler renders
1352
- // it. Only a reconnect turns a thrown establishment into another attempt. Give the stream
1353
- // back on the way out: this is the one exit from this loop that does not run through `done`,
1354
- // and a resumed stdin is a ref'd handle that would hold the command open after it has already
1355
- // printed why it is giving up.
1356
- if (first) {
1357
- releaseStdin();
1358
- throw e;
1359
- }
1360
- est = thrownEstablishment(e);
1361
- }
1362
- if (!est.ok) {
1363
- // Nothing changes for the first attach: any refusal is the same loud exit as before.
1364
- if (first || est.kind === "fatal" || est.kind === "denied")
1365
- return await done({ kind: "failed", message: est.message, ...(est.fromManager ? { refusal: { code: est.code, noTerminal: est.noTerminal === true } } : {}) });
1366
- if (est.kind === "gone")
1367
- return await done({ kind: "gone" });
1368
- const notice = reconnectNotice(est, saidWhy);
1369
- if (notice) {
1370
- saidWhy = est.message;
1371
- console.error(c.dim(notice));
1372
- }
1373
- continue; // transient: the manager or the link is unreachable right now
1374
- }
1375
- saidWhy = "";
1376
- const from = est.resumedFrom ? ` (resumed from ${est.resumedFrom.host}:${est.resumedFrom.source})` : "";
1377
- if (first)
1378
- console.error(c.dim(`attached to ${vv.name}${from} - ${key.label} to detach`));
1379
- else
1380
- console.error(c.dim("[cotal: reconnected]"));
1381
- first = false;
1382
- let outcome;
1383
- const startedAt = Date.now();
1384
- const transport = meshSessionTransport(est.nc, est.grant);
1385
- try {
1386
- // The manager replays its byte-exact backlog snapshot on every open (the `ready` handshake
1387
- // in session/bridge.ts), so the reconnected screen repaints through the path that already
1388
- // exists; there is no second backlog here.
1389
- outcome = await attachClient(transport, hold, () => releaseStdin({ pause: false }));
1390
- }
1391
- finally {
1392
- // Take the stream back FIRST, ahead of the hand-back's own round trips. A session that ends
1393
- // while its SOCKET is still alive is the case this whole change is about, and everything
1394
- // below it here is a publish, a flush and a close, each bounded by LINK_DEADLINE_MS: placed
1395
- // after them, this line would leave the keyboard unowned for as long as a dying link takes
1396
- // to answer, which is precisely when an operator reaches for the detach key. attachClient's
1397
- // cleanup has already removed its own reader and paused the stream, so the ownerless gap is
1398
- // the width of this statement rather than of two deadlines. `done` releases it again on
1399
- // whichever exit follows, and the backoff wait re-installs the same reader.
1400
- if (reconnect)
1401
- ownStdin();
1402
- // Hand the session back. With the link still up that is one advisory frame over the
1403
- // connection already open (a rail that broke while the socket lived — a stall, a gap — is
1404
- // exactly this case, and it is idempotent after a detach has already closed it). The FLUSH is
1405
- // what decides whether it left, not the publish: a link can die between the closed-check and
1406
- // the frame, which is the same slow death this whole change is about. Only a flush that
1407
- // returned counts as handed back; anything else keeps the credential for the next attempt,
1408
- // and see releaseAbandonedSession for why no other credential can carry it.
1409
- let handedBack = false;
1410
- if (!est.nc.isClosed()) {
1411
- transport.close();
1412
- handedBack = await flushed(est.nc);
1413
- }
1414
- if (!handedBack && reconnect) {
1415
- abandoned.push({ grant: est.grant, link: est.link, inbox: est.inbox, server: est.server });
1416
- }
1417
- await closeLink(est.nc);
1418
- }
1419
- // The backoff resets on a session that WORKED, not on one that merely opened. `carried` is not
1420
- // enough on its own: a seat that has produced nothing has an empty backlog snapshot, so a
1421
- // perfectly healthy attach to a QUIET agent would never reset and would climb to the 30s cap
1422
- // over a few blips, which is the seat this whole change cares most about. A session that simply
1423
- // lasted is the other half of the same question.
1424
- if (outcome.carried || Date.now() - startedAt >= SESSION_WORKED_MS)
1425
- attempt = 0;
1426
- if (!reconnect) {
1427
- // One-shot: a faulted session still throws, so `--no-reconnect` exits the way it does today.
1428
- if (outcome.error)
1429
- throw outcome.error;
1430
- return await done({ kind: "ended", reason: outcome.reason });
1431
- }
1432
- if (!isTransportEnd(outcome.reason)) {
1433
- // A faulted end this classification does not recognise is NOT a detach. Printing
1434
- // `detached from` over it would be the silent swallow this whole change exists to remove,
1435
- // so it exits non-zero carrying the fault. Every `fireEnd` site passes a reason today, so
1436
- // this is a guard against a future one that forgets, not a live path.
1437
- if (outcome.error)
1438
- return await done({ kind: "failed", message: outcome.error.message });
1439
- return await done({ kind: "ended", reason: outcome.reason });
1440
- }
1441
- console.error(c.dim("[cotal: connection lost, reconnecting]"));
1442
- // Raw from here on, whether or not the session that just ended reached ready. A first session
1443
- // whose link died inside its opening flush never ran `onReady`, so the terminal is still cooked:
1444
- // the line discipline would echo the detach key as `^]` and hold it until Enter, and the reader
1445
- // below would never see it (#1471). The attach has already said it is attached, so Ctrl-C at a
1446
- // cooked tty is no longer the way out; the detach key is.
1447
- hold.enterRaw();
1448
- }
1449
- }
1450
- /**
1451
- * The whole attach, as one call: seat locality, the establish-redeem-drive loop with its reconnect,
1452
- * end-reason classification and abandoned-session hand-back, and the terminal given back before
1453
- * the verdict is returned. `cotal attach` and the console's `a` / `:attach` both run THIS, so the
1454
- * console can never drift into a lesser attach. `onRefusal` chooses what an unresolvable mesh or
1455
- * an absent seat does: the command prints and exits; the console needs a returned verdict, because
1456
- * an exit inside a TUI blanks the screen. The pin, when the caller already knows it (`--on`), is
1457
- * honoured; otherwise the seat is located the way `stop` locates it.
773
+ * The attach `cotal attach`, the console and spawn-then-attach run: the shared attach from
774
+ * `@cotal-ai/workspace/attach`, with this CLI's control client, verb and remedy. On the command path
775
+ * (`exit`) with no pin given, the seat is located here first, before the detach key is read and the
776
+ * terminal is held, because that step prints and exits in the CLI's words.
1458
777
  */
1459
778
  export async function attachSeat(v, opts) {
1460
- let on = opts.on;
1461
- if (on === undefined) {
1462
- if (opts.onRefusal === "exit")
1463
- on = await pinForTarget(v, "cotal attach");
1464
- else {
1465
- const loc = await locateSeat(cliControl, v, v.name, { onRefusal: "throw" });
1466
- if (loc.kind === "absent")
1467
- return { kind: "failed", message: seatNotFoundMessage(loc, v.name) };
1468
- if (loc.kind === "several")
1469
- return { kind: "failed", message: seatMissRefusal(v.name, "cotal attach", loc) };
1470
- if (loc.kind === "pin")
1471
- on = loc.instanceId;
1472
- }
1473
- }
1474
- const detach = detachKey();
1475
- const hold = holdTerminal();
1476
- try {
1477
- return await runAttachLoop({ ...v, onRefusal: opts.onRefusal }, on, opts.reconnect, detach, hold);
1478
- }
1479
- catch (e) {
1480
- if (opts.onRefusal === "exit")
1481
- throw e;
1482
- return { kind: "failed", message: e.message };
1483
- }
1484
- finally {
1485
- // The terminal comes back before anything else happens, whatever ended the attach — including
1486
- // a throw out of the one-shot path, which is how `--no-reconnect` keeps its old exit.
1487
- hold.restore();
1488
- }
779
+ const on = opts.on === undefined && opts.onRefusal === "exit" ? await pinForTarget(v, "cotal attach") : opts.on;
780
+ return attachSeatWith(v, { ...opts, on, control: cliControl, verb: "cotal attach", noTerminalRemedy });
1489
781
  }
1490
782
  async function pickAttach(values) {
1491
783
  const census = await readSeats(values, "ps", await managerPin(values, "cotal attach"));