@sagentlab/navarch-runtime 0.1.112 → 0.1.117
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/README.md +56 -18
- package/bin/muse-mcp-bridge.cjs +3 -0
- package/dist/adapters/index.cjs +6 -1
- package/dist/adapters/muse.cjs +433 -0
- package/dist/agent-auth.cjs +2 -1
- package/dist/cli.cjs +4 -1
- package/dist/config.cjs +9 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/dist/worktree-guard.cjs +137 -3
- package/node_modules/@muse-code/sdk/LICENSE +21 -0
- package/node_modules/@muse-code/sdk/README.md +88 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
- package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
- package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
- package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
- package/node_modules/@muse-code/sdk/package.json +47 -0
- package/package.json +8 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MuseClient` — the SS7.1 facade's session-opening half (FR-018, T030).
|
|
3
|
+
*
|
|
4
|
+
* It turns the two v1 session verbs into correctly-shaped `session/start` /
|
|
5
|
+
* `session/resume` params and hands back a WIRED `Session`. It frames nothing
|
|
6
|
+
* and mints nothing: `Connection.command()` stamps the `commandId` from the
|
|
7
|
+
* single injected mint, so INV-013's single-minter property is preserved by
|
|
8
|
+
* DELEGATION rather than re-implemented here.
|
|
9
|
+
*
|
|
10
|
+
* IT OWNS TWO FACTS NO OTHER LAYER HOLDS, and that is why obligations (b) and
|
|
11
|
+
* (c) of T030 live here rather than on `Session`:
|
|
12
|
+
*
|
|
13
|
+
* - WHETHER A CLOSE WAS OURS. `Connection` is duplex-generic; a transport
|
|
14
|
+
* cannot tell a peer's hang-up from its own shutdown. SS2.13.3b counts
|
|
15
|
+
* "process exit **or transport EOF**" as a death, so somebody has to know
|
|
16
|
+
* the difference, and only the layer that owns `close()` does.
|
|
17
|
+
* - WHAT AN EARLIER DEATH DISCARDED. `DiscardedSessions` is client-scoped, so
|
|
18
|
+
* a `commandId` refusal and a withheld reattach survive the `Session` object
|
|
19
|
+
* that died — which is exactly what "do not replay the SESSION's commandIds"
|
|
20
|
+
* requires of a client that then opens a new session.
|
|
21
|
+
*
|
|
22
|
+
* OPTIONAL MEANS OMITTED, NEVER `null` (tdd SS1.2). Every member this module
|
|
23
|
+
* can leave unset is optional-non-nullable in the generated params, so an
|
|
24
|
+
* unset member is dropped from the frame rather than nulled. The guards below
|
|
25
|
+
* are LOOSE (`!= null`) on purpose: the types only bind strict TypeScript
|
|
26
|
+
* consumers, and a plain-JS caller reaching the built `dist` can pass an
|
|
27
|
+
* explicit `null` that a `!== undefined` guard would forward verbatim. `false
|
|
28
|
+
* != null` is true, so `excludeItems: false` still serializes.
|
|
29
|
+
*
|
|
30
|
+
* The params and the caller options are both COMPOSED from the generated
|
|
31
|
+
* types rather than restated (INV-001: narrow and compose, never restate).
|
|
32
|
+
* Composition alone does NOT stop drift, though: the guard chains below are
|
|
33
|
+
* hand-listed, so a regenerated member would be settable by a caller and then
|
|
34
|
+
* silently dropped on the way to the wire. The `AssertNever` pins next to each
|
|
35
|
+
* chain close that gap — add a member upstream and the BUILD fails here until
|
|
36
|
+
* it is forwarded (or explicitly allow-listed as unforwardable).
|
|
37
|
+
*/
|
|
38
|
+
import type { Connection } from "../connection/connection.js";
|
|
39
|
+
import type { ExitClassification, SpawnedMspConnection } from "../connection/spawn.js";
|
|
40
|
+
import type { SessionDurabilityProfile } from "./host-death.js";
|
|
41
|
+
import { Session } from "./session.js";
|
|
42
|
+
import type { InitializeParams, InitializeResult, SessionResumeParams, SessionStartParams } from "../msp.js";
|
|
43
|
+
/**
|
|
44
|
+
* Caller-facing options for `session/start` (tdd SS2.5.1).
|
|
45
|
+
*
|
|
46
|
+
* `config` is excluded as well as `commandId` until the facade implements the
|
|
47
|
+
* required `sessionMcp` grant check and forwarding.
|
|
48
|
+
*/
|
|
49
|
+
export type StartSessionOptions = Readonly<Omit<SessionStartParams, "commandId" | "config"> & {
|
|
50
|
+
/**
|
|
51
|
+
* Require the `permissionProfile` selection (TEST-42624-10): when set,
|
|
52
|
+
* the client proves the committed selection equals the requested id —
|
|
53
|
+
* from the start result's `session.permissionState`, else a
|
|
54
|
+
* `session/read` read-back — and throws `PermissionProfileNotApplied`
|
|
55
|
+
* instead of proceeding on the host default. Client-local: never sent
|
|
56
|
+
* on the wire. Setting it without `permissionProfile` is a usage error.
|
|
57
|
+
*/
|
|
58
|
+
requirePermissionProfile?: boolean;
|
|
59
|
+
}>;
|
|
60
|
+
/** Caller-facing options for `session/resume` (tdd SS2.5.2). */
|
|
61
|
+
export type ResumeSessionOptions = Readonly<Omit<SessionResumeParams, "commandId" | "config">>;
|
|
62
|
+
/**
|
|
63
|
+
* Options for building a `MuseClient` around a connection you already own.
|
|
64
|
+
* `MuseClient.spawn` fills these in for you; supply them yourself only when
|
|
65
|
+
* you compose the client with your own transport and handshake.
|
|
66
|
+
*/
|
|
67
|
+
export interface MuseClientOptions {
|
|
68
|
+
/**
|
|
69
|
+
* The handshake's `sessionDurability`, read through `readSessionDurability`.
|
|
70
|
+
*
|
|
71
|
+
* REQUIRED, for the same reason `SessionOptions.durability` is: it decides
|
|
72
|
+
* what happens to every in-flight item and command when the host dies
|
|
73
|
+
* (SS2.13.3b), and a default would let a caller skip reading the handshake
|
|
74
|
+
* and silently inherit the wrong obligation. `MuseClient.spawn` supplies it.
|
|
75
|
+
*/
|
|
76
|
+
readonly durability: SessionDurabilityProfile;
|
|
77
|
+
/**
|
|
78
|
+
* The owned host, set by `MuseClient.spawn`. A client built around a
|
|
79
|
+
* connection somebody else owns has none, and says so by omitting it — which
|
|
80
|
+
* is why `close()` falls back to closing just the connection.
|
|
81
|
+
*/
|
|
82
|
+
readonly host?: SpawnedMspConnection;
|
|
83
|
+
}
|
|
84
|
+
/** What `MuseClient.spawn` needs: FR-018's `{ museBin, args?, env? }`, plus the handshake. */
|
|
85
|
+
export interface MuseClientSpawnOptions {
|
|
86
|
+
readonly museBin: string;
|
|
87
|
+
readonly args?: readonly string[];
|
|
88
|
+
readonly cwd?: string;
|
|
89
|
+
/** REPLACES the child's environment (PATH included) — spread `process.env` to extend it. */
|
|
90
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
91
|
+
/** Client identification, forwarded verbatim into `initialize` (SS1.4). */
|
|
92
|
+
readonly clientInfo: InitializeParams["clientInfo"];
|
|
93
|
+
/** Capability posture; absent means all defaults. */
|
|
94
|
+
readonly capabilities?: InitializeParams["capabilities"];
|
|
95
|
+
/** Raw stderr chunks, drained from process birth and never parsed (INV-010). */
|
|
96
|
+
readonly onStderr?: (chunk: string) => void;
|
|
97
|
+
/** See `MuseServeChildOptions.shutdownTimeoutMs`. */
|
|
98
|
+
readonly shutdownTimeoutMs?: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Your entry point to the SDK.
|
|
102
|
+
*
|
|
103
|
+
* Spawn a host with `MuseClient.spawn`, then open conversations with
|
|
104
|
+
* `startSession` or `resumeSession`. The client owns the connection: it
|
|
105
|
+
* routes every server event to the session it belongs to and reports a host
|
|
106
|
+
* death to all of them. Call `close` to shut the host down cleanly.
|
|
107
|
+
*/
|
|
108
|
+
export declare class MuseClient {
|
|
109
|
+
#private;
|
|
110
|
+
constructor(connection: Connection, options: MuseClientOptions);
|
|
111
|
+
/**
|
|
112
|
+
* Spawn an owned host, run the SS1.4 handshake, and hand back a client whose
|
|
113
|
+
* durability profile came from that handshake (FR-018).
|
|
114
|
+
*
|
|
115
|
+
* The profile is READ rather than asked of the caller, which is the whole
|
|
116
|
+
* reason this factory exists beside the bare constructor: SS2.13.1 makes the
|
|
117
|
+
* absent/unrecognized distinction load-bearing, and a caller re-deriving it
|
|
118
|
+
* by hand is a caller that can get it wrong.
|
|
119
|
+
*
|
|
120
|
+
* Rejects with {@link MuseHostStartError} when the host is lost before the
|
|
121
|
+
* handshake completes (the binary could not be spawned, or the process
|
|
122
|
+
* ended first — exit code or signal plus the bounded stderr tail;
|
|
123
|
+
* `answeredInitialize` says whether it died after a usable answer). A host
|
|
124
|
+
* that answers with a reply the SDK refuses keeps its own error: an
|
|
125
|
+
* `MspError` for an SS1.6 refusal, or a `ProtocolError` (with the whole
|
|
126
|
+
* refused frame on `line`) for a reply the SDK cannot use, such as one with
|
|
127
|
+
* no schema fingerprint.
|
|
128
|
+
*/
|
|
129
|
+
static spawn(options: MuseClientSpawnOptions): Promise<MuseClient>;
|
|
130
|
+
/**
|
|
131
|
+
* The handshake result, when this client was built by `spawn`.
|
|
132
|
+
*
|
|
133
|
+
* A plain `Error`, not a typed one: reading this off a client that was not
|
|
134
|
+
* built by `spawn` is API misuse, not a protocol STATE an embedder branches
|
|
135
|
+
* on — a typed class here would send the SS2.13.3b recovery path chasing a
|
|
136
|
+
* host death that never happened.
|
|
137
|
+
*/
|
|
138
|
+
get initializeResult(): InitializeResult;
|
|
139
|
+
/** The host's SS2.11 exit row, when this client was built by `spawn`. */
|
|
140
|
+
get exit(): Promise<ExitClassification>;
|
|
141
|
+
/** The durability profile every session this client opens inherits. */
|
|
142
|
+
get durability(): SessionDurabilityProfile;
|
|
143
|
+
/** Open a new root session (tdd SS2.5.1). */
|
|
144
|
+
startSession(options?: StartSessionOptions): Promise<Session>;
|
|
145
|
+
/**
|
|
146
|
+
* Load an existing session and subscribe this connection to its view
|
|
147
|
+
* (tdd SS2.5.2).
|
|
148
|
+
*
|
|
149
|
+
* WITHHELD after an ephemeral host death (SS2.13.3b clause 2, "do not attempt
|
|
150
|
+
* to reattach"), and refused on THIS side of the transport: a reattach
|
|
151
|
+
* attempt that reaches the wire has already violated the clause, whatever the
|
|
152
|
+
* server answers.
|
|
153
|
+
*/
|
|
154
|
+
resumeSession(options: ResumeSessionOptions): Promise<Session>;
|
|
155
|
+
/**
|
|
156
|
+
* Shut the host down in an orderly way (SS2.1.2); the shutdown this causes
|
|
157
|
+
* is not reported to your sessions as a host death.
|
|
158
|
+
*
|
|
159
|
+
* The EOF this close produces is NOT a death, which is what `#closing`
|
|
160
|
+
* records.
|
|
161
|
+
*
|
|
162
|
+
* `#closing` covers only the EOF; it must not swallow the EXIT ROW. When the
|
|
163
|
+
* host ignores stdin EOF, `#child.close()` escalates to SIGTERM/SIGKILL and
|
|
164
|
+
* the exit classifies as a crash — an abnormal death under SS2.13.3b no
|
|
165
|
+
* matter who started the close, and one no transport event will ever report
|
|
166
|
+
* here because `#closing` already claimed the EOF. So the classification the
|
|
167
|
+
* close itself returns is forwarded to every session: `hostExited` answers
|
|
168
|
+
* `notADeath` for `cleanShutdown`, keeping the orderly arm inert.
|
|
169
|
+
*/
|
|
170
|
+
close(): Promise<void>;
|
|
171
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MuseClient` — the SS7.1 facade's session-opening half (FR-018, T030).
|
|
3
|
+
*
|
|
4
|
+
* It turns the two v1 session verbs into correctly-shaped `session/start` /
|
|
5
|
+
* `session/resume` params and hands back a WIRED `Session`. It frames nothing
|
|
6
|
+
* and mints nothing: `Connection.command()` stamps the `commandId` from the
|
|
7
|
+
* single injected mint, so INV-013's single-minter property is preserved by
|
|
8
|
+
* DELEGATION rather than re-implemented here.
|
|
9
|
+
*
|
|
10
|
+
* IT OWNS TWO FACTS NO OTHER LAYER HOLDS, and that is why obligations (b) and
|
|
11
|
+
* (c) of T030 live here rather than on `Session`:
|
|
12
|
+
*
|
|
13
|
+
* - WHETHER A CLOSE WAS OURS. `Connection` is duplex-generic; a transport
|
|
14
|
+
* cannot tell a peer's hang-up from its own shutdown. SS2.13.3b counts
|
|
15
|
+
* "process exit **or transport EOF**" as a death, so somebody has to know
|
|
16
|
+
* the difference, and only the layer that owns `close()` does.
|
|
17
|
+
* - WHAT AN EARLIER DEATH DISCARDED. `DiscardedSessions` is client-scoped, so
|
|
18
|
+
* a `commandId` refusal and a withheld reattach survive the `Session` object
|
|
19
|
+
* that died — which is exactly what "do not replay the SESSION's commandIds"
|
|
20
|
+
* requires of a client that then opens a new session.
|
|
21
|
+
*
|
|
22
|
+
* OPTIONAL MEANS OMITTED, NEVER `null` (tdd SS1.2). Every member this module
|
|
23
|
+
* can leave unset is optional-non-nullable in the generated params, so an
|
|
24
|
+
* unset member is dropped from the frame rather than nulled. The guards below
|
|
25
|
+
* are LOOSE (`!= null`) on purpose: the types only bind strict TypeScript
|
|
26
|
+
* consumers, and a plain-JS caller reaching the built `dist` can pass an
|
|
27
|
+
* explicit `null` that a `!== undefined` guard would forward verbatim. `false
|
|
28
|
+
* != null` is true, so `excludeItems: false` still serializes.
|
|
29
|
+
*
|
|
30
|
+
* The params and the caller options are both COMPOSED from the generated
|
|
31
|
+
* types rather than restated (INV-001: narrow and compose, never restate).
|
|
32
|
+
* Composition alone does NOT stop drift, though: the guard chains below are
|
|
33
|
+
* hand-listed, so a regenerated member would be settable by a caller and then
|
|
34
|
+
* silently dropped on the way to the wire. The `AssertNever` pins next to each
|
|
35
|
+
* chain close that gap — add a member upstream and the BUILD fails here until
|
|
36
|
+
* it is forwarded (or explicitly allow-listed as unforwardable).
|
|
37
|
+
*/
|
|
38
|
+
import { initializeAnswered, spawnMspConnection } from "../connection/spawn.js";
|
|
39
|
+
import { MuseSessionDiscardedError, PermissionProfileNotApplied } from "../errors.js";
|
|
40
|
+
import { DiscardedSessions } from "./discarded.js";
|
|
41
|
+
import { isAbnormalHostDeath, readSessionDurability } from "./host-death.js";
|
|
42
|
+
import { MspError, ProtocolError } from "../connection/connection.js";
|
|
43
|
+
import { hostStartFailure } from "./host-start.js";
|
|
44
|
+
import { Session } from "./session.js";
|
|
45
|
+
/**
|
|
46
|
+
* Every `StartParams` member `startSession` forwards. `config` is temporarily
|
|
47
|
+
* excluded until the facade can pair forwarding with the required
|
|
48
|
+
* `sessionMcp` grant check.
|
|
49
|
+
*/
|
|
50
|
+
const START_FORWARDED = [
|
|
51
|
+
"approvalMode",
|
|
52
|
+
"providerId",
|
|
53
|
+
"sessionId",
|
|
54
|
+
"workspaceRoot",
|
|
55
|
+
"workspaceRoots",
|
|
56
|
+
"modelId",
|
|
57
|
+
"permissionProfile",
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Every `ResumeParams` member `resumeSession` forwards. `config` has the same
|
|
61
|
+
* temporary capability-check boundary as the start facade above.
|
|
62
|
+
*/
|
|
63
|
+
const RESUME_FORWARDED = ["sessionId", "cursor", "excludeItems", "history"];
|
|
64
|
+
/**
|
|
65
|
+
* The committed profile id a session snapshot claims, or null when the
|
|
66
|
+
* snapshot is silent (the stable bundle strips the member; absent and null
|
|
67
|
+
* both mean unknown, never applied). Structural on purpose: the stable
|
|
68
|
+
* `Session` type carries no `permissionState`, and an unknown wire shape
|
|
69
|
+
* must read as unknown rather than throw.
|
|
70
|
+
*/
|
|
71
|
+
function committedProfileId(snapshot) {
|
|
72
|
+
if (typeof snapshot !== "object" || snapshot === null)
|
|
73
|
+
return null;
|
|
74
|
+
const state = snapshot.permissionState;
|
|
75
|
+
if (typeof state !== "object" || state === null)
|
|
76
|
+
return null;
|
|
77
|
+
const id = state.profileId;
|
|
78
|
+
return typeof id === "string" ? id : null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Your entry point to the SDK.
|
|
82
|
+
*
|
|
83
|
+
* Spawn a host with `MuseClient.spawn`, then open conversations with
|
|
84
|
+
* `startSession` or `resumeSession`. The client owns the connection: it
|
|
85
|
+
* routes every server event to the session it belongs to and reports a host
|
|
86
|
+
* death to all of them. Call `close` to shut the host down cleanly.
|
|
87
|
+
*/
|
|
88
|
+
export class MuseClient {
|
|
89
|
+
#connection;
|
|
90
|
+
#durability;
|
|
91
|
+
#discarded;
|
|
92
|
+
/** Sessions this client opened, so one death notification reaches them all. */
|
|
93
|
+
#sessions = new Set();
|
|
94
|
+
/**
|
|
95
|
+
* Frames naming a session this client has not registered YET, held only
|
|
96
|
+
* while a `session/start`/`session/resume` is in flight (#49790): the host
|
|
97
|
+
* flushes the resume replay BEFORE the result, and the promise awaiting that
|
|
98
|
+
* result resumes only after the whole chunk has been routed. `#route` holds,
|
|
99
|
+
* `#openSession` drains, `#open` clears when the last open settles.
|
|
100
|
+
*/
|
|
101
|
+
#held = new Map();
|
|
102
|
+
#opening = 0;
|
|
103
|
+
/** The spawned host, when `spawn` built this client. */
|
|
104
|
+
#child;
|
|
105
|
+
#initializeResult;
|
|
106
|
+
/**
|
|
107
|
+
* Set by `close()` BEFORE the connection is torn down.
|
|
108
|
+
*
|
|
109
|
+
* This flag is the entire discriminator for obligation (b): the same
|
|
110
|
+
* transport EOF is an orderly SS2.1.2 shutdown when we caused it and an
|
|
111
|
+
* abnormal death when we did not, and nothing below this layer can tell.
|
|
112
|
+
*/
|
|
113
|
+
#closing = false;
|
|
114
|
+
/** Latched by an abnormal EOF, so a later `resumeSession` can be withheld. */
|
|
115
|
+
#hostDied = false;
|
|
116
|
+
constructor(connection, options) {
|
|
117
|
+
this.#connection = connection;
|
|
118
|
+
this.#durability = options.durability;
|
|
119
|
+
// Client-owned, never injected: SS2.13.3b is a one-client obligation and
|
|
120
|
+
// no current consumer spans two clients over one host lineage, so a
|
|
121
|
+
// sharing knob would be declared-ahead surface (Constitution XI).
|
|
122
|
+
this.#discarded = new DiscardedSessions();
|
|
123
|
+
this.#child = options.host?.child;
|
|
124
|
+
this.#initializeResult = options.host?.initializeResult;
|
|
125
|
+
// THE ONE INBOUND PUMP (FR-018/FR-019). `Session.apply` is the only event
|
|
126
|
+
// entry point and `#connection` is private, so without this a spawn-built
|
|
127
|
+
// consumer has no way to hear a single view frame: every turn wait hangs
|
|
128
|
+
// (SS3.1.4) and `onApproval` can never fire. `Connection.onNotification`
|
|
129
|
+
// holds ONE handler — this client claims it, which is part of what
|
|
130
|
+
// "MuseClient owns the connection" means; a consumer that needs the raw
|
|
131
|
+
// feed builds around a bare `Connection` and `Session` instead.
|
|
132
|
+
this.#connection.onNotification((notification) => this.#route(notification));
|
|
133
|
+
// Registered at construction, not at the first session: an EOF can land
|
|
134
|
+
// before any session exists, and the flag has to be read at that moment.
|
|
135
|
+
void this.#connection.closed.then(() => this.#transportClosed());
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Deliver one view notification to the session(s) it names.
|
|
139
|
+
*
|
|
140
|
+
* A frame naming NO session is dropped here rather than fed to every
|
|
141
|
+
* session: `Session.apply` already tolerates unknown methods, but only the
|
|
142
|
+
* frames that name a `sessionId` have an owner to route to. A frame naming
|
|
143
|
+
* an UNKNOWN session is dropped for the same reason — routing it anywhere
|
|
144
|
+
* would trip the foreign-frame throw on a session it does not belong to —
|
|
145
|
+
* UNLESS an open is in flight, when it may be that open's own session named
|
|
146
|
+
* by the server before the awaiting promise has run (#49790): then it is
|
|
147
|
+
* held for `#openSession` to fold, or for `#open` to clear.
|
|
148
|
+
*/
|
|
149
|
+
#route(notification) {
|
|
150
|
+
const named = notification.params?.sessionId;
|
|
151
|
+
if (typeof named !== "string")
|
|
152
|
+
return;
|
|
153
|
+
let delivered = false;
|
|
154
|
+
for (const session of this.#sessions) {
|
|
155
|
+
if (session.sessionId === named) {
|
|
156
|
+
session.apply(notification);
|
|
157
|
+
delivered = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (delivered || this.#opening === 0)
|
|
161
|
+
return;
|
|
162
|
+
const held = this.#held.get(named);
|
|
163
|
+
if (held === undefined)
|
|
164
|
+
this.#held.set(named, [notification]);
|
|
165
|
+
else
|
|
166
|
+
held.push(notification);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Spawn an owned host, run the SS1.4 handshake, and hand back a client whose
|
|
170
|
+
* durability profile came from that handshake (FR-018).
|
|
171
|
+
*
|
|
172
|
+
* The profile is READ rather than asked of the caller, which is the whole
|
|
173
|
+
* reason this factory exists beside the bare constructor: SS2.13.1 makes the
|
|
174
|
+
* absent/unrecognized distinction load-bearing, and a caller re-deriving it
|
|
175
|
+
* by hand is a caller that can get it wrong.
|
|
176
|
+
*
|
|
177
|
+
* Rejects with {@link MuseHostStartError} when the host is lost before the
|
|
178
|
+
* handshake completes (the binary could not be spawned, or the process
|
|
179
|
+
* ended first — exit code or signal plus the bounded stderr tail;
|
|
180
|
+
* `answeredInitialize` says whether it died after a usable answer). A host
|
|
181
|
+
* that answers with a reply the SDK refuses keeps its own error: an
|
|
182
|
+
* `MspError` for an SS1.6 refusal, or a `ProtocolError` (with the whole
|
|
183
|
+
* refused frame on `line`) for a reply the SDK cannot use, such as one with
|
|
184
|
+
* no schema fingerprint.
|
|
185
|
+
*/
|
|
186
|
+
static async spawn(options) {
|
|
187
|
+
const handshake = spawnMspConnection({
|
|
188
|
+
command: options.museBin,
|
|
189
|
+
...(options.args === undefined ? {} : { args: options.args }),
|
|
190
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
191
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
192
|
+
...(options.onStderr === undefined ? {} : { onStderr: options.onStderr }),
|
|
193
|
+
...(options.shutdownTimeoutMs === undefined
|
|
194
|
+
? {}
|
|
195
|
+
: { shutdownTimeoutMs: options.shutdownTimeoutMs }),
|
|
196
|
+
});
|
|
197
|
+
let spawned;
|
|
198
|
+
try {
|
|
199
|
+
spawned = await handshake.initialize({
|
|
200
|
+
clientInfo: options.clientInfo,
|
|
201
|
+
...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// A failed handshake must not leak the process it already spawned.
|
|
206
|
+
await handshake.close().catch(() => undefined);
|
|
207
|
+
// A host that ANSWERED `initialize` is not a lost host, however it ends
|
|
208
|
+
// once we close it: its answer is the actionable error. "Answered" is
|
|
209
|
+
// evidence that a response frame came back — an SS1.6 `MspError`, or a
|
|
210
|
+
// `ProtocolError` carrying the frame the SDK refused (a malformed or
|
|
211
|
+
// version-skewed reply) — never the error's class alone (#49912).
|
|
212
|
+
// Decided before the exit is read, so our own close ladder can never
|
|
213
|
+
// dress an answer up as a death.
|
|
214
|
+
const answered = error instanceof MspError || (error instanceof ProtocolError && error.line !== undefined);
|
|
215
|
+
if (answered)
|
|
216
|
+
throw error;
|
|
217
|
+
// No refused frame: the host was lost — before answering, or after a
|
|
218
|
+
// usable answer while we sent `initialized` (the error then says so).
|
|
219
|
+
// close() ended at its observed exit (or its spawn failure), so the
|
|
220
|
+
// evidence is settled: the typed error carries the spawn error or the
|
|
221
|
+
// SS2.11 row plus the stderr tail instead of the transport's first
|
|
222
|
+
// symptom (#49791, #49912).
|
|
223
|
+
throw await hostStartFailure(handshake.child, error, handshake[initializeAnswered]());
|
|
224
|
+
}
|
|
225
|
+
return new MuseClient(spawned.connection, {
|
|
226
|
+
durability: readSessionDurability(spawned.initializeResult),
|
|
227
|
+
host: spawned,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The handshake result, when this client was built by `spawn`.
|
|
232
|
+
*
|
|
233
|
+
* A plain `Error`, not a typed one: reading this off a client that was not
|
|
234
|
+
* built by `spawn` is API misuse, not a protocol STATE an embedder branches
|
|
235
|
+
* on — a typed class here would send the SS2.13.3b recovery path chasing a
|
|
236
|
+
* host death that never happened.
|
|
237
|
+
*/
|
|
238
|
+
get initializeResult() {
|
|
239
|
+
if (this.#initializeResult === undefined) {
|
|
240
|
+
throw new Error("initializeResult is only available on a client built by MuseClient.spawn");
|
|
241
|
+
}
|
|
242
|
+
return this.#initializeResult;
|
|
243
|
+
}
|
|
244
|
+
/** The host's SS2.11 exit row, when this client was built by `spawn`. */
|
|
245
|
+
get exit() {
|
|
246
|
+
if (this.#child === undefined) {
|
|
247
|
+
// Plain `Error` for the same reason as `initializeResult`: API misuse,
|
|
248
|
+
// not a protocol state.
|
|
249
|
+
throw new Error("exit is only available on a client built by MuseClient.spawn");
|
|
250
|
+
}
|
|
251
|
+
return this.#child.exit;
|
|
252
|
+
}
|
|
253
|
+
/** The durability profile every session this client opens inherits. */
|
|
254
|
+
get durability() {
|
|
255
|
+
return this.#durability;
|
|
256
|
+
}
|
|
257
|
+
/** Open a new root session (tdd SS2.5.1). */
|
|
258
|
+
async startSession(options = {}) {
|
|
259
|
+
const params = {};
|
|
260
|
+
if (options.approvalMode != null)
|
|
261
|
+
params.approvalMode = options.approvalMode;
|
|
262
|
+
if (options.providerId != null)
|
|
263
|
+
params.providerId = options.providerId;
|
|
264
|
+
if (options.sessionId != null)
|
|
265
|
+
params.sessionId = options.sessionId;
|
|
266
|
+
if (options.workspaceRoot != null)
|
|
267
|
+
params.workspaceRoot = options.workspaceRoot;
|
|
268
|
+
if (options.workspaceRoots != null)
|
|
269
|
+
params.workspaceRoots = options.workspaceRoots;
|
|
270
|
+
if (options.modelId != null)
|
|
271
|
+
params.modelId = options.modelId;
|
|
272
|
+
// The initial permission-profile selection, forwarded verbatim like the
|
|
273
|
+
// other start members (undefined stays omitted).
|
|
274
|
+
if (options.permissionProfile != null)
|
|
275
|
+
params.permissionProfile = options.permissionProfile;
|
|
276
|
+
// TEST-42624-10: a caller that requires its selection proves it, never
|
|
277
|
+
// assuming the host default. The flag without the member is API misuse
|
|
278
|
+
// (plain Error, like the other misuse guards here), raised pre-wire.
|
|
279
|
+
const required = options.requirePermissionProfile === true;
|
|
280
|
+
if (required && options.permissionProfile == null) {
|
|
281
|
+
throw new Error("requirePermissionProfile needs permissionProfile: require the selection you pass");
|
|
282
|
+
}
|
|
283
|
+
// Keyed by the id the SERVER named, never the one the caller asked for:
|
|
284
|
+
// `sessionId` is a REQUEST on start, and a session keyed by a hopeful id
|
|
285
|
+
// would reject every one of its own events as foreign.
|
|
286
|
+
const session = await this.#open("session/start", params, (raw) => ({
|
|
287
|
+
result: raw,
|
|
288
|
+
verb: "session/start",
|
|
289
|
+
}));
|
|
290
|
+
if (!required)
|
|
291
|
+
return session;
|
|
292
|
+
return this.#proveSelection(session, options.permissionProfile);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Prove a required start selection (TEST-42624-10): the start result's own
|
|
296
|
+
* `session.permissionState` first (absent = unknown, never applied); else a
|
|
297
|
+
* point-in-time `session/read`, which never subscribes. Only `profileId ==
|
|
298
|
+
* requested` proceeds; anything else throws `PermissionProfileNotApplied`
|
|
299
|
+
* BEFORE any turn is sent. The refused session is dropped from the client
|
|
300
|
+
* (no orphan keeps folding for an id the caller can never reach — the SDK
|
|
301
|
+
* has no set/close-by-id verb); the error's `sessionId` is the caller's
|
|
302
|
+
* handle to resume via `sessionId` or start again. A failed read-back
|
|
303
|
+
* propagates: the client still never proceeds on the default.
|
|
304
|
+
*/
|
|
305
|
+
async #proveSelection(session, requested) {
|
|
306
|
+
const opening = session.opening;
|
|
307
|
+
const started = opening !== undefined && opening.verb === "session/start"
|
|
308
|
+
? committedProfileId(opening.result.session)
|
|
309
|
+
: null;
|
|
310
|
+
if (started !== null) {
|
|
311
|
+
if (started === requested)
|
|
312
|
+
return session;
|
|
313
|
+
this.#sessions.delete(session);
|
|
314
|
+
throw new PermissionProfileNotApplied(requested, started, session.sessionId);
|
|
315
|
+
}
|
|
316
|
+
const read = await this.#connection.command("session/read", {
|
|
317
|
+
sessionId: session.sessionId,
|
|
318
|
+
});
|
|
319
|
+
const served = committedProfileId(read["session"]);
|
|
320
|
+
if (served === requested)
|
|
321
|
+
return session;
|
|
322
|
+
this.#sessions.delete(session);
|
|
323
|
+
throw new PermissionProfileNotApplied(requested, served, session.sessionId);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Load an existing session and subscribe this connection to its view
|
|
327
|
+
* (tdd SS2.5.2).
|
|
328
|
+
*
|
|
329
|
+
* WITHHELD after an ephemeral host death (SS2.13.3b clause 2, "do not attempt
|
|
330
|
+
* to reattach"), and refused on THIS side of the transport: a reattach
|
|
331
|
+
* attempt that reaches the wire has already violated the clause, whatever the
|
|
332
|
+
* server answers.
|
|
333
|
+
*/
|
|
334
|
+
async resumeSession(options) {
|
|
335
|
+
this.#assertReattachAllowed(options.sessionId);
|
|
336
|
+
const params = { sessionId: options.sessionId };
|
|
337
|
+
if (options.cursor != null)
|
|
338
|
+
params.cursor = options.cursor;
|
|
339
|
+
if (options.excludeItems != null)
|
|
340
|
+
params.excludeItems = options.excludeItems;
|
|
341
|
+
if (options.history != null)
|
|
342
|
+
params.history = options.history;
|
|
343
|
+
return this.#open("session/resume", params, (raw) => ({
|
|
344
|
+
result: raw,
|
|
345
|
+
verb: "session/resume",
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Shut the host down in an orderly way (SS2.1.2); the shutdown this causes
|
|
350
|
+
* is not reported to your sessions as a host death.
|
|
351
|
+
*
|
|
352
|
+
* The EOF this close produces is NOT a death, which is what `#closing`
|
|
353
|
+
* records.
|
|
354
|
+
*
|
|
355
|
+
* `#closing` covers only the EOF; it must not swallow the EXIT ROW. When the
|
|
356
|
+
* host ignores stdin EOF, `#child.close()` escalates to SIGTERM/SIGKILL and
|
|
357
|
+
* the exit classifies as a crash — an abnormal death under SS2.13.3b no
|
|
358
|
+
* matter who started the close, and one no transport event will ever report
|
|
359
|
+
* here because `#closing` already claimed the EOF. So the classification the
|
|
360
|
+
* close itself returns is forwarded to every session: `hostExited` answers
|
|
361
|
+
* `notADeath` for `cleanShutdown`, keeping the orderly arm inert.
|
|
362
|
+
*/
|
|
363
|
+
async close() {
|
|
364
|
+
this.#closing = true;
|
|
365
|
+
if (this.#child !== undefined) {
|
|
366
|
+
const exit = await this.#child.close();
|
|
367
|
+
if (isAbnormalHostDeath(exit)) {
|
|
368
|
+
for (const session of this.#sessions)
|
|
369
|
+
session.hostExited(exit);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await this.#connection.close();
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The window `#route` holds unknown-session frames for: from the request to
|
|
377
|
+
* the Session's registration. Counted, not flagged, so two concurrent opens
|
|
378
|
+
* keep it open until the LAST settles; cleared on settle so a frame that
|
|
379
|
+
* named no session this client opened is held no longer than the open it
|
|
380
|
+
* rode in with (#49790).
|
|
381
|
+
*/
|
|
382
|
+
async #open(verb, params, opening) {
|
|
383
|
+
this.#opening += 1;
|
|
384
|
+
try {
|
|
385
|
+
const opened = opening(await this.#connection.command(verb, params));
|
|
386
|
+
return this.#openSession(opened.result.session.sessionId, opened);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
this.#opening -= 1;
|
|
390
|
+
if (this.#opening === 0)
|
|
391
|
+
this.#held.clear();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
#openSession(sessionId, opening) {
|
|
395
|
+
const session = new Session({
|
|
396
|
+
connection: this.#connection,
|
|
397
|
+
discarded: this.#discarded,
|
|
398
|
+
durability: this.#durability,
|
|
399
|
+
opening,
|
|
400
|
+
sessionId,
|
|
401
|
+
});
|
|
402
|
+
this.#sessions.add(session);
|
|
403
|
+
// The frames that named this session while its verb was in flight fold
|
|
404
|
+
// NOW, in arrival order — synchronously, so nothing the pump routes next
|
|
405
|
+
// can overtake them.
|
|
406
|
+
const held = this.#held.get(sessionId);
|
|
407
|
+
if (held !== undefined) {
|
|
408
|
+
this.#held.delete(sessionId);
|
|
409
|
+
for (const frame of held)
|
|
410
|
+
session.apply(frame);
|
|
411
|
+
}
|
|
412
|
+
return session;
|
|
413
|
+
}
|
|
414
|
+
#assertReattachAllowed(sessionId) {
|
|
415
|
+
// Two facts, both recorded by an ephemeral discharge: THIS session was
|
|
416
|
+
// discarded, or this client's own ephemeral host died (in which case there
|
|
417
|
+
// is nothing on the other end to reattach TO, whatever id is asked for).
|
|
418
|
+
if (this.#discarded.sessionIds.has(sessionId)) {
|
|
419
|
+
throw new MuseSessionDiscardedError(`session ${sessionId} was discarded after an ephemeral host death; it cannot be resumed`);
|
|
420
|
+
}
|
|
421
|
+
if (this.#hostDied && !this.#survivesHostDeath()) {
|
|
422
|
+
throw new MuseSessionDiscardedError("this client's ephemeral host died; SS2.13.3b forbids reattaching to it");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
#survivesHostDeath() {
|
|
426
|
+
return this.#durability.kind === "durable";
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The transport reached EOF. If this client did not cause it, that is
|
|
430
|
+
* SS2.13.3b's second death notification (T030 obligation b) and every session
|
|
431
|
+
* this client opened discharges through the SAME `Session.hostExited` path
|
|
432
|
+
* the process exit uses — including its latch, so whichever notification
|
|
433
|
+
* arrives second replays the first one's report instead of a half delta.
|
|
434
|
+
*/
|
|
435
|
+
#transportClosed() {
|
|
436
|
+
if (this.#closing)
|
|
437
|
+
return;
|
|
438
|
+
this.#hostDied = true;
|
|
439
|
+
for (const session of this.#sessions)
|
|
440
|
+
session.hostExited({ kind: "transportEof" });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an ephemeral host death discarded, remembered ACROSS the sessions one
|
|
3
|
+
* client opened (spec 14990 T030 obligation (c); tdd SS2.13.3b).
|
|
4
|
+
*
|
|
5
|
+
* SS2.13.3b's five-clause MUST includes "do not attempt to reattach" and "do
|
|
6
|
+
* not replay the session's `commandId`s". Both are statements about what the
|
|
7
|
+
* CLIENT does next, and "next" outlives the session object that died:
|
|
8
|
+
*
|
|
9
|
+
* - `PendingCommandSet` already refuses a replay of an id IT discarded, but
|
|
10
|
+
* its memory is per-instance, so a FRESH `Session` — the natural thing to
|
|
11
|
+
* build after a host dies — knew nothing and would replay those ids at the
|
|
12
|
+
* new host. That is the exactly-once violation the clause exists to prevent,
|
|
13
|
+
* and it is why T030b's own arm could only name the gap;
|
|
14
|
+
* - nothing at all held the discarded `sessionId`, so `resumeSession` had no
|
|
15
|
+
* fact to withhold on.
|
|
16
|
+
*
|
|
17
|
+
* This is client state, never a wire shape and never durable (INV-007): it
|
|
18
|
+
* lives exactly as long as the `MuseClient` that owns it. It is deliberately
|
|
19
|
+
* NOT a module-global — two clients in one process are two independent trust
|
|
20
|
+
* boundaries, and a process-wide set would let one client's dead host silence
|
|
21
|
+
* another's live one.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The two sets, exposed directly rather than behind add/has pairs.
|
|
25
|
+
*
|
|
26
|
+
* `PendingCommandSet` takes the `commandIds` set BY REFERENCE and writes to it
|
|
27
|
+
* from `discardEphemeral()`, so a wrapper would have to re-expose a mutable
|
|
28
|
+
* seam anyway; a `Set<string>` is the honest shape for one, and it is already
|
|
29
|
+
* the shape that class used internally.
|
|
30
|
+
*/
|
|
31
|
+
export declare class DiscardedSessions {
|
|
32
|
+
/** `commandId`s an ephemeral discharge retired. Never replayed again. */
|
|
33
|
+
readonly commandIds: Set<string>;
|
|
34
|
+
/** `sessionId`s whose ephemeral host died. Never reattached. */
|
|
35
|
+
readonly sessionIds: Set<string>;
|
|
36
|
+
}
|