@workerdeck/protocol 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/build/index.d.mts +255 -1
- package/build/index.mjs +165 -1
- package/build/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,10 +91,18 @@ ride through as `sdk_event` rather than breaking older clients.
|
|
|
91
91
|
- **A breaking wire change bumps `PROTOCOL_VERSION`.** New client-visible frames also need matching
|
|
92
92
|
surface in `@workerdeck/client`, or no client can reach them.
|
|
93
93
|
- **This package owns rules, not just shapes.** `transcriptActivity` is the row-count both the
|
|
94
|
-
reducer renders by and the runners report as `activityCount`; `
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
reducer renders by and the runners report as `activityCount`; `transcriptContent` is the
|
|
95
|
+
does-it-mutate-items rule behind `conversation_reset`'s replay (the runner skips content below
|
|
96
|
+
the latest reset, and it must skip exactly what the reducer would have cleared — note it is
|
|
97
|
+
broader than `transcriptActivity() > 0`: deltas and tool results count zero rows and still
|
|
98
|
+
mutate items); `session-list.ts` is the sessions list view model; `watermarks.ts` is the unread
|
|
99
|
+
model. They live here because a client that filtered or counted differently would announce work
|
|
100
|
+
it is hiding. Change one, change every consumer — including the Swift mirror in
|
|
101
|
+
`apps/ios/WorkerDeckKit`.
|
|
102
|
+
- **`activityCount` is monotonic across a `conversation_reset`.** It is an unread *cursor*
|
|
103
|
+
diffed against stored monotonic watermarks, not an item count — resetting it to the fresh row
|
|
104
|
+
count would leave every stored mark above it and that session's badge silently dead. After a
|
|
105
|
+
`/clear` it deliberately exceeds the rendered row count.
|
|
98
106
|
- **`ENGINE_CAPABILITIES` is pinned by identity, and is a *fallback*.** A server that reports its
|
|
99
107
|
own record wins; this table is what a client uses when talking to one that doesn't. Editing a
|
|
100
108
|
value here is a cross-client change, not a local one.
|
package/build/index.d.mts
CHANGED
|
@@ -136,6 +136,72 @@ declare function hasFacetFilter(config: ViewConfig): boolean;
|
|
|
136
136
|
* choices are a layout preference and survive. */
|
|
137
137
|
declare function clearFilters(config: ViewConfig): ViewConfig;
|
|
138
138
|
//#endregion
|
|
139
|
+
//#region src/usage.d.ts
|
|
140
|
+
/**
|
|
141
|
+
* What one session was last told about the plan's windows: the transcript's own
|
|
142
|
+
* rate-limit state, and the event clock of the newest reading in it.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately structural rather than `TranscriptState` — protocol may not
|
|
145
|
+
* import a client — and it is exactly the two fields the reducer keeps.
|
|
146
|
+
*/
|
|
147
|
+
type SessionUsage = {
|
|
148
|
+
/** Keyed by `rateLimitType`, as the reducer stores it. */rateLimits?: Record<string, RateLimitInfo>;
|
|
149
|
+
/** Epoch ms of the newest `rate_limit` event this session saw — one clock for
|
|
150
|
+
* the whole map, which is all the reducer records. */
|
|
151
|
+
updatedAt?: number;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The usage a client should render: the gateway's per-profile state where it has
|
|
155
|
+
* the window, this session's own reading where it does not.
|
|
156
|
+
*
|
|
157
|
+
* Why the profile wins outright rather than by comparing timestamps: the
|
|
158
|
+
* gateway's `ProfileUsageTracker` is fed from **every** session on the profile —
|
|
159
|
+
* including this one, from seq 0 — and keeps the newest reading per window by
|
|
160
|
+
* the event's own `ts`. So for any window it holds, it holds a reading at least
|
|
161
|
+
* as new as the one in this transcript, and a timestamp comparison could only
|
|
162
|
+
* ever go wrong: the reducer keeps a *single* `updatedAt` for the whole map, so
|
|
163
|
+
* a `five_hour` reading from this morning is dated with the afternoon's
|
|
164
|
+
* `seven_day` event and would beat a genuinely fresher profile entry.
|
|
165
|
+
*
|
|
166
|
+
* The session half is not a fallback for correctness but for *coverage*: the
|
|
167
|
+
* profile map is in-memory, so a restarted gateway serves nothing until a
|
|
168
|
+
* session reports again, and a session with no profile has no account state at
|
|
169
|
+
* all. In both cases the transcript's reading is the only one there is, and it
|
|
170
|
+
* is dated honestly (see {@link SessionUsage.updatedAt}) rather than as now.
|
|
171
|
+
*
|
|
172
|
+
* Absent stays absent throughout: a window nobody has reported is **unknown,
|
|
173
|
+
* never 0%**, and this returns an empty map rather than inventing entries.
|
|
174
|
+
*/
|
|
175
|
+
declare function mergeUsage(session: SessionUsage, profile: ProfileUsage | undefined): ProfileUsage;
|
|
176
|
+
/** One window as a surface draws it: the reading, its own date, and whether the
|
|
177
|
+
* gateway is the one that zeroed it. */
|
|
178
|
+
type UsageWindowRow = {
|
|
179
|
+
key: string;
|
|
180
|
+
info: RateLimitInfo; /** Epoch ms of the reading. Absent only for a hand-built state with no clock. */
|
|
181
|
+
updatedAt?: number;
|
|
182
|
+
inferredReset?: boolean;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* The windows in reading order: the session window, the weekly one, then the
|
|
186
|
+
* per-model weeklies alphabetically.
|
|
187
|
+
*
|
|
188
|
+
* Discovered rather than hardcoded — the engine's set of windows is an open
|
|
189
|
+
* union and has grown before — but ordered, so the first two always mean the
|
|
190
|
+
* same thing wherever they are drawn. A window with no `utilization` is
|
|
191
|
+
* **unknown, not zero**, and is dropped entirely rather than rendered as an
|
|
192
|
+
* empty bar that reads as "plenty left".
|
|
193
|
+
*
|
|
194
|
+
* Here rather than in a client because two surfaces now render the same windows
|
|
195
|
+
* from different sources — the session panel from its merged state, the
|
|
196
|
+
* dashboard's profile page straight off `ProfileInfo.usage` — and a list that
|
|
197
|
+
* ordered or filtered differently would be the same account described two ways.
|
|
198
|
+
*/
|
|
199
|
+
declare function orderUsageWindows(usage: ProfileUsage | undefined): UsageWindowRow[];
|
|
200
|
+
/** The flat `rateLimitType → reading` map every existing renderer takes, out of
|
|
201
|
+
* the dated form. Undefined in, undefined out — so a surface can keep telling
|
|
202
|
+
* "no reading" apart from "an empty one". */
|
|
203
|
+
declare function usageInfos(usage: ProfileUsage | undefined): Record<string, RateLimitInfo> | undefined;
|
|
204
|
+
//#endregion
|
|
139
205
|
//#region src/watermarks.d.ts
|
|
140
206
|
/**
|
|
141
207
|
* "What had you seen, and when" — per session, across reloads.
|
|
@@ -269,6 +335,45 @@ type UnknownBlock = {
|
|
|
269
335
|
type: string;
|
|
270
336
|
[key: string]: unknown;
|
|
271
337
|
};
|
|
338
|
+
/**
|
|
339
|
+
* One hunk of a file edit, in unified-diff terms.
|
|
340
|
+
*
|
|
341
|
+
* The numbers are the engine's own, not the client's: `newStart` is where this
|
|
342
|
+
* hunk begins in the file *after* the edit, which is what a reader needs to jump
|
|
343
|
+
* to the change. A client cannot compute them — it has never seen the file — so
|
|
344
|
+
* a diff rendered without this is a diff with no line numbers.
|
|
345
|
+
*/
|
|
346
|
+
type PatchHunk = {
|
|
347
|
+
oldStart: number;
|
|
348
|
+
oldLines: number;
|
|
349
|
+
newStart: number;
|
|
350
|
+
newLines: number;
|
|
351
|
+
/** Body lines, each prefixed ' ' (context), '-' (removed) or '+' (added), as
|
|
352
|
+
* unified diff spells them. The prefix is part of the string. */
|
|
353
|
+
lines: string[];
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* What a file-editing tool changed — the renderable half of an engine's edit
|
|
357
|
+
* output, and deliberately only that half.
|
|
358
|
+
*
|
|
359
|
+
* Both engines can say far more: the Claude SDK's `FileEditOutput` carries
|
|
360
|
+
* `originalFile`, the **entire** contents of the file before the edit. That must
|
|
361
|
+
* not travel here. This log is replayed to every attaching client and captured
|
|
362
|
+
* into parking snapshots, so a whole file on every edit is paid for again on
|
|
363
|
+
* every attach, forever — the same reason attachment bytes are references (see
|
|
364
|
+
* {@link MessageAttachment}) rather than inline base64.
|
|
365
|
+
*
|
|
366
|
+
* So the runner projects the engine's output down to the hunks, which is exactly
|
|
367
|
+
* what a diff renders and nothing more.
|
|
368
|
+
*/
|
|
369
|
+
type FilePatch = {
|
|
370
|
+
/** Absolute path the engine reported, when it named one. */path?: string; /** `create` when the file did not exist before this edit. */
|
|
371
|
+
kind?: 'create' | 'update';
|
|
372
|
+
hunks: PatchHunk[];
|
|
373
|
+
/** Hunks were dropped to keep the event small. A renderer must say so rather
|
|
374
|
+
* than present a partial diff as the whole change. */
|
|
375
|
+
truncated?: boolean;
|
|
376
|
+
};
|
|
272
377
|
type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock;
|
|
273
378
|
/**
|
|
274
379
|
* A file the user attached to a message — a photo, a screenshot, a document.
|
|
@@ -567,6 +672,28 @@ type SessionEventBody = /** SDK init handshake: what this session actually is. *
|
|
|
567
672
|
| {
|
|
568
673
|
type: 'plan_info';
|
|
569
674
|
subscriptionType: string;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The engine started a **fresh conversation inside the same session** — the
|
|
678
|
+
* CLI's `/clear`, a plan-mode exit, and whatever fresh-conversation flows the
|
|
679
|
+
* SDK grows. The session id, registry row, workspace and scope are all
|
|
680
|
+
* unchanged; only the conversation is new. Clients empty the transcript and
|
|
681
|
+
* keep every session-scoped fact (models, commands, skills, produced files,
|
|
682
|
+
* rate limits, cwd, permission mode).
|
|
683
|
+
*
|
|
684
|
+
* The server's replay honours it too: an attach after a reset does not
|
|
685
|
+
* resurrect the cleared rows, because the runner skips *transcript content*
|
|
686
|
+
* below the latest reset (see {@link transcriptContent}) while still
|
|
687
|
+
* replaying every state-bearing event. `SessionInfo.activityCount` stays
|
|
688
|
+
* monotonic across a reset on purpose — it is an unread cursor, not an item
|
|
689
|
+
* count, and winding it back would kill every stored watermark above it.
|
|
690
|
+
*/
|
|
691
|
+
| {
|
|
692
|
+
type: 'conversation_reset';
|
|
693
|
+
/** The engine session id the fresh conversation runs under (the SDK's
|
|
694
|
+
* `new_conversation_id`), when the engine reported one. The follow-up
|
|
695
|
+
* `system_init` remains authoritative. */
|
|
696
|
+
sdkSessionId?: string;
|
|
570
697
|
} | {
|
|
571
698
|
type: 'assistant_message';
|
|
572
699
|
message: ApiMessage; /** Set when the message was produced inside a subagent (Task tool). */
|
|
@@ -583,6 +710,16 @@ type SessionEventBody = /** SDK init handshake: what this session actually is. *
|
|
|
583
710
|
* `message.content` carries the typed text only — the attachment bytes went
|
|
584
711
|
* to the model, not into this log. */
|
|
585
712
|
attachments?: MessageAttachment[];
|
|
713
|
+
/**
|
|
714
|
+
* What a file-editing tool changed, when this message carries that tool's
|
|
715
|
+
* result (see {@link FilePatch}). Set by the runner from the engine's own
|
|
716
|
+
* structured output — never derived by a client from the result text.
|
|
717
|
+
*
|
|
718
|
+
* Only when the message carries exactly one `tool_result` block, which is
|
|
719
|
+
* what both engines send: with two, there is nothing that says which one
|
|
720
|
+
* the patch belongs to, and guessing would attach a diff to the wrong call.
|
|
721
|
+
*/
|
|
722
|
+
patch?: FilePatch;
|
|
586
723
|
uuid?: string;
|
|
587
724
|
}
|
|
588
725
|
/** Raw Anthropic streaming event (message_start/content_block_delta/...); emitted only
|
|
@@ -924,6 +1061,37 @@ type ProfileSessionDefaults = {
|
|
|
924
1061
|
mcpServers?: string[]; /** Prepended to the session's system prompt. */
|
|
925
1062
|
instructions?: string;
|
|
926
1063
|
};
|
|
1064
|
+
/**
|
|
1065
|
+
* One rate-limit window of a profile's plan, as the *gateway* last saw it — the
|
|
1066
|
+
* newest {@link RateLimitInfo} any session on the profile reported, across every
|
|
1067
|
+
* session, live or since closed. The profile is the account boundary (one config
|
|
1068
|
+
* dir / codex home / provider key = one plan), so this is the single usage state
|
|
1069
|
+
* per account, where a session's own transcript only knows what *it* was last
|
|
1070
|
+
* told.
|
|
1071
|
+
*
|
|
1072
|
+
* Two rules a client must keep:
|
|
1073
|
+
* - An absent window (or an absent {@link ProfileInfo.usage} entirely) is
|
|
1074
|
+
* **unknown, not 0%** — render nothing, exactly as for session-level readings.
|
|
1075
|
+
* The map is in-memory and starts empty on a cold server.
|
|
1076
|
+
* - `inferredReset` marks a reading the server zeroed at serve time because the
|
|
1077
|
+
* reading's own `resetsAt` passed with nothing newer: the pre-reset number is
|
|
1078
|
+
* then provably wrong, and 0 is the truthful *floor* (the account may have
|
|
1079
|
+
* been used outside this gateway since). Distinguishable on the wire from an
|
|
1080
|
+
* engine-reported 0, which carries no flag.
|
|
1081
|
+
*/
|
|
1082
|
+
type ProfileUsageWindow = {
|
|
1083
|
+
/** The reading, exactly as the session event carried it — except after an
|
|
1084
|
+
* elapsed reset, when `utilization` is 0 and `resetsAt` is dropped (the old
|
|
1085
|
+
* one names the *previous* window; a countdown from it would be nonsense). */
|
|
1086
|
+
info: RateLimitInfo;
|
|
1087
|
+
/** Epoch ms of the event that carried the reading — honest for "Updated …"
|
|
1088
|
+
* lines even when the served utilization is inferred. */
|
|
1089
|
+
updatedAt: number; /** Present (true) only on the served-as-0 inference described above. */
|
|
1090
|
+
inferredReset?: boolean;
|
|
1091
|
+
};
|
|
1092
|
+
/** Per-window plan usage, keyed by `rateLimitType` ('five_hour', 'seven_day',
|
|
1093
|
+
* ...) — the same keying as a transcript's rate-limit state. */
|
|
1094
|
+
type ProfileUsage = Record<string, ProfileUsageWindow>;
|
|
927
1095
|
type ProfileInfo = {
|
|
928
1096
|
/** Unique name, used as {@link CreateSessionRequest.profile}. */name: string;
|
|
929
1097
|
/** Engine this profile runs on. Defaults to 'claude' when absent, so profiles
|
|
@@ -961,6 +1129,11 @@ type ProfileInfo = {
|
|
|
961
1129
|
/** Response-only: one operator-actionable line, present only when
|
|
962
1130
|
* `available === false`. */
|
|
963
1131
|
unavailableReason?: string;
|
|
1132
|
+
/** Response-only: the plan's rate-limit windows as last reported by any
|
|
1133
|
+
* session on this profile (see {@link ProfileUsageWindow}). Absent = unknown
|
|
1134
|
+
* — no session has reported yet (API-key sessions never do), or the server
|
|
1135
|
+
* restarted. **Display-only**, like `available`: never a gate. */
|
|
1136
|
+
usage?: ProfileUsage;
|
|
964
1137
|
/** Response-only, computed by the server: this profile came from the profile
|
|
965
1138
|
* store and can be edited or deleted through the API. Profiles declared in
|
|
966
1139
|
* server options are absent/false — they are code. Ignored on the way in. */
|
|
@@ -1172,6 +1345,13 @@ type SessionInfo = {
|
|
|
1172
1345
|
* `lastSeq` cannot either — it counts every event, and with token streaming on
|
|
1173
1346
|
* that is hundreds per reply. Absent on an older server; a client should fall
|
|
1174
1347
|
* back to `numTurns` rather than showing nothing.
|
|
1348
|
+
*
|
|
1349
|
+
* Monotonic for the session's whole life, **including across a
|
|
1350
|
+
* `conversation_reset`**: after a `/clear` this deliberately exceeds the
|
|
1351
|
+
* number of rows a fresh attach renders. It is an unread *cursor* diffed
|
|
1352
|
+
* against stored monotonic watermarks (see `watermarks.ts`) — resetting it to
|
|
1353
|
+
* the new row count would leave every stored mark above it, and that
|
|
1354
|
+
* session's badge dead until the count caught back up.
|
|
1175
1355
|
*/
|
|
1176
1356
|
activityCount?: number; /** Epoch ms of the most recent emitted event. */
|
|
1177
1357
|
lastActivityAt?: number;
|
|
@@ -1196,6 +1376,80 @@ type SessionInfo = {
|
|
|
1196
1376
|
* reducer's row rule changes, change this with it.
|
|
1197
1377
|
*/
|
|
1198
1378
|
declare function transcriptActivity(body: SessionEventBody): number;
|
|
1379
|
+
/**
|
|
1380
|
+
* Whether an event is **transcript content** — whether the reducer
|
|
1381
|
+
* (`@workerdeck/react`'s `transcript.ts`, and its Swift mirror) mutates
|
|
1382
|
+
* `items` when it applies it. The rule behind `conversation_reset`'s replay
|
|
1383
|
+
* semantics: the runner keeps its whole event log, but `subscribe()` skips
|
|
1384
|
+
* content below the latest reset so an attaching client does not resurrect a
|
|
1385
|
+
* cleared conversation — while every *state-bearing* event (`system_init`,
|
|
1386
|
+
* `capabilities`, `skills`, `status_changed`, usage and rate-limit readings,
|
|
1387
|
+
* `file_produced`, permission bookkeeping) still replays, because a fresh
|
|
1388
|
+
* attacher with no model list and no cwd is broken, not cleared.
|
|
1389
|
+
*
|
|
1390
|
+
* Deliberately **broader than `transcriptActivity() > 0`**: stream deltas,
|
|
1391
|
+
* tool results (synthetic user messages) and execution lifecycle events count
|
|
1392
|
+
* zero rows but still mutate items — replaying them across a reset would leave
|
|
1393
|
+
* orphaned deltas and results with no parent message.
|
|
1394
|
+
*
|
|
1395
|
+
* `conversation_reset` itself is content under this rule, and that is load-
|
|
1396
|
+
* bearing twice: a *superseded* reset (below a newer one) is skipped with the
|
|
1397
|
+
* conversation it cleared, while the latest reset always replays (the skip is
|
|
1398
|
+
* strictly-below), which is what clears a reconnecting client that still holds
|
|
1399
|
+
* pre-reset rows.
|
|
1400
|
+
*
|
|
1401
|
+
* Lives here beside {@link transcriptActivity} for the same reason: the
|
|
1402
|
+
* reducer owns the rule and the runners filter with it, and the two sides may
|
|
1403
|
+
* not import each other. If the reducer's items-mutating set changes, change
|
|
1404
|
+
* this with it. Unknown/future event types are NOT content — the safe failure
|
|
1405
|
+
* is replaying a stale row, never withholding state.
|
|
1406
|
+
*/
|
|
1407
|
+
declare function transcriptContent(body: SessionEventBody): boolean;
|
|
1408
|
+
/**
|
|
1409
|
+
* The dedupe key for an event that is **last-write-wins** on replay, or
|
|
1410
|
+
* `undefined` for one that must always be delivered.
|
|
1411
|
+
*
|
|
1412
|
+
* The problem: the runner polls context usage and the plan's rate limits after
|
|
1413
|
+
* every turn, so a fifty-turn session's log holds fifty context readings and
|
|
1414
|
+
* fifty per rate-limit window. Replaying all of them is not merely wasteful —
|
|
1415
|
+
* it is *visible*. A client applies each in turn, so opening a session shows
|
|
1416
|
+
* the usage meters counting up from the session's first reading to its last
|
|
1417
|
+
* over the length of the replay, announcing history as if it were news.
|
|
1418
|
+
*
|
|
1419
|
+
* The fix is a backwards scan over the buffered log keeping the first
|
|
1420
|
+
* occurrence of each key, which is `staleReplaySeqs` in `@workerdeck/core`.
|
|
1421
|
+
* The key is per *window* for rate limits, not per event type: the reducer
|
|
1422
|
+
* stores them keyed by window ("so five_hour and seven_day updates don't
|
|
1423
|
+
* clobber each other"), so a single key would keep only the most recently
|
|
1424
|
+
* polled window and silently drop the others.
|
|
1425
|
+
*
|
|
1426
|
+
* **This is a claim about the reducer**, which is why it lives here rather
|
|
1427
|
+
* than in core: only the server coalesces, but only `@workerdeck/react` can
|
|
1428
|
+
* prove the rule correct, and neither package may import the other. The
|
|
1429
|
+
* property that must hold is that coalescing is *unobservable* — folding the
|
|
1430
|
+
* full log and the coalesced log through `applyEvent` yields identical state.
|
|
1431
|
+
* `packages/react/test/replay-coalesce.test.ts` asserts exactly that, over
|
|
1432
|
+
* every event kind. Extend the rule only with a case that test still passes.
|
|
1433
|
+
*
|
|
1434
|
+
* Three kinds are deliberately **excluded** despite looking eligible:
|
|
1435
|
+
*
|
|
1436
|
+
* - `capabilities` — `defaultModel: event.defaultModel ?? base.defaultModel`
|
|
1437
|
+
* is a fallback *merge*, so a later event without one would erase an earlier
|
|
1438
|
+
* event's. (It is also emitted once per session, so there is nothing to win.)
|
|
1439
|
+
* - `model_changed` — `undefined` means "reset to the server default" and the
|
|
1440
|
+
* reducer *keeps* the last known model, so the last event alone is not the
|
|
1441
|
+
* same as the fold.
|
|
1442
|
+
* - `system_init` — pure replace for the reducer, but the server's
|
|
1443
|
+
* `watchAuthSource` reads the **first** one to decide an auth policy, and
|
|
1444
|
+
* parking treats each as a resume point.
|
|
1445
|
+
*
|
|
1446
|
+
* Coalescing never drops the highest-seq event, and that is load-bearing
|
|
1447
|
+
* rather than incidental: the globally-last event is by definition the last of
|
|
1448
|
+
* its own key, so it always survives. `useClaudeSession`'s replay hold waits
|
|
1449
|
+
* for `state.lastSeq` to reach the attach's `session.lastSeq`, and would hang
|
|
1450
|
+
* on a blank panel forever if a coalescer could swallow the final event.
|
|
1451
|
+
*/
|
|
1452
|
+
declare function replayCoalesceKey(body: SessionEventBody): string | undefined;
|
|
1199
1453
|
/**
|
|
1200
1454
|
* A session in an engine's on-disk store (independent of this server's registry):
|
|
1201
1455
|
* the Agent SDK's session files, or a codex profile's CODEX_HOME threads. Listed
|
|
@@ -1631,5 +1885,5 @@ type QueueStatsResponse = {
|
|
|
1631
1885
|
stats: QueueStats;
|
|
1632
1886
|
};
|
|
1633
1887
|
//#endregion
|
|
1634
|
-
export { ApiMessage, AttachedFrame, ClientFrame, ContentBlock, ContextUsage, ContextUsageCategory, CreateJobRequest, CreateJobResponse, CreateProfileRequest, CreateSessionRequest, CreateSessionResponse, DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, EngineCapabilities, ErrorResponse, Facet, FindHostFilesResponse, GetJobResponse, GetProfileResponse, GetSessionResponse, GroupBy, HostDirEntry, HostFileMatch, HostFileRoot, JobEvent, JobInfo, JobProgress, JobResult, JobStatus, JobUsage, ListHostDirResponse, ListHostRootsResponse, ListJobsResponse, ListProfilesResponse, ListSdkSessionsResponse, ListSessionFilesResponse, ListSessionsResponse, McpServerActionRequest, McpServerConfigWire, McpServerStatusInfo, McpServerToolInfo, McpServersResponse, MessageAttachment, ModelOption, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, PermissionDecisionSource, PermissionMode, PermissionRequest, ProfileConfigSnapshot, ProfileDefaults, ProfileEngine, ProfileInfo, ProfileSessionDefaults, ProviderConfig, QuestionBehavior, QueueServerFrame, QueueStats, QueueStatsResponse, RateLimitInfo, ReadHostFileResponse, ResolvePermissionRequest, ResolvePermissionResponse, STATE_LABELS, STATE_ORDER, SaveProfileResponse, ScopeRoot, SdkSessionSummary, ServerFrame, SessionCapability, SessionCommand, SessionEvent, SessionEventBody, SessionFileInfo, SessionGroup, SessionInfo, SessionNotification, SessionNotificationType, SessionRow, SessionState, SessionStatus, SessionWebhookConfig, SkillInfo, SlashCommandInfo, SortBy, SubmitExecutionResultRequest, SubmitExecutionResultResponse, SubsetSummary, TextBlock, ThinkingBlock, ToolCallRequestFrame, ToolExecutionBackend, ToolExecutionOutput, ToolExecutionStatus, ToolResultBlock, ToolUseBlock, UnknownBlock, UpdateProfileRequest, UpdateSessionRequest, UpdateSessionResponse, UploadAttachmentResponse, UserQuestion, UserQuestionOption, ViewConfig, Watermark, WatermarkStore, Watermarks, WebhookConfig, WorkspaceScope, WriteHostFileRequest, WriteHostFileResponse, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, unseenCount, watermarkKey };
|
|
1888
|
+
export { ApiMessage, AttachedFrame, ClientFrame, ContentBlock, ContextUsage, ContextUsageCategory, CreateJobRequest, CreateJobResponse, CreateProfileRequest, CreateSessionRequest, CreateSessionResponse, DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, EngineCapabilities, ErrorResponse, Facet, FilePatch, FindHostFilesResponse, GetJobResponse, GetProfileResponse, GetSessionResponse, GroupBy, HostDirEntry, HostFileMatch, HostFileRoot, JobEvent, JobInfo, JobProgress, JobResult, JobStatus, JobUsage, ListHostDirResponse, ListHostRootsResponse, ListJobsResponse, ListProfilesResponse, ListSdkSessionsResponse, ListSessionFilesResponse, ListSessionsResponse, McpServerActionRequest, McpServerConfigWire, McpServerStatusInfo, McpServerToolInfo, McpServersResponse, MessageAttachment, ModelOption, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, PatchHunk, PermissionDecisionSource, PermissionMode, PermissionRequest, ProfileConfigSnapshot, ProfileDefaults, ProfileEngine, ProfileInfo, ProfileSessionDefaults, ProfileUsage, ProfileUsageWindow, ProviderConfig, QuestionBehavior, QueueServerFrame, QueueStats, QueueStatsResponse, RateLimitInfo, ReadHostFileResponse, ResolvePermissionRequest, ResolvePermissionResponse, STATE_LABELS, STATE_ORDER, SaveProfileResponse, ScopeRoot, SdkSessionSummary, ServerFrame, SessionCapability, SessionCommand, SessionEvent, SessionEventBody, SessionFileInfo, SessionGroup, SessionInfo, SessionNotification, SessionNotificationType, SessionRow, SessionState, SessionStatus, SessionUsage, SessionWebhookConfig, SkillInfo, SlashCommandInfo, SortBy, SubmitExecutionResultRequest, SubmitExecutionResultResponse, SubsetSummary, TextBlock, ThinkingBlock, ToolCallRequestFrame, ToolExecutionBackend, ToolExecutionOutput, ToolExecutionStatus, ToolResultBlock, ToolUseBlock, UnknownBlock, UpdateProfileRequest, UpdateSessionRequest, UpdateSessionResponse, UploadAttachmentResponse, UsageWindowRow, UserQuestion, UserQuestionOption, ViewConfig, Watermark, WatermarkStore, Watermarks, WebhookConfig, WorkspaceScope, WriteHostFileRequest, WriteHostFileResponse, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, mergeUsage, orderUsageWindows, replayCoalesceKey, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, transcriptContent, unseenCount, usageInfos, watermarkKey };
|
|
1635
1889
|
//# sourceMappingURL=index.d.mts.map
|
package/build/index.mjs
CHANGED
|
@@ -160,6 +160,74 @@ function clearFilters(config) {
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
//#endregion
|
|
163
|
+
//#region src/usage.ts
|
|
164
|
+
/**
|
|
165
|
+
* The usage a client should render: the gateway's per-profile state where it has
|
|
166
|
+
* the window, this session's own reading where it does not.
|
|
167
|
+
*
|
|
168
|
+
* Why the profile wins outright rather than by comparing timestamps: the
|
|
169
|
+
* gateway's `ProfileUsageTracker` is fed from **every** session on the profile —
|
|
170
|
+
* including this one, from seq 0 — and keeps the newest reading per window by
|
|
171
|
+
* the event's own `ts`. So for any window it holds, it holds a reading at least
|
|
172
|
+
* as new as the one in this transcript, and a timestamp comparison could only
|
|
173
|
+
* ever go wrong: the reducer keeps a *single* `updatedAt` for the whole map, so
|
|
174
|
+
* a `five_hour` reading from this morning is dated with the afternoon's
|
|
175
|
+
* `seven_day` event and would beat a genuinely fresher profile entry.
|
|
176
|
+
*
|
|
177
|
+
* The session half is not a fallback for correctness but for *coverage*: the
|
|
178
|
+
* profile map is in-memory, so a restarted gateway serves nothing until a
|
|
179
|
+
* session reports again, and a session with no profile has no account state at
|
|
180
|
+
* all. In both cases the transcript's reading is the only one there is, and it
|
|
181
|
+
* is dated honestly (see {@link SessionUsage.updatedAt}) rather than as now.
|
|
182
|
+
*
|
|
183
|
+
* Absent stays absent throughout: a window nobody has reported is **unknown,
|
|
184
|
+
* never 0%**, and this returns an empty map rather than inventing entries.
|
|
185
|
+
*/
|
|
186
|
+
function mergeUsage(session, profile) {
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const [key, info] of Object.entries(session.rateLimits ?? {})) out[key] = {
|
|
189
|
+
info,
|
|
190
|
+
updatedAt: session.updatedAt ?? 0
|
|
191
|
+
};
|
|
192
|
+
for (const [key, window] of Object.entries(profile ?? {})) out[key] = window;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The windows in reading order: the session window, the weekly one, then the
|
|
197
|
+
* per-model weeklies alphabetically.
|
|
198
|
+
*
|
|
199
|
+
* Discovered rather than hardcoded — the engine's set of windows is an open
|
|
200
|
+
* union and has grown before — but ordered, so the first two always mean the
|
|
201
|
+
* same thing wherever they are drawn. A window with no `utilization` is
|
|
202
|
+
* **unknown, not zero**, and is dropped entirely rather than rendered as an
|
|
203
|
+
* empty bar that reads as "plenty left".
|
|
204
|
+
*
|
|
205
|
+
* Here rather than in a client because two surfaces now render the same windows
|
|
206
|
+
* from different sources — the session panel from its merged state, the
|
|
207
|
+
* dashboard's profile page straight off `ProfileInfo.usage` — and a list that
|
|
208
|
+
* ordered or filtered differently would be the same account described two ways.
|
|
209
|
+
*/
|
|
210
|
+
function orderUsageWindows(usage) {
|
|
211
|
+
const all = Object.entries(usage ?? {}).filter(([, w]) => w.info.utilization !== void 0).map(([key, w]) => ({
|
|
212
|
+
key,
|
|
213
|
+
info: w.info,
|
|
214
|
+
updatedAt: w.updatedAt,
|
|
215
|
+
inferredReset: w.inferredReset
|
|
216
|
+
}));
|
|
217
|
+
const named = ["five_hour", "seven_day"].flatMap((key) => all.filter((w) => w.key === key));
|
|
218
|
+
const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
|
|
219
|
+
return [...named, ...perModel];
|
|
220
|
+
}
|
|
221
|
+
/** The flat `rateLimitType → reading` map every existing renderer takes, out of
|
|
222
|
+
* the dated form. Undefined in, undefined out — so a surface can keep telling
|
|
223
|
+
* "no reading" apart from "an empty one". */
|
|
224
|
+
function usageInfos(usage) {
|
|
225
|
+
if (!usage) return void 0;
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [key, window] of Object.entries(usage)) out[key] = window.info;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
163
231
|
//#region src/watermarks.ts
|
|
164
232
|
/** Entries older than this are dropped on write — a session deleted months ago
|
|
165
233
|
* should not keep a row in storage forever. */
|
|
@@ -402,7 +470,103 @@ function transcriptActivity(body) {
|
|
|
402
470
|
default: return 0;
|
|
403
471
|
}
|
|
404
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Whether an event is **transcript content** — whether the reducer
|
|
475
|
+
* (`@workerdeck/react`'s `transcript.ts`, and its Swift mirror) mutates
|
|
476
|
+
* `items` when it applies it. The rule behind `conversation_reset`'s replay
|
|
477
|
+
* semantics: the runner keeps its whole event log, but `subscribe()` skips
|
|
478
|
+
* content below the latest reset so an attaching client does not resurrect a
|
|
479
|
+
* cleared conversation — while every *state-bearing* event (`system_init`,
|
|
480
|
+
* `capabilities`, `skills`, `status_changed`, usage and rate-limit readings,
|
|
481
|
+
* `file_produced`, permission bookkeeping) still replays, because a fresh
|
|
482
|
+
* attacher with no model list and no cwd is broken, not cleared.
|
|
483
|
+
*
|
|
484
|
+
* Deliberately **broader than `transcriptActivity() > 0`**: stream deltas,
|
|
485
|
+
* tool results (synthetic user messages) and execution lifecycle events count
|
|
486
|
+
* zero rows but still mutate items — replaying them across a reset would leave
|
|
487
|
+
* orphaned deltas and results with no parent message.
|
|
488
|
+
*
|
|
489
|
+
* `conversation_reset` itself is content under this rule, and that is load-
|
|
490
|
+
* bearing twice: a *superseded* reset (below a newer one) is skipped with the
|
|
491
|
+
* conversation it cleared, while the latest reset always replays (the skip is
|
|
492
|
+
* strictly-below), which is what clears a reconnecting client that still holds
|
|
493
|
+
* pre-reset rows.
|
|
494
|
+
*
|
|
495
|
+
* Lives here beside {@link transcriptActivity} for the same reason: the
|
|
496
|
+
* reducer owns the rule and the runners filter with it, and the two sides may
|
|
497
|
+
* not import each other. If the reducer's items-mutating set changes, change
|
|
498
|
+
* this with it. Unknown/future event types are NOT content — the safe failure
|
|
499
|
+
* is replaying a stale row, never withholding state.
|
|
500
|
+
*/
|
|
501
|
+
function transcriptContent(body) {
|
|
502
|
+
switch (body.type) {
|
|
503
|
+
case "user_message":
|
|
504
|
+
case "assistant_message":
|
|
505
|
+
case "stream_delta":
|
|
506
|
+
case "turn_result":
|
|
507
|
+
case "execution_dispatched":
|
|
508
|
+
case "execution_result":
|
|
509
|
+
case "execution_failed":
|
|
510
|
+
case "file_delivered":
|
|
511
|
+
case "session_error":
|
|
512
|
+
case "session_closed":
|
|
513
|
+
case "conversation_reset": return true;
|
|
514
|
+
default: return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* The dedupe key for an event that is **last-write-wins** on replay, or
|
|
519
|
+
* `undefined` for one that must always be delivered.
|
|
520
|
+
*
|
|
521
|
+
* The problem: the runner polls context usage and the plan's rate limits after
|
|
522
|
+
* every turn, so a fifty-turn session's log holds fifty context readings and
|
|
523
|
+
* fifty per rate-limit window. Replaying all of them is not merely wasteful —
|
|
524
|
+
* it is *visible*. A client applies each in turn, so opening a session shows
|
|
525
|
+
* the usage meters counting up from the session's first reading to its last
|
|
526
|
+
* over the length of the replay, announcing history as if it were news.
|
|
527
|
+
*
|
|
528
|
+
* The fix is a backwards scan over the buffered log keeping the first
|
|
529
|
+
* occurrence of each key, which is `staleReplaySeqs` in `@workerdeck/core`.
|
|
530
|
+
* The key is per *window* for rate limits, not per event type: the reducer
|
|
531
|
+
* stores them keyed by window ("so five_hour and seven_day updates don't
|
|
532
|
+
* clobber each other"), so a single key would keep only the most recently
|
|
533
|
+
* polled window and silently drop the others.
|
|
534
|
+
*
|
|
535
|
+
* **This is a claim about the reducer**, which is why it lives here rather
|
|
536
|
+
* than in core: only the server coalesces, but only `@workerdeck/react` can
|
|
537
|
+
* prove the rule correct, and neither package may import the other. The
|
|
538
|
+
* property that must hold is that coalescing is *unobservable* — folding the
|
|
539
|
+
* full log and the coalesced log through `applyEvent` yields identical state.
|
|
540
|
+
* `packages/react/test/replay-coalesce.test.ts` asserts exactly that, over
|
|
541
|
+
* every event kind. Extend the rule only with a case that test still passes.
|
|
542
|
+
*
|
|
543
|
+
* Three kinds are deliberately **excluded** despite looking eligible:
|
|
544
|
+
*
|
|
545
|
+
* - `capabilities` — `defaultModel: event.defaultModel ?? base.defaultModel`
|
|
546
|
+
* is a fallback *merge*, so a later event without one would erase an earlier
|
|
547
|
+
* event's. (It is also emitted once per session, so there is nothing to win.)
|
|
548
|
+
* - `model_changed` — `undefined` means "reset to the server default" and the
|
|
549
|
+
* reducer *keeps* the last known model, so the last event alone is not the
|
|
550
|
+
* same as the fold.
|
|
551
|
+
* - `system_init` — pure replace for the reducer, but the server's
|
|
552
|
+
* `watchAuthSource` reads the **first** one to decide an auth policy, and
|
|
553
|
+
* parking treats each as a resume point.
|
|
554
|
+
*
|
|
555
|
+
* Coalescing never drops the highest-seq event, and that is load-bearing
|
|
556
|
+
* rather than incidental: the globally-last event is by definition the last of
|
|
557
|
+
* its own key, so it always survives. `useClaudeSession`'s replay hold waits
|
|
558
|
+
* for `state.lastSeq` to reach the attach's `session.lastSeq`, and would hang
|
|
559
|
+
* on a blank panel forever if a coalescer could swallow the final event.
|
|
560
|
+
*/
|
|
561
|
+
function replayCoalesceKey(body) {
|
|
562
|
+
switch (body.type) {
|
|
563
|
+
case "context_usage": return "context_usage";
|
|
564
|
+
case "rate_limit": return body.info.rateLimitType ? `rate_limit:${body.info.rateLimitType}` : void 0;
|
|
565
|
+
case "status_changed": return "status_changed";
|
|
566
|
+
default: return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
405
569
|
//#endregion
|
|
406
|
-
export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, Watermarks, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, unseenCount, watermarkKey };
|
|
570
|
+
export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, Watermarks, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, mergeUsage, orderUsageWindows, replayCoalesceKey, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, transcriptContent, unseenCount, usageInfos, watermarkKey };
|
|
407
571
|
|
|
408
572
|
//# sourceMappingURL=index.mjs.map
|
package/build/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["#store","#cache","#prune"],"sources":["../src/session-list.ts","../src/watermarks.ts","../src/index.ts"],"sourcesContent":["import type { SessionInfo } from './index.ts'\n\n/**\n * How a sessions list is filtered, grouped and sorted — the whole of the view\n * config, kept pure and separate from the components so every surface renders\n * one derived list and nothing else decides what is visible.\n *\n * Framework-free like `transcript.ts`, and for the same reason: more than one\n * party has to agree. In the VS Code extension the webview renders the list and\n * the extension host counts the activity-bar badge over the same rows (a badge\n * that ignored the filter would announce work in sessions the list is\n * deliberately hiding); in the dashboard the list and its subset line derive\n * from it; on iOS it is mirrored to Swift the way the reducer is.\n *\n * Sessions are shown across ALL gateways by default; the gateway is a facet like\n * any other, not the frame the list lives in.\n */\n\n/** Coarse lifecycle bucket — what a person actually filters on. Raw statuses are\n * too many and too engine-shaped ('starting' vs 'running' is not a decision). */\nexport type SessionState = 'attention' | 'working' | 'idle' | 'ended'\n\nexport const STATE_ORDER: readonly SessionState[] = ['attention', 'working', 'idle', 'ended']\n\nexport const STATE_LABELS: Record<SessionState, string> = {\n attention: 'Needs attention',\n working: 'Working',\n idle: 'Idle',\n ended: 'Ended',\n}\n\nexport function sessionState(info: SessionInfo): SessionState {\n if (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') return 'attention'\n if (info.status === 'running' || info.status === 'starting') return 'working'\n if (info.status === 'failed' || info.status === 'closed') return 'ended'\n return 'idle'\n}\n\n/** The facets a session can be grouped or sorted by. */\nexport type Facet = 'gateway' | 'adapter' | 'state'\nexport type GroupBy = 'none' | Facet\nexport type SortBy = 'recent' | 'name' | Facet\n\nexport type ViewConfig = {\n search: string\n /** Empty = no filter. Ids, not names: names are editable. */\n gateways: string[]\n adapters: string[]\n states: SessionState[]\n /** Show only sessions inside the host's own folders. Inert where there is no\n * such notion (no folder open, a dashboard with no workspace), which is why it\n * can default on. */\n scoped: boolean\n groupBy: GroupBy\n sortBy: SortBy\n}\n\nexport const DEFAULT_VIEW_CONFIG: ViewConfig = {\n search: '',\n gateways: [],\n adapters: [],\n states: [],\n scoped: true,\n groupBy: 'state',\n sortBy: 'recent',\n}\n\n/**\n * One folder the surrounding host has open, as a place sessions can live in.\n *\n * `hostId` present = the folder belongs to exactly that gateway. Absent = a real\n * local folder, which only a loopback gateway's cwds can be inside: a remote\n * gateway's paths are on another machine, where an identical-looking path means\n * nothing.\n */\nexport type ScopeRoot = { hostId?: string; path: string }\n\n/** The host's own folders — the sessions list's intrinsic scope. */\nexport type WorkspaceScope = { label: string; roots: ScopeRoot[] }\n\n/** A session with everything the list needs to filter, group and label it. */\nexport type SessionRow = {\n hostId: string\n hostName: string\n /** Its gateway is loopback — its cwds are paths on this machine. */\n local: boolean\n adapter: string\n state: SessionState\n info: SessionInfo\n /** Transcript rows since this session was last on screen. 0 = nothing new (or\n * never visited, which is not the same as unread). */\n unseen: number\n}\n\nexport type SessionGroup = { key: string; label?: string; rows: SessionRow[] }\n\n/** The adapters actually present, for the filter chips — derived rather than\n * enumerated, so a new engine needs no change here. */\nexport function adaptersOf(rows: readonly SessionRow[]): string[] {\n return [...new Set(rows.map((r) => r.adapter))].sort()\n}\n\nexport function sessionLabel(info: SessionInfo): string {\n return info.title ?? info.id.slice(0, 8)\n}\n\n/**\n * This session is a job run — the queue created it, and `JobInfo.sessionId`\n * points at it.\n *\n * A job run is an ordinary registry session in every other respect, which is\n * what makes this worth spelling once: a client that renders jobs on their own\n * surface should not list them again among the sessions, and a client with no\n * jobs surface (the extension, the phone) should, or they would be invisible.\n * The queue stamps `meta.jobId`; nothing else may write that key.\n */\nexport function isJobRun(info: SessionInfo): boolean {\n return typeof info.meta?.jobId === 'string'\n}\n\nfunction matchesSearch(row: SessionRow, needle: string): boolean {\n if (!needle) return true\n return (\n sessionLabel(row.info).toLowerCase().includes(needle) ||\n row.info.cwd.toLowerCase().includes(needle) ||\n row.hostName.toLowerCase().includes(needle) ||\n row.adapter.toLowerCase().includes(needle) ||\n row.info.id.startsWith(needle)\n )\n}\n\n/** Trailing separators dropped and separators unified, so containment is a\n * plain prefix test on both a posix and a Windows gateway. */\nfunction normalizePath(path: string): string {\n return path.replace(/\\\\/g, '/').replace(/\\/+$/, '')\n}\n\nfunction isWithin(root: string, path: string): boolean {\n const base = normalizePath(root)\n const dir = normalizePath(path)\n // The separator matters: /a/project must not swallow /a/project-2.\n return dir === base || dir.startsWith(`${base}/`)\n}\n\n/**\n * Is this session inside one of the host's folders? A gateway-tagged root only\n * ever matches its own gateway; an untagged one only matches a loopback gateway,\n * because a remote gateway's identical-looking path is a different machine's\n * directory.\n */\nexport function inScope(row: SessionRow, scope: WorkspaceScope): boolean {\n return scope.roots.some(\n (root) =>\n (root.hostId ? root.hostId.toLowerCase() === row.hostId.toLowerCase() : row.local) &&\n isWithin(root.path, row.info.cwd),\n )\n}\n\n/** Whether the scope filter is actually hiding anything — it is inert with no\n * folder open, and that is the difference between a default and a filter. */\nexport function scopeActive(config: ViewConfig, scope: WorkspaceScope | undefined): boolean {\n return config.scoped && scope !== undefined\n}\n\nexport function filterRows(\n rows: readonly SessionRow[],\n config: ViewConfig,\n scope?: WorkspaceScope,\n): SessionRow[] {\n const needle = config.search.trim().toLowerCase()\n const scoping = scopeActive(config, scope) ? scope : undefined\n return rows.filter(\n (row) =>\n (config.gateways.length === 0 || config.gateways.includes(row.hostId)) &&\n (config.adapters.length === 0 || config.adapters.includes(row.adapter)) &&\n (config.states.length === 0 || config.states.includes(row.state)) &&\n (!scoping || inScope(row, scoping)) &&\n matchesSearch(row, needle),\n )\n}\n\nfunction facetKey(row: SessionRow, facet: Facet): string {\n return facet === 'gateway' ? row.hostId : facet === 'adapter' ? row.adapter : row.state\n}\n\nfunction facetLabel(row: SessionRow, facet: Facet): string {\n return facet === 'gateway'\n ? row.hostName\n : facet === 'adapter'\n ? row.adapter\n : STATE_LABELS[row.state]\n}\n\n/** Comparable rank for a facet: states run worst-first (attention before ended),\n * the rest alphabetically by their visible label. */\nfunction facetRank(row: SessionRow, facet: Facet): string {\n if (facet === 'state') return String(STATE_ORDER.indexOf(row.state))\n return facetLabel(row, facet).toLowerCase()\n}\n\nconst byRecency = (a: SessionRow, b: SessionRow) =>\n (b.info.lastActivityAt ?? b.info.createdAt) - (a.info.lastActivityAt ?? a.info.createdAt)\n\nfunction compare(a: SessionRow, b: SessionRow, sortBy: SortBy): number {\n if (sortBy === 'recent') return byRecency(a, b)\n if (sortBy === 'name') {\n return (\n sessionLabel(a.info).localeCompare(sessionLabel(b.info), undefined, {\n sensitivity: 'base',\n }) || byRecency(a, b)\n )\n }\n return facetRank(a, sortBy).localeCompare(facetRank(b, sortBy)) || byRecency(a, b)\n}\n\n/**\n * The list as rendered: filtered, grouped, and sorted within each group. Groups\n * themselves come out in the sort's own order — grouping by state and sorting by\n * name should still put \"Needs attention\" first, so groups are ordered by their\n * facet rank, never by the row sort.\n */\nexport function groupRows(rows: readonly SessionRow[], config: ViewConfig): SessionGroup[] {\n const sorted = [...rows].sort((a, b) => compare(a, b, config.sortBy))\n if (config.groupBy === 'none') return sorted.length ? [{ key: 'all', rows: sorted }] : []\n const facet = config.groupBy\n const groups = new Map<string, SessionGroup & { rank: string }>()\n for (const row of sorted) {\n const key = facetKey(row, facet)\n const group = groups.get(key)\n if (group) group.rows.push(row)\n else {\n groups.set(key, {\n key,\n label: facetLabel(row, facet),\n rank: facetRank(row, facet),\n rows: [row],\n })\n }\n }\n return [...groups.values()].sort((a, b) => a.rank.localeCompare(b.rank))\n}\n\n/**\n * What the list is hiding, and why — the one \"you are seeing a subset\" signal.\n *\n * There used to be two: a dot on the funnel and a scope line above the list.\n * They competed (the scope line said one thing, the dot counted a superset of\n * it) and neither said how much was missing. This is the single rule both the\n * count and the wording come from: absent when nothing is hidden, and otherwise\n * naming every cause, so the line is never \"12 of 30\" with no way to guess why.\n *\n * Search is a cause like any other. Its box is visible, but the *consequence*\n * of it — rows gone from the list — is the thing being reported, and leaving it\n * out would make the arithmetic wrong.\n */\nexport type SubsetSummary = { shown: number; total: number; causes: string[] }\n\nexport function subsetSummary(\n config: ViewConfig,\n scope: WorkspaceScope | undefined,\n shown: number,\n total: number,\n): SubsetSummary | undefined {\n if (shown >= total) return undefined\n const causes: string[] = []\n if (scope && scopeActive(config, scope)) causes.push(scope.label)\n // The facets collapse to a count: naming three of them would wrap the line in\n // a sidebar, and the funnel beside it is where their detail already lives.\n const facets =\n (config.gateways.length ? 1 : 0) +\n (config.adapters.length ? 1 : 0) +\n (config.states.length ? 1 : 0)\n if (facets > 0) causes.push(`${facets} filter${facets === 1 ? '' : 's'}`)\n if (config.search.trim()) causes.push('search')\n return { shown, total, causes }\n}\n\n/**\n * Is anything OTHER than the workspace scope narrowing the list?\n *\n * The distinction an empty list turns on: \"this project has no sessions\" wants a\n * different sentence, and a different way out, from \"your filters match none\".\n * Scope is excluded because it is on by default — it is the state, not a choice\n * someone made.\n */\nexport function hasFacetFilter(config: ViewConfig): boolean {\n return (\n config.search.trim().length > 0 ||\n config.gateways.length > 0 ||\n config.adapters.length > 0 ||\n config.states.length > 0\n )\n}\n\n/** \"Show me everything\": every filter off, including scope. The group/sort\n * choices are a layout preference and survive. */\nexport function clearFilters(config: ViewConfig): ViewConfig {\n return {\n ...DEFAULT_VIEW_CONFIG,\n scoped: false,\n groupBy: config.groupBy,\n sortBy: config.sortBy,\n }\n}\n","/**\n * \"What had you seen, and when\" — per session, across reloads.\n *\n * Two numbers because two surfaces ask different questions. A session list has\n * only the REST rollup for sessions it isn't showing, so it compares **rows the\n * gateway counted**; a panel has the whole transcript, so it compares **rows it\n * rendered** and can put the mark in the right place. Keeping both means neither\n * surface has to attach to something it isn't rendering.\n *\n * A watermark is only written while a session is genuinely on screen. A surface\n * nobody can see is not being read, and marking it read is how an unread badge\n * silently stops working.\n *\n * Storage is a seam (`WatermarkStore`) rather than a dependency: the VS Code\n * extension backs it with `globalState`, the dashboard with `localStorage`, and\n * neither belongs in this package.\n */\nexport type Watermark = {\n /** Transcript rows seen (`SessionVitals.itemCount`). */\n itemCount: number\n /** Rows the gateway had counted (`SessionInfo.activityCount`) — the same unit\n * as `itemCount`, but from the rollup, so it is knowable for a session this\n * client is not showing. */\n activity: number\n /** Completed turns seen. The fallback unit for a gateway too old to report\n * `activityCount`; five tool calls in one turn count as one. */\n turns: number\n /** When this was last true. */\n seenAt: number\n}\n\n/** Where the marks are kept. Reads happen once at construction; writes are\n * whole-map and may be async — nothing here awaits them. */\nexport type WatermarkStore = {\n read(): Record<string, Watermark> | undefined\n write(marks: Record<string, Watermark>): void\n}\n\n/** Entries older than this are dropped on write — a session deleted months ago\n * should not keep a row in storage forever. */\nconst MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\n/** How stale \"last here\" is allowed to get before a write happens anyway. */\nconst TOUCH_MS = 60_000\n\nexport const watermarkKey = (hostId: string, sessionId: string) => `${hostId}:${sessionId}`\n\nexport class Watermarks {\n readonly #store: WatermarkStore\n #cache: Record<string, Watermark>\n\n constructor(store: WatermarkStore) {\n this.#store = store\n this.#cache = { ...store.read() }\n }\n\n get(hostId: string, sessionId: string): Watermark | undefined {\n return this.#cache[watermarkKey(hostId, sessionId)]\n }\n\n /** Every mark, for a caller deriving unread counts over a whole list. */\n all(): Readonly<Record<string, Watermark>> {\n return this.#cache\n }\n\n /**\n * Record what is on screen now. Monotonic on purpose: a transcript that\n * *shrank* (a compaction, a fresh attach mid-replay) must not walk the mark\n * backwards and resurrect rows the user already read.\n *\n * Returns whether the mark actually moved, because an unread badge is computed\n * from it and nothing else will say so: rows read in a panel do not touch the\n * sessions poll, so a caller that doesn't hear about this has no other way to\n * learn the count is now wrong.\n */\n mark(\n hostId: string,\n sessionId: string,\n seen: { itemCount?: number; activity?: number; turns?: number },\n now = Date.now(),\n ): boolean {\n const id = watermarkKey(hostId, sessionId)\n const previous = this.#cache[id]\n const next: Watermark = {\n itemCount: Math.max(previous?.itemCount ?? 0, seen.itemCount ?? 0),\n activity: Math.max(previous?.activity ?? 0, seen.activity ?? 0),\n turns: Math.max(previous?.turns ?? 0, seen.turns ?? 0),\n seenAt: now,\n }\n if (\n previous &&\n previous.itemCount === next.itemCount &&\n previous.activity === next.activity &&\n previous.turns === next.turns &&\n // Still worth a write once a minute so \"last here\" stays honest without\n // hammering storage on every streamed row.\n next.seenAt - previous.seenAt < TOUCH_MS\n ) {\n return false\n }\n this.#cache[id] = next\n this.#store.write(this.#prune(now))\n return true\n }\n\n /** Forget a session — it was deleted, and its mark is now noise. */\n forget(hostId: string, sessionId: string): void {\n const id = watermarkKey(hostId, sessionId)\n if (!(id in this.#cache)) return\n delete this.#cache[id]\n this.#store.write(this.#cache)\n }\n\n #prune(now: number): Record<string, Watermark> {\n const cutoff = now - MAX_AGE_MS\n for (const [id, mark] of Object.entries(this.#cache)) {\n if (mark.seenAt < cutoff) delete this.#cache[id]\n }\n return this.#cache\n }\n}\n\n/**\n * Rows this client has not seen, from the rollup alone.\n *\n * `activityCount` is the unit that makes an honest badge: turns undercount badly\n * (five tool calls in one turn is one turn) and a stream sequence overcounts\n * absurdly (every delta). Turns stay the fallback for a gateway too old to\n * report it.\n *\n * A session never visited returns 0 — \"never opened\" is not \"unread\", and a\n * badge that counted every session's whole history on first launch would be\n * noise on the one day it should be quiet.\n */\nexport function unseenCount(\n mark: Watermark | undefined,\n info: { activityCount?: number; turns?: number },\n): number {\n if (!mark) return 0\n if (info.activityCount !== undefined) return Math.max(0, info.activityCount - mark.activity)\n return Math.max(0, (info.turns ?? 0) - mark.turns)\n}\n","/**\n * @workerdeck/protocol — the wire protocol between a workerdeck server and its clients.\n *\n * One session = one ordered stream of {@link SessionEvent}s (each stamped with a monotonically\n * increasing `seq`) plus a small command set ({@link SessionCommand}). Clients attach over\n * WebSocket, optionally replaying from a known `seq`, and drive the session with commands.\n *\n * This package is dependency-free and browser-safe. Anthropic API message content is modeled\n * structurally (see {@link ApiMessage}) so clients don't need the Agent SDK to render transcripts.\n */\n\n/** Bumped on any breaking change to events, commands, or REST shapes. */\nexport const PROTOCOL_VERSION = 7\n\n// ---------------------------------------------------------------------------\n// Session lifecycle\n// ---------------------------------------------------------------------------\n\n/**\n * - `starting` — runner spawned, waiting for the SDK init handshake\n * - `running` — a turn is in progress\n * - `awaiting_approval` — blocked on at least one pending permission request\n * - `idle` — between turns; accepting user messages\n * - `parked` — waiting on a deferred tool execution. The live runner has been torn\n * down and the session's state persisted; delivering the execution's result\n * (`POST {basePath}/executions/:executionId/result`) rehydrates it under the same\n * id and the run continues. Not terminal.\n * - `failed` — the underlying query errored; terminal\n * - `closed` — closed by a client or the host; terminal\n */\nexport type SessionStatus =\n | 'starting'\n | 'running'\n | 'awaiting_approval'\n | 'idle'\n | 'parked'\n | 'failed'\n | 'closed'\n\nexport type PermissionMode =\n | 'default'\n | 'acceptEdits'\n | 'bypassPermissions'\n | 'plan'\n | 'dontAsk'\n | 'auto'\n\n// ---------------------------------------------------------------------------\n// API message content (structural mirror of Anthropic message shapes)\n// ---------------------------------------------------------------------------\n\nexport type TextBlock = { type: 'text'; text: string }\nexport type ThinkingBlock = { type: 'thinking'; thinking: string }\nexport type ToolUseBlock = { type: 'tool_use'; id: string; name: string; input: unknown }\nexport type ToolResultBlock = {\n type: 'tool_result'\n tool_use_id: string\n content?: string | Array<{ type: string; text?: string; [key: string]: unknown }>\n is_error?: boolean\n}\n/** Forward-compatible fallback for block types this protocol version doesn't model. */\nexport type UnknownBlock = { type: string; [key: string]: unknown }\n\nexport type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock\n\n/**\n * A file the user attached to a message — a photo, a screenshot, a document.\n *\n * The bytes never travel on this protocol. An attachment is uploaded first\n * (`POST {basePath}/sessions/:id/attachments`), and the command that sends the\n * message names it by id; what lands in the seq-numbered event log is this\n * reference. That is deliberate: the log is replayed to every attaching client\n * and captured into parking snapshots, so a few phone photos inlined as base64\n * would be paid for on every attach, forever. Clients render a thumbnail by\n * fetching `GET {basePath}/sessions/:id/attachments/:attachmentId`.\n *\n * Lifetime is the session's, like `/files` — the store is in-memory and an\n * attachment 404s after a server restart. The message itself is unaffected: the\n * model saw the bytes at send time.\n */\nexport type MessageAttachment = {\n /** Server-assigned; the path segment of the download URL. */\n id: string\n /** Display name from the file the user picked. A leaf name, never a path. */\n name: string\n /** IANA media type, e.g. 'image/jpeg'. The server decides how it reaches the\n * model (image block, document block, or inlined text) from this. */\n mediaType: string\n bytes: number\n}\n\nexport type ApiMessage = {\n role: 'user' | 'assistant'\n content: string | ContentBlock[]\n model?: string\n stop_reason?: string | null\n /** Per-API-call token usage when the message carries it (assistant messages do).\n * Enables mid-run token accounting; result-message usage stays authoritative. */\n usage?: {\n input_tokens?: number\n output_tokens?: number\n cache_creation_input_tokens?: number\n cache_read_input_tokens?: number\n }\n}\n\n// ---------------------------------------------------------------------------\n// Permission requests\n// ---------------------------------------------------------------------------\n\n/** A tool call promoted into a pending approval by the runner's canUseTool hook\n * (Claude), or an engine ask-channel request surfaced by its runner (codex).\n * The two do not share a tense: Claude asks BEFORE a tool runs; codex's command\n * approval is usually an escalation AFTER its sandbox already ran and refused\n * the command (\"command failed; retry without sandbox?\"). The runner authors\n * `title`/`description`/`decisionReason` to say which — clients should render\n * those fields rather than composing their own \"X wants to run Y\" sentence. */\nexport type PermissionRequest = {\n /** Server-assigned id; used by the `permission_decision` command. */\n id: string\n toolName: string\n input: Record<string, unknown>\n toolUseId: string\n /** Full prompt sentence from the SDK, e.g. \"Claude wants to read foo.txt\". */\n title?: string\n /** Short noun phrase for the tool action, e.g. \"Read file\". */\n displayName?: string\n /** Human-readable subtitle, e.g. \"Claude will have read access to ~/x\". */\n description?: string\n /** Why this permission request was triggered. */\n decisionReason?: string\n /** If raised from within a subagent, that subagent's id. */\n agentId?: string\n /** Epoch ms after which the server resolves it via its timeout policy. */\n expiresAt?: number\n}\n\nexport type PermissionDecisionSource = 'client' | 'timeout' | 'policy'\n\n// ---------------------------------------------------------------------------\n// User questions (the AskUserQuestion tool)\n// ---------------------------------------------------------------------------\n\n/** One choice of an AskUserQuestion question (SDK tool-input mirror). */\nexport type UserQuestionOption = {\n label: string\n description?: string\n /** Optional preview content (markdown unless the session configures html)\n * rendered when the option is focused. */\n preview?: string\n}\n\n/** One question from the AskUserQuestion tool's input. By the tool's convention the\n * first option is the model's recommended choice. */\nexport type UserQuestion = {\n question: string\n /** Short chip/tag label (max ~12 chars), e.g. \"Auth method\". */\n header: string\n options: UserQuestionOption[]\n multiSelect?: boolean\n}\n\n/** How a session treats the AskUserQuestion tool:\n * - 'ask' (default) — a pending permission like any other: interactive UIs render the\n * question form; job webhooks carry the full request so a remote controller can\n * answer over REST (POST /sessions/:id/permissions/:requestId).\n * - 'auto' — resolved immediately with each question's first (recommended) option.\n * - 'deny' — the tool is refused with guidance to decide autonomously (unattended runs).\n * Answers ride a permission allow as `updatedInput.answers`: question text → chosen\n * option label(s), multi-select labels comma-joined — the shape the CLI's own UI uses. */\nexport type QuestionBehavior = 'ask' | 'auto' | 'deny'\n\n// ---------------------------------------------------------------------------\n// Session capabilities (models / slash commands the CLI reports)\n// ---------------------------------------------------------------------------\n\n/** A model the session can switch to (SDK ModelInfo mirror; fields it may grow stay unknown). */\nexport type ModelOption = {\n /** Model id for createSession.model / set_model. */\n value: string\n /** Wire model id this row resolves to ('sonnet' → 'claude-sonnet-5'). What a\n * session actually reports as its model is the resolved form, so this is how a\n * client matches the running model back to the row that names it. */\n resolvedModel?: string\n displayName: string\n description?: string\n /** Whether this belongs in a picker's main list rather than behind a \"more\n * models\" step: the newest model of each family. Derived server-side — the CLI\n * reports one flat list — so that every client groups it the same way. */\n primary?: boolean\n /** Reasoning efforts this model supports at create time (codex catalogs carry\n * them, from the binary's own per-model list). Absent = the engine's default\n * set ({@link EngineCapabilities.reasoningEfforts}) applies. Open strings —\n * the binary's vocabulary outruns its SDK's union. */\n reasoningEfforts?: readonly string[]\n}\n\n/**\n * A skill the engine can decide to use — **not** a command.\n *\n * The distinction is the whole point of this type existing beside\n * {@link SlashCommandInfo}. A slash command is wire syntax: the CLI parses\n * `/wrapup` out of the message and runs it. A skill is a capability the model\n * *chooses* from its description; there is no `/skillname` the engine would\n * recognise, and sending one reaches the model as literal text.\n *\n * So a client may list these, and may offer them as a **typing aid** that\n * inserts ordinary editable prose ({@link SkillInfo.defaultPrompt}) — but it\n * must never render them as command chips, and must never put them in\n * `capabilities.commands`, which means \"the CLI accepts these as commands\".\n */\nexport type SkillInfo = {\n /** Directory name under the skills root — the identity the model refers to. */\n name: string\n /** What the skill is for, as its own manifest states it. This is the text the\n * MODEL selects on, so it is also the most honest thing to show a human. */\n description?: string\n /** A one-liner where the skill declares one, for a list row too narrow for\n * `description`. */\n shortDescription?: string\n /** Human-facing name from the skill's own interface block, when it differs\n * from `name`. */\n displayName?: string\n /**\n * The engine's own suggested opening message for this skill. A client that\n * offers a picker INSERTS this as plain editable text for the user to finish\n * and send; it is a draft, never something submitted on selection.\n */\n defaultPrompt?: string\n /** Where the skill came from: 'user' | 'repo' | 'system' | 'admin' — kept as\n * a string, the engine's set may grow. */\n scope?: string\n /** False when the operator has this skill switched off: still listed, because\n * \"installed but off\" is a different answer from \"not installed\". */\n enabled: boolean\n}\n\n/** A slash command the CLI accepts as user-message text (SDK SlashCommand mirror). */\nexport type SlashCommandInfo = {\n /** Command name without the leading slash. */\n name: string\n description?: string\n /** Hint for arguments, e.g. \"<file>\". */\n argumentHint?: string\n /** Alternate names resolving to this command. */\n aliases?: string[]\n}\n\n// ---------------------------------------------------------------------------\n// Usage telemetry (context window + subscription rate limits)\n// ---------------------------------------------------------------------------\n\n/** One category row from the CLI's context-usage breakdown (system prompt, tools, ...). */\nexport type ContextUsageCategory = {\n name: string\n tokens: number\n /** Color the CLI assigns the category. Often a CLI theme token name ('inactive',\n * 'promptBorder', ...), not a CSS color — validate before styling with it. */\n color: string\n}\n\n/** Context-window usage snapshot (SDK getContextUsage mirror), polled after each turn. */\nexport type ContextUsage = {\n categories: ContextUsageCategory[]\n totalTokens: number\n maxTokens: number\n /** Used share of the window, 0–100. */\n percentage: number\n /** Model the window sizing applies to. */\n model?: string\n}\n\n/**\n * One rate-limit window snapshot (SDK SDKRateLimitInfo mirror). Emitted only for\n * claude.ai subscription sessions — API-key sessions may never produce one, so\n * clients must render nothing (not 0%) until data arrives.\n */\nexport type RateLimitInfo = {\n /** 'allowed' | 'allowed_warning' | 'rejected' — kept as string, the SDK union may grow. */\n status: string\n /** Which window: 'five_hour' (session), 'seven_day' (weekly), 'seven_day_opus',\n * 'seven_day_sonnet', 'overage', ... — kept as string, the SDK union may grow. */\n rateLimitType?: string\n /** Used share of the window, 0–100. The CLI omits it on some updates — treat\n * absent as unknown, not 0. */\n utilization?: number\n /** Epoch **seconds** when the window resets (render countdowns client-side). */\n resetsAt?: number\n isUsingOverage?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Tool execution (bridged, deferred, and remote)\n// ---------------------------------------------------------------------------\n\n/**\n * Lifecycle of one tool execution, correlated by `executionId` end to end.\n *\n * - `pending` — dispatched, result not in yet (bridged to a client, or queued).\n * - `deferred` — parked beyond this turn/process; may outlive the session's\n * liveness and be applied on rehydration.\n * - `settled` / `failed` — terminal. Results are applied idempotently by id, so\n * a duplicate delivery is a no-op rather than a second application.\n */\nexport type ToolExecutionStatus = 'pending' | 'deferred' | 'settled' | 'failed'\n\n/** Where a tool execution ran (or is running). Advisory: for display and routing. */\nexport type ToolExecutionBackend = 'server' | 'browser' | 'managed' | 'remote'\n\n/** Result payload of a tool execution, by value — never a live host reference. */\nexport type ToolExecutionOutput =\n | { type: 'text'; value: string }\n | { type: 'json'; value: unknown }\n\n// ---------------------------------------------------------------------------\n// Session events (server -> client)\n// ---------------------------------------------------------------------------\n\nexport type SessionEventBody =\n /** SDK init handshake: what this session actually is. */\n | {\n type: 'system_init'\n sdkSessionId: string\n model: string\n cwd: string\n /** Where the session's Anthropic auth came from: 'oauth' means a claude.ai\n * subscription login; other values ('user' | 'project' | 'org' | 'temporary')\n * are API-key provenance. Kept as string — the SDK union may grow. */\n apiKeySource: string\n tools: string[]\n skills: string[]\n slashCommands: string[]\n permissionMode: PermissionMode\n claudeCodeVersion: string\n mcpServers: Array<{ name: string; status: string }>\n }\n | { type: 'status_changed'; status: SessionStatus; detail?: string }\n /** Models and slash commands available to this session; fetched from the CLI after\n * init. Late attachers get it via replay like any other event. */\n | {\n type: 'capabilities'\n models: ModelOption[]\n commands: SlashCommandInfo[]\n /** Wire id the session's *default* resolves to, from the CLI's own `default`\n * row. Answers \"what will this session answer as\" before it has answered\n * anything — `system_init` carries the model, but a promptless session gets\n * no `system_init` until its first message. */\n defaultModel?: string\n }\n /**\n * The skills this session's engine can reach ({@link SkillInfo}) — a full\n * replacement each time, not a delta, so a late attacher's replay of several\n * of these converges on the last one. Emitted once the session's engine has\n * enumerated them, and again whenever the engine reports the set changed\n * (a skill added or edited on disk).\n *\n * Deliberately NOT folded into `capabilities.commands`: skills are not\n * commands (see {@link SkillInfo}). Only engines whose record sets\n * {@link EngineCapabilities.skillsList} ever emit it.\n */\n | { type: 'skills'; skills: SkillInfo[] }\n /**\n * The engine wrote a file on the **host filesystem** and handed over its path\n * — codex's `image_gen` saving a PNG is the case that motivated it. The\n * host-filesystem sibling of `file_delivered` (which is the scratch-VFS one).\n *\n * Fetch it at `GET {basePath}/sessions/:id/produced/:fileId` for as long as\n * the session lives. That route has no root allowlist and no size cap, and\n * that is sound *because of where the path came from*: this event is authored\n * by the runner about a file the engine itself just wrote, not by the agent\n * about a path it chose. `/fs/*` gates the second kind and must keep doing so\n * — a file the agent merely *read* is not a produced file and does not belong\n * here.\n *\n * Re-emitting the same file is a no-op: `fileId` is derived from the path, so\n * a runner that learns the path twice (codex reports `savedPath` on both the\n * progress and completed item) registers it once.\n */\n | {\n type: 'file_produced'\n /** Opaque, stable per session+path. The route's path segment. */\n fileId: string\n /** Absolute host path, as the engine reported it. Shown to the operator,\n * and what a client matches against a tool card's own `savedPath`. */\n path: string\n /** Media type when the runner could determine one (usually from the\n * extension). Absent = let the route's own sniffing decide. */\n mediaType?: string\n /** Size at the time it was reported, when the runner knew it. */\n bytes?: number\n /** The tool call that produced it, when one did. */\n toolUseId?: string\n }\n /** The session's model changed via `set_model`. `model` undefined = back to default. */\n | { type: 'model_changed'; model?: string }\n /** The session's permission mode changed via `set_permission_mode`. */\n | { type: 'permission_mode_changed'; mode: PermissionMode }\n /** Context-window usage snapshot; the runner polls it after each turn. */\n | { type: 'context_usage'; usage: ContextUsage }\n /** Subscription rate-limit update for one window (see {@link RateLimitInfo}). */\n | { type: 'rate_limit'; info: RateLimitInfo }\n /** Which claude.ai plan the rate-limit windows belong to: 'pro' | 'max' | 'team' |\n * 'enterprise' — kept as string, the set may grow. Emitted from the same poll as\n * `rate_limit`, once per change, and never for an API-key session (which has no\n * plan). It names the windows; it does not size them — the tier suffix a\n * subscription page shows (\"Max 20x\") is not in the data. */\n | { type: 'plan_info'; subscriptionType: string }\n | {\n type: 'assistant_message'\n message: ApiMessage\n /** Set when the message was produced inside a subagent (Task tool). */\n parentToolUseId: string | null\n /** True when backfilled from a resumed session's history. */\n replay?: boolean\n uuid: string\n }\n | {\n type: 'user_message'\n message: ApiMessage\n parentToolUseId: string | null\n /** True when replayed from a resumed session's history. */\n replay?: boolean\n /** True for tool results and other synthetic user-role messages. */\n synthetic?: boolean\n /** Files sent with this message, by reference (see {@link MessageAttachment}).\n * `message.content` carries the typed text only — the attachment bytes went\n * to the model, not into this log. */\n attachments?: MessageAttachment[]\n uuid?: string\n }\n /** Raw Anthropic streaming event (message_start/content_block_delta/...); emitted only\n * when the session was created with `includePartialMessages`. */\n | {\n type: 'stream_delta'\n event: { type: string; [key: string]: unknown }\n parentToolUseId: string | null\n uuid: string\n }\n | {\n type: 'turn_result'\n subtype:\n | 'success'\n | 'error_during_execution'\n | 'error_max_turns'\n | 'error_max_budget_usd'\n | 'error_max_structured_output_retries'\n isError: boolean\n durationMs: number\n numTurns: number\n totalCostUsd: number\n /** Final text of the turn (success only). */\n result?: string\n errors?: string[]\n usage?: unknown\n }\n | { type: 'permission_requested'; request: PermissionRequest }\n | {\n type: 'permission_resolved'\n requestId: string\n behavior: 'allow' | 'deny'\n resolvedBy: PermissionDecisionSource\n /** Denial message, when denied. */\n message?: string\n }\n /** A tool execution was dispatched to a backend. For bridged executions this\n * precedes the `tool_call_request` frame; for deferred ones it is the record\n * that survives a teardown. */\n | {\n type: 'execution_dispatched'\n executionId: string\n toolName: string\n backend: ToolExecutionBackend\n /** True when the execution may outlive this turn or process. */\n deferred?: boolean\n /** Epoch ms after which the server applies its timeout policy. */\n expiresAt?: number\n }\n /** A dispatched execution produced a result. Applied idempotently by `executionId`. */\n | {\n type: 'execution_result'\n executionId: string\n output: ToolExecutionOutput\n /** Guest/agent-visible logs, if the backend captured any. */\n logs?: string[]\n durationMs?: number\n }\n /** A dispatched execution failed, timed out, or was orphaned. The failure is fed\n * back into the loop as tool output so the agent can adapt — it is not a session error. */\n | {\n type: 'execution_failed'\n executionId: string\n /** Machine-readable cause: 'timeout' | 'oom' | 'exception' | 'orphaned' | backend-specific. */\n reason: string\n error: string\n logs?: string[]\n durationMs?: number\n }\n /** The agent handed over a file from its session scratch filesystem (the\n * `deliver_file` tool). Download it via `GET {basePath}/sessions/:id/files/<path>`\n * for as long as the session lives (the VFS is in-memory). */\n | { type: 'file_delivered'; path: string; bytes: number; description?: string }\n /** Any SDKMessage this protocol version doesn't model first-class (task progress,\n * compaction boundaries, auth status, ...). Payload is the raw SDK message. */\n | { type: 'sdk_event'; payload: { type: string; [key: string]: unknown } }\n | { type: 'session_error'; message: string }\n | { type: 'session_closed'; reason: 'client' | 'server' | 'error' }\n\nexport type SessionEvent = SessionEventBody & {\n /** Monotonic per-session sequence number, starting at 1. */\n seq: number\n /** Epoch ms when the server emitted the event. */\n ts: number\n}\n\n// ---------------------------------------------------------------------------\n// Commands (client -> server)\n// ---------------------------------------------------------------------------\n\nexport type SessionCommand =\n | {\n type: 'user_message'\n text: string\n /** Ids from `POST {basePath}/sessions/:id/attachments`, in the order they\n * should reach the model. Unknown ids fail the command rather than sending\n * a message that quietly lost its picture. */\n attachmentIds?: string[]\n }\n | {\n type: 'permission_decision'\n requestId: string\n behavior: 'allow' | 'deny'\n /** allow only: modified tool input to run instead of the original. */\n updatedInput?: Record<string, unknown>\n /** deny only: reason surfaced to the model. */\n message?: string\n /** deny only: also interrupt the running turn. */\n interrupt?: boolean\n }\n | { type: 'interrupt' }\n | { type: 'set_permission_mode'; mode: PermissionMode }\n /** Switch the model for subsequent responses; omit `model` for the default. */\n | { type: 'set_model'; model?: string }\n /**\n * Result of a tool execution the server bridged to this client (see\n * {@link ToolCallRequestFrame}). Unknown or already-settled `executionId`s are\n * ignored — delivery is idempotent, and a late result after a timeout must not\n * re-open a settled call.\n *\n * Browser-returned results are UNTRUSTED input: acceptable for the user's own\n * data, never a source for server-authoritative state.\n */\n | {\n type: 'tool_call_result'\n executionId: string\n output: ToolExecutionOutput\n logs?: string[]\n }\n /** The client could not execute a bridged call (unsupported tool, guest error,\n * tab closing). Fed back to the agent as tool output. */\n | {\n type: 'tool_call_error'\n executionId: string\n reason: string\n error: string\n logs?: string[]\n }\n | { type: 'close' }\n\n// ---------------------------------------------------------------------------\n// WebSocket frames\n// ---------------------------------------------------------------------------\n\n/** First frame the server sends after a successful attach. */\nexport type AttachedFrame = {\n type: 'attached'\n protocolVersion: number\n session: SessionInfo\n /** Events with seq > the client's `afterSeq` follow as `event` frames. */\n replayingFrom: number\n}\n\n/**\n * Ask the attached client to execute a tool call in its own sandbox (browser\n * bridge). The client answers with `tool_call_result` or `tool_call_error`\n * carrying the same `executionId`.\n *\n * Only sandbox-benefiting tools are ever bridged. Authenticated/authoritative\n * tools (MCP, secret-bearing APIs) execute server-side and never appear here.\n */\nexport type ToolCallRequestFrame = {\n type: 'tool_call_request'\n executionId: string\n toolName: string\n input: unknown\n /** Files to seed the client's scratch VFS with, path → contents. */\n vfsSeed?: Record<string, string>\n limits?: { timeoutMs?: number; memoryLimitBytes?: number }\n /** Epoch ms after which the server gives up and fails the execution. */\n expiresAt?: number\n}\n\nexport type ServerFrame =\n | AttachedFrame\n | { type: 'event'; event: SessionEvent }\n | ToolCallRequestFrame\n /** A bridged execution no longer needs an answer (turn interrupted, timed out,\n * or the session closed) — the client should abandon it. */\n | { type: 'tool_call_canceled'; executionId: string; reason: string }\n | { type: 'protocol_error'; message: string }\n\nexport type ClientFrame = SessionCommand\n\n// ---------------------------------------------------------------------------\n// Profiles (named Claude Code config directories)\n// ---------------------------------------------------------------------------\n\n/** Per-profile fallbacks filled into session/job requests that leave the field\n * unset. Defaults, not enforced caps — an explicit request value always wins. */\nexport type ProfileDefaults = {\n model?: string\n permissionMode?: PermissionMode\n}\n\n/**\n * A named Claude Code config directory sessions can run under: the session's CLI\n * process gets it as CLAUDE_CONFIG_DIR, so the profile carries that directory's\n * settings, memory, skills, and whatever credentials the SDK/CLI resolves from it.\n * Profiles are declared in server options at startup (or a 'default' one is\n * auto-created from the operator's own config dir) — the API only reads them.\n */\n/**\n * Which engine a profile runs on. A **closed union, deliberately**: both clients\n * switch exhaustively, the Swift mirror ships in lockstep, and a closed set is\n * what lets this package carry per-engine capability defaults\n * ({@link ENGINE_CAPABILITIES}) browser-safe, with no server round-trip. Adding a\n * member is a versioned protocol event.\n *\n * - `claude` (default) — Claude Code via the Agent SDK, configured by a config dir.\n * - `codex` — OpenAI Codex over the codex CLI binary's `app-server` JSON-RPC\n * surface, configured by a CODEX_HOME (auth resolved by the binary itself,\n * like claude).\n * - `provider` — a model-agnostic provider over the AI SDK, assembled by the\n * host's `createEngineRunner` hook.\n */\nexport type ProfileEngine = 'claude' | 'codex' | 'provider'\n\n/**\n * What an engine does and does not do — one axis per real difference, each field\n * answering a concrete UI or gateway question. Clients render from this record\n * instead of switching on the engine name: an absent capability means the\n * affordance is *hidden*, never a control that silently does nothing.\n *\n * Reaches clients in two places, same shape: `ProfileInfo.capabilities` (stamped\n * by the server; the create form's source) and `SessionInfo.capabilities`\n * (reported by the runner; the session surface's source). When the field is\n * absent — an older server — {@link ENGINE_CAPABILITIES} keyed by the engine\n * name is the browser-safe default.\n */\nexport type EngineCapabilities = {\n /** PermissionRequest / permission_resolved can occur; approval UI is live.\n * False: hide approval affordances entirely (and `questionBehavior` on jobs). */\n interactiveApprovals: boolean\n /** Modes this engine can honor. A stored choice outside the set is coerced to\n * {@link EngineCapabilities.defaultPermissionMode}, not submitted. */\n permissionModes: readonly PermissionMode[]\n /** Coercion target for a stored/unsupported mode choice (always ∈ permissionModes). */\n defaultPermissionMode: PermissionMode\n /** CreateSessionRequest.resume works (an engine session id continues). */\n resume: boolean\n /** Resume replays prior history into the transcript (Claude's backfill).\n * False + resume: show a \"history predates this attach\" notice instead of\n * treating an empty transcript as a bug. */\n resumeBackfill: boolean\n /** GET /sdk-sessions offers a resume picker for this engine. */\n listSessions: boolean\n /** context_usage events can occur. False: render nothing — never a 0% ring. */\n contextUsage: boolean\n /** rate_limit / plan_info events can occur. False: render nothing. */\n rateLimits: boolean\n /** GET /sessions/:id/mcp works (else 501) — the engine can *list* its MCP\n * servers. Gates the MCP panel's existence. */\n mcpStatus: boolean\n /**\n * POST /sessions/:id/mcp/:name works — the engine can reconnect, enable and\n * disable a server. Separate from {@link EngineCapabilities.mcpStatus}\n * because listing and acting are genuinely different powers: codex reports\n * rich status but exposes no per-server action on this transport, and a panel\n * that rendered the buttons anyway would present three controls that do\n * nothing and then report success. False: render the panel read-only.\n */\n mcpServerActions: boolean\n /** A session request may bring its own mcpServers. */\n sessionMcpServers: boolean\n /** capabilities events carry slash commands (composer popover). */\n slashCommands: boolean\n /** `skills` events can occur — the engine can enumerate its skills. False:\n * hide the skills panel entirely rather than showing an empty one. Orthogonal\n * to `slashCommands`: an engine can have skills and no commands (codex), or\n * commands and no skill listing (claude, whose skills reach clients only as\n * `system_init.skills` names). */\n skillsList: boolean\n /** settingSources / allowDangerouslySkipPermissions-style CLI options apply. */\n settingSources: boolean\n /** maxTurns / maxBudgetUsd are honored (else the gateway 400s them). */\n budgets: boolean\n /** Attachment kinds sendMessage can deliver to the model. Filter the attach\n * menu by kind; refuse locally before the server's 415. */\n attachments: ReadonlyArray<'image' | 'pdf' | 'text'>\n /** Efforts offerable at create time; absent = not settable (hide the control).\n * Open strings — Codex's own binary already outruns its SDK's union. */\n reasoningEfforts?: readonly string[]\n /** Sessions expose a scratch VFS (GET /sessions/:id/files, deliverables panel). */\n vfs: boolean\n /**\n * The engine runs against a host directory, so `CreateSessionRequest.cwd` is\n * required and meaningful (and a create form should ask for it). False: the\n * engine has no host filesystem — the gateway accepts a session with no\n * `cwd`, `SessionInfo.cwd` reports `''`, and there is no path to validate.\n *\n * Absent = true, so a wire copy from an older gateway keeps the old\n * always-required behaviour rather than silently relaxing it.\n */\n hostCwd?: boolean\n /** stream_delta granularity: per-token, coarse item updates (no typing\n * cursor), or none. */\n streaming: 'token' | 'item' | 'none'\n}\n\n/**\n * The static capability record of each engine — the browser-safe default for\n * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place\n * the values are written down. Core's adapters *reference* this record and a\n * conformance test compares runner behaviour against it, so it cannot silently\n * diverge from the code. When both a wire copy and this default exist, the wire\n * copy wins.\n */\nexport const ENGINE_CAPABILITIES: Record<ProfileEngine, EngineCapabilities> = {\n claude: {\n interactiveApprovals: true,\n permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk', 'auto'],\n defaultPermissionMode: 'default',\n resume: true,\n resumeBackfill: true,\n listSessions: true,\n contextUsage: true,\n rateLimits: true,\n mcpStatus: true,\n mcpServerActions: true,\n sessionMcpServers: true,\n slashCommands: true,\n // The CLI reports skill NAMES on `system_init` and nothing more — no\n // descriptions, no scope, no suggested prompt. That is not enough to fill a\n // picker honestly, and the SDK exposes no listing call, so the panel stays\n // off here rather than rendering a list of bare words.\n skillsList: false,\n settingSources: true,\n budgets: true,\n attachments: ['image', 'pdf', 'text'],\n // The engine-wide set (SDK Options.effort); per-model narrowing rides the\n // catalog rows, and the CLI silently downgrades an effort a model lacks.\n reasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],\n vfs: false,\n hostCwd: true,\n streaming: 'token',\n },\n codex: {\n // The app-server ask channels (server→client JSON-RPC requests: command\n // escalations, file changes, permission grants, tool questions, MCP\n // elicitations) are wired to the permission surface: they arrive as\n // `permission_requested` and are answered by `permission_decision`.\n // NOTE the semantic shift a client should not paper over: codex's command\n // approval is usually an ESCALATION after the sandbox already refused the\n // command (\"command failed; retry without sandbox?\"), not a gate before\n // execution — the runner authors `title`/`decisionReason` from codex's own\n // reason sentence, so render those rather than composing \"wants to use X\".\n interactiveApprovals: true,\n // 'default' = read-only sandbox + ask (a blocked action becomes a real\n // question instead of a silent refusal); acceptEdits = workspace-write +\n // ask (in-workspace writes sail through, escalations still ask); bypass =\n // full access, asking nothing. plan/dontAsk/auto name CLI workflows codex\n // cannot deliver.\n permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],\n defaultPermissionMode: 'default',\n resume: true,\n // A resume replays the thread's prior turns from `thread/resume`'s\n // `thread.turns` (topped up via `thread/read {includeTurns: true}` when the\n // resume page is partial) as `replay: true` events — same contract as the\n // Claude engine's backfill.\n resumeBackfill: true,\n // `GET /sdk-sessions?profile=<codex profile>` lists CODEX_HOME's threads\n // over a short-lived `thread/list` connection; no live session required.\n listSessions: true,\n // From `thread/tokenUsage/updated.last` against `modelContextWindow`, after\n // each turn. Its `categories` is always empty — codex publishes no\n // breakdown — so a client must not draw an empty breakdown section.\n contextUsage: true,\n // From `account/rateLimits/updated`, which app-server pushes during a turn\n // (no poll needed). Windows are positional there and named here by their\n // measured duration — see `docs/GOTCHAS.md` §Codex.\n rateLimits: true,\n // `mcpServerStatus/list` answers with each server, its `serverInfo`, its\n // auth status and — unlike the Agent SDK — the full JSON Schema of every\n // tool. Live status rides the `mcpServer/startupStatus/updated`\n // notification rather than the list response, so the runner tracks it.\n mcpStatus: true,\n // …but nothing on this transport reconnects or toggles ONE server. The\n // reload RPC is server-wide, and enable/disable would mean writing the\n // operator's config.toml — a different act from Claude's session-scoped\n // switch. So the panel is read-only here instead of offering buttons that\n // would lie.\n mcpServerActions: false,\n // MCP belongs to CODEX_HOME's config.toml; a session request cannot add servers.\n sessionMcpServers: false,\n // There is no command-listing RPC in the app-server surface at all: codex's\n // own `/model`, `/approvals` etc. are TUI-local and never reach this\n // transport. This is settled, not pending.\n slashCommands: false,\n // …but `skills/list` does exist, and `skills/changed` says when to re-read\n // it. What comes back is metadata rich enough to render (description,\n // scope, and codex's own `defaultPrompt`) — see {@link SkillInfo} for why\n // that is still not a command.\n skillsList: true,\n settingSources: false,\n budgets: false,\n // Images travel as localImage host paths, text is inlined into the prompt\n // envelope; pdf has no representation and 415s at upload.\n attachments: ['image', 'text'],\n // The engine-wide floor; per-model supersets (max, ultra) ride\n // ModelOption.reasoningEfforts from the catalog.\n reasoningEfforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],\n vfs: false,\n hostCwd: true,\n // item/agentMessage/delta and the reasoning deltas arrive token-by-token.\n streaming: 'token',\n },\n provider: {\n interactiveApprovals: false,\n permissionModes: ['default', 'bypassPermissions', 'dontAsk'],\n defaultPermissionMode: 'default',\n resume: false,\n resumeBackfill: false,\n listSessions: false,\n contextUsage: false,\n rateLimits: false,\n // The one engine whose MCP is entirely host-wired, and so the one that can\n // always answer: `AiSdkRunner` reports what the host assembled the session\n // from (an empty list when that was nothing). Acting on a server is a\n // different power and stays absent — the host owns those connections and\n // this engine has no channel to renegotiate one.\n mcpStatus: true,\n mcpServerActions: false,\n sessionMcpServers: false,\n slashCommands: false,\n skillsList: false,\n settingSources: false,\n budgets: false,\n attachments: ['image', 'pdf', 'text'],\n vfs: true,\n // No host filesystem at all: the tools run against the in-memory VFS, and\n // the runner never opens a path. A required `cwd` here would be a field\n // nothing reads, and `allowedCwdRoots` would look like the sandbox boundary\n // when the capability wiring is what actually bounds this engine.\n hostCwd: false,\n streaming: 'token',\n },\n}\n\n/**\n * Permission modes the model-agnostic provider engine understands.\n * @deprecated Read `ENGINE_CAPABILITIES.provider.permissionModes` (this is an\n * alias of it, kept for protocol-5 consumers).\n */\nexport const PROVIDER_PERMISSION_MODES: readonly PermissionMode[] =\n ENGINE_CAPABILITIES.provider.permissionModes\n\n/**\n * Whether a profile's engine can run a permission mode. The single source of\n * truth for the restriction: create forms filter what they offer with it, the\n * gateway rejects with it. An absent `engine` means 'claude' (every mode).\n */\nexport function supportsPermissionMode(\n engine: ProfileEngine | undefined,\n mode: PermissionMode,\n): boolean {\n return ENGINE_CAPABILITIES[engine ?? 'claude'].permissionModes.includes(mode)\n}\n\n/**\n * A model provider a `provider` profile can run on. Credentials are ALWAYS\n * resolved from the operator's environment — never carried on the wire, never\n * stored here. `apiKeyEnv` names the variable to read, it does not hold a key.\n */\nexport type ProviderConfig = {\n /** Provider adapter to use, e.g. 'anthropic' | 'openai' | 'moonshotai' |\n * 'openai-compatible'. Kept as a string: the set is host-extensible. */\n id: string\n /** Default model id, e.g. 'kimi-k3'. Overridable per session. */\n model?: string\n /** Model ids this profile offers, for the dashboard's picker. Operator-declared\n * rather than discovered: provider engines have no equivalent of the CLI's\n * `supportedModels()`, and only the operator knows which ids their endpoint and\n * key actually serve. Unset → the picker offers {@link ProviderConfig.model} alone. */\n models?: string[]\n /** Base URL for OpenAI-compatible providers. */\n baseUrl?: string\n /** Environment variable the operator put the key in. Never the key itself. */\n apiKeyEnv?: string\n}\n\n/**\n * A grantable capability of the model-agnostic engine, named after the tool it\n * yields. The always-present tools (`fs_*`, `eval_script`) are not listed: they\n * are the engine's scratch filesystem and sandbox, not a grant.\n */\nexport type SessionCapability = 'web_search' | 'download' | 'web_fetch' | 'deliver_file'\n\n/**\n * What sessions under a `provider` profile get, declared by the operator. Meaning-\n * less for `claude` profiles, whose equivalents live in the config directory.\n *\n * MCP servers are named, never configured, here: a server's transport config can\n * carry credentials in its headers, and this type is served by `GET /profiles`.\n * The names refer to servers the host connected in `createEngineRunner`, which is\n * where the configs (and the credentials) stay.\n */\nexport type ProfileSessionDefaults = {\n /** Capabilities granted to sessions under this profile. Absent = no\n * declaration, so a session gets whatever backends the host wired. A session\n * request may narrow this set, never widen it. */\n capabilities?: SessionCapability[]\n /** MCP servers, by name, whose tools sessions under this profile may use.\n * Absent = no declaration (every server the host connected). */\n mcpServers?: string[]\n /** Prepended to the session's system prompt. */\n instructions?: string\n}\n\nexport type ProfileInfo = {\n /** Unique name, used as {@link CreateSessionRequest.profile}. */\n name: string\n /** Engine this profile runs on. Defaults to 'claude' when absent, so profiles\n * written before provider support keep working unchanged. */\n engine?: ProfileEngine\n /** Absolute path set as CLAUDE_CONFIG_DIR for the session's CLI process.\n * Required for 'claude' profiles; meaningless for the other engines. */\n configDir?: string\n /** Codex profiles: absolute path set as CODEX_HOME for the session's codex\n * process (auth, config.toml, thread storage) — the `configDir` analogue,\n * request-writable like it. Unset = the binary's own `~/.codex`. */\n codexHome?: string\n /** Provider wiring for 'provider' profiles. */\n provider?: ProviderConfig\n description?: string\n defaults?: ProfileDefaults\n /** Provider-engine session grants (capabilities, MCP servers, instructions). */\n session?: ProfileSessionDefaults\n /** Response-only: the engine's model catalog, shipped with the release and\n * served from the first request (no process spawned, no warm-up session).\n * For provider profiles the ids come from `provider.models` instead. Never\n * contains a 'default' sentinel row — forms add their own \"Profile default\"\n * row mapping to an unset model. Ignored on the way in. */\n models?: ModelOption[]\n /** Response-only: what this profile's default model resolves to. For claude\n * profiles this is the operator's CLI config — unknowable statically — so it\n * is absent until a session on this profile reports it. */\n defaultModel?: string\n /** Response-only: the engine's capability record (see {@link EngineCapabilities}).\n * Absent = use ENGINE_CAPABILITIES[engine]. Ignored on the way in. */\n capabilities?: EngineCapabilities\n /** Response-only: whether the profile's credentials probe as usable right now.\n * Absent = unknown/unchecked — treat as available. **Display-only**: create\n * against an unavailable profile still proceeds and fails with the engine's\n * own error (the probe can be stale in both directions). */\n available?: boolean\n /** Response-only: one operator-actionable line, present only when\n * `available === false`. */\n unavailableReason?: string\n /** Response-only, computed by the server: this profile came from the profile\n * store and can be edited or deleted through the API. Profiles declared in\n * server options are absent/false — they are code. Ignored on the way in. */\n managed?: boolean\n}\n\n/**\n * Curated, read-only snapshot of what a profile's config directory contains —\n * the parts relevant to running worker sessions. Values that could carry secrets\n * (env var values) never leave the server; only names are listed.\n */\nexport type ProfileConfigSnapshot = {\n /** From the config dir's settings.json; absent when missing or unparseable. */\n settings?: {\n /** Configured default model. */\n model?: string\n /** permissions.defaultMode — the CLI's default permission mode. */\n defaultPermissionMode?: string\n /** Rule counts from permissions.allow / ask / deny. */\n permissionRules?: { allow: number; ask: number; deny: number }\n /** Env var NAMES declared in settings.json env (values never included). */\n envKeys?: string[]\n /** Hook event names with at least one hook configured. */\n hooks?: string[]\n }\n /** CLAUDE.md (user memory) present in the config dir. */\n hasUserMemory: boolean\n /** Skill names (skills/<name>/). */\n skills: string[]\n /** Agent names (agents/<name>.md). */\n agents: string[]\n /** Custom slash-command names (commands/<name>.md). */\n commands: string[]\n}\n\n// ---------------------------------------------------------------------------\n// REST shapes\n// ---------------------------------------------------------------------------\n\nexport type McpServerConfigWire =\n | { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }\n | { type: 'http'; url: string; headers?: Record<string, string> }\n | { type: 'sse'; url: string; headers?: Record<string, string> }\n\n/** One tool an MCP server exposes, as the session's engine reports it.\n * Parameters are deliberately absent: the CLI's status payload names and\n * describes each tool but does not carry its input schema. */\nexport type McpServerToolInfo = {\n name: string\n description?: string\n annotations?: { readOnly?: boolean; destructive?: boolean; openWorld?: boolean }\n /**\n * The tool's JSON Schema, where the engine reports one. **Engine-dependent,\n * and that is not an oversight**: the Agent SDK's `McpServerStatus` names and\n * describes each tool but carries no schema at all, while codex's\n * `mcpServerStatus/list` returns the full one. So a client renders parameters\n * where they exist and says they are unavailable where they don't — rather\n * than either leaving a silent gap or claiming the absence is universal.\n *\n * Opaque on purpose: this is a JSON Schema document, not a shape this\n * protocol models.\n */\n inputSchema?: unknown\n}\n\n/**\n * Live status of one MCP server on a session — what `GET\n * {basePath}/sessions/:id/mcp` answers with, and what the `/mcp` screens render.\n *\n * The connection *identity* is here (transport, command, url, scope) but never\n * its secrets: the engine's config carries `env` for stdio servers and `headers`\n * for HTTP/SSE ones, and both are dropped on the way out. A client that can read\n * this is not thereby entitled to the tokens the operator configured.\n */\nexport type McpServerStatusInfo = {\n name: string\n /** 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled' — kept open,\n * the engine's set may grow. */\n status: string\n /** Where the server was configured: 'project' | 'user' | 'local' | 'dynamic' | … */\n scope?: string\n /** Present when `status` is 'failed'. */\n error?: string\n /** Name and version the server announced on connect. */\n serverInfo?: { name: string; version: string }\n transport?: 'stdio' | 'http' | 'sse' | 'sdk'\n /** stdio only. */\n command?: string\n /** stdio only. Secrets do occasionally ride argv; the operator's own client\n * shows them, and hiding them here would only mislead. `env` is not exposed. */\n args?: string[]\n /** http/sse only. */\n url?: string\n /** Present when connected. */\n tools?: McpServerToolInfo[]\n}\n\nexport type McpServersResponse = { servers: McpServerStatusInfo[] }\n\n/** `POST {basePath}/sessions/:id/mcp/:name` — answers with the refreshed status. */\nexport type McpServerActionRequest = { action: 'reconnect' | 'enable' | 'disable' }\n\n/** `POST {basePath}/sessions/:id/attachments?name=<name>` — the body is the raw\n * file, the `content-type` header its media type. Answers with the reference to\n * name on the next `user_message`. */\nexport type UploadAttachmentResponse = { attachment: MessageAttachment }\n\nexport type CreateSessionRequest = {\n /** Directory the session is rooted at. Required for any engine whose\n * capability record declares {@link EngineCapabilities.hostCwd} — `cwd` is\n * per-query in the SDK and the server re-pins it on every call. Omittable for\n * an engine that has no host filesystem at all (the provider engine, whose\n * tools run against the in-memory VFS): there the field would be a required\n * lie, and `allowedCwdRoots` would look like a sandbox boundary it is not. */\n cwd?: string\n /** Profile (named Claude Code config dir) to run under. Required when the server\n * declares more than one profile; implicit when exactly one exists. */\n profile?: string\n /** Optional initial prompt (may be a skill invocation like \"/verify-content 123\"). */\n prompt?: string\n permissionMode?: PermissionMode\n /** Pre-authorize 'bypassPermissions' (the CLI's --dangerously-skip-permissions\n * capability) so the mode can be switched on mid-session. Without it the CLI\n * rejects `set_permission_mode: 'bypassPermissions'` on a running session.\n * Implied when `permissionMode` is already 'bypassPermissions'. */\n allowDangerouslySkipPermissions?: boolean\n allowedTools?: string[]\n disallowedTools?: string[]\n mcpServers?: Record<string, McpServerConfigWire>\n /** Which filesystem settings the session loads. Include 'project' to pick up the\n * target repo's skills and CLAUDE.md (\"close-to-real\" fidelity). */\n settingSources?: Array<'user' | 'project' | 'local'>\n model?: string\n maxTurns?: number\n maxBudgetUsd?: number\n /** Resume an existing SDK session by id. */\n resume?: string\n /** With `resume`: fork to a new session id instead of continuing. */\n forkSession?: boolean\n /** Reasoning effort for the session's model (codex engine). Open string —\n * offerable values come from the profile's catalog/capability record. The\n * gateway 400s it when the engine's record declares no `reasoningEfforts`. */\n reasoningEffort?: string\n /** Emit `stream_delta` events for token-by-token rendering. Default true. */\n includePartialMessages?: boolean\n /** Per-session override of the server's permission-request timeout (ms). */\n approvalTimeoutMs?: number\n /** AskUserQuestion handling (see {@link QuestionBehavior}). Default 'ask'. */\n questionBehavior?: QuestionBehavior\n /** Provider engine only: run with fewer capabilities than the profile grants\n * (see {@link ProfileSessionDefaults.capabilities}). Narrowing only — naming a\n * capability the profile does not grant is a 400, not a silent upgrade. */\n capabilities?: SessionCapability[]\n /** Free-form metadata echoed back on SessionInfo (host app bookkeeping). */\n meta?: Record<string, unknown>\n /**\n * Opaque string tags naming what this session *belongs to* — the gateway's\n * only intra-deployment scoping primitive. Assigned at create, **immutable\n * afterwards** (no route writes it), echoed on {@link SessionInfo}, and\n * carried through parking/dormancy so a restart cannot un-scope a session.\n *\n * WorkerDeck never interprets a key: an embedder writes `{ space, user }` or\n * `{ tenant }` or nothing at all. What the tags *mean* is the host's\n * `authorizeSession` predicate; absent one, the default rule is that every\n * key the authenticated principal pins must match here (an unset principal\n * scope is unrestricted — the same \"unset means all\" rule `allowedProfiles`\n * uses, so an operator's dashboard keeps working unchanged).\n *\n * NOT `meta`: `meta` is free-form, client-settable and echoed, and an\n * enforcement rule whose input the caller supplies is not an enforcement\n * rule. Values are visible to any principal the policy admits a session to,\n * so use opaque ids rather than names you would not show that audience.\n */\n scope?: Record<string, string>\n}\n\nexport type SessionInfo = {\n /** Server-assigned id (stable across SDK session forks/resumes). */\n id: string\n /** Underlying Agent SDK session id, once known; use for `resume`. */\n sdkSessionId?: string\n status: SessionStatus\n /** Empty string for a session whose engine has no host filesystem (see\n * {@link CreateSessionRequest.cwd}). Deliberately not optional: every client\n * renders and searches it, and a synthetic path would send the workspace and\n * `@file` search probing a directory that does not exist. */\n cwd: string\n /** Profile the session runs under (resolved name, present even when implicit). */\n profile?: string\n /** Engine actually running this session, reported by the runner itself. Lets a\n * session surface gate CLI-only affordances (permission modes, context usage,\n * rate limits) without looking the profile back up. Absent = 'claude'. */\n engine?: ProfileEngine\n /** The engine's capability record, reported by the runner like `engine`. The\n * attach snapshot is the session-level source (no event carries it). Absent =\n * ENGINE_CAPABILITIES[engine]. */\n capabilities?: EngineCapabilities\n model?: string\n permissionMode?: PermissionMode\n /** Whether this session may be switched into `bypassPermissions`. The CLI only\n * allows it when the process was spawned for it, so it is decided at creation\n * and never changes: a session that did not ask for bypass up front cannot\n * gain it later. Lets a picker disable the mode instead of offering a switch\n * the engine will refuse. Absent = unknown (an older server). */\n canBypassPermissions?: boolean\n /** See the `system_init` event; 'oauth' = claude.ai subscription credentials. */\n apiKeySource?: string\n createdAt: number\n /** Highest event seq emitted so far; attach with `afterSeq` to catch up. */\n lastSeq: number\n pendingPermissionCount: number\n meta?: Record<string, unknown>\n /** Display title: `meta.title` if the host set one, else derived (e.g. first prompt). */\n title?: string\n /** Cumulative cost across all turns so far (sum of turn_result totals). */\n totalCostUsd?: number\n /** Cumulative turn count across the session. */\n numTurns?: number\n /**\n * How many transcript rows this session has produced (see\n * {@link transcriptActivity}) — a monotonic counter a client can diff against\n * a remembered value to answer \"how much happened while I wasn't looking\",\n * without attaching.\n *\n * `numTurns` cannot answer it: five tool calls inside one turn are one turn.\n * `lastSeq` cannot either — it counts every event, and with token streaming on\n * that is hundreds per reply. Absent on an older server; a client should fall\n * back to `numTurns` rather than showing nothing.\n */\n activityCount?: number\n /** Epoch ms of the most recent emitted event. */\n lastActivityAt?: number\n /** Opaque scope tags this session was created with — see\n * {@link CreateSessionRequest.scope}. Echoed by the runner, re-stamped by the\n * gateway, and never editable. */\n scope?: Record<string, string>\n}\n\n/**\n * How many transcript rows an event materializes — the unit behind\n * {@link SessionInfo.activityCount}.\n *\n * Deliberately the *reducer's* rule (`@workerdeck/react`'s `transcript.ts`), not\n * a server-side approximation: one row per content block of an assistant\n * message (a text, a thought, each tool call), one for a user message, one per\n * turn result, delivered file or error. Everything else — status changes, usage\n * readings, stream deltas, permission bookkeeping — is state, not a row, and\n * counts zero.\n *\n * It lives in `protocol` because both sides need it and neither may import the\n * other: the runners count with it, and any client compares the totals. If the\n * reducer's row rule changes, change this with it.\n */\nexport function transcriptActivity(body: SessionEventBody): number {\n switch (body.type) {\n case 'assistant_message': {\n const content = body.message.content\n // A string body is one text row. Blocks are one row each, except tool\n // results (which land inside the call's own row) and unknown blocks.\n if (typeof content === 'string') return content.trim() === '' ? 0 : 1\n const rows = content.filter(\n (block) => block.type === 'text' || block.type === 'thinking' || block.type === 'tool_use',\n ).length\n return rows\n }\n case 'user_message':\n // Tool results arrive as synthetic user messages; they are not rows.\n return body.synthetic ? 0 : 1\n case 'turn_result':\n case 'file_delivered':\n case 'session_error':\n return 1\n default:\n return 0\n }\n}\n\n/**\n * A session in an engine's on-disk store (independent of this server's registry):\n * the Agent SDK's session files, or a codex profile's CODEX_HOME threads. Listed\n * so hosts can offer \"resume\" across server restarts: feed `sessionId` to\n * CreateSessionRequest.resume — under a profile of the SAME engine, since the id\n * only means something to the store it came from. `GET {basePath}/sdk-sessions`\n * takes an optional `profile` query parameter naming whose store to list; absent,\n * the profile is resolved implicitly when the server declares exactly one, else\n * the Claude engine's store is listed (the pre-engine-aware behavior). Mirrors\n * the SDK's SDKSessionInfo shape, kept browser-safe.\n */\nexport type SdkSessionSummary = {\n sessionId: string\n /** Custom title, auto summary, or first prompt — whichever the SDK has. */\n summary: string\n /** Epoch ms of last modification. */\n lastModified: number\n createdAt?: number\n customTitle?: string\n firstPrompt?: string\n gitBranch?: string\n cwd?: string\n}\n\n/** One deliverable in the session's scratch filesystem (see the `file_delivered` event). */\nexport type SessionFileInfo = { path: string; bytes: number }\n/** `GET {basePath}/sessions/:id/files` — every file currently in the session's VFS.\n * `GET {basePath}/sessions/:id/files/<path>` downloads one (attachment disposition).\n * 404 when the session's engine exposes no VFS (Claude-engine sessions). */\nexport type ListSessionFilesResponse = { files: SessionFileInfo[] }\nexport type ListSessionsResponse = { sessions: SessionInfo[] }\nexport type CreateSessionResponse = { session: SessionInfo }\nexport type GetSessionResponse = { session: SessionInfo }\n\n/**\n * Body of `PATCH {basePath}/sessions/:id` — the host-facing edits to a live\n * session. Today that is only its display name: `title` writes `meta.title`,\n * which {@link SessionInfo.title} prefers over the derived one, and `null` (or\n * an empty string) clears the override so the derived title comes back. Nothing\n * here reaches the engine — renaming does not speak to the model.\n *\n * 409 when the session is parked: a parked session has no runner to carry the\n * change, and its snapshot is the host's to rewrite, not this route's.\n */\nexport type UpdateSessionRequest = { title?: string | null }\nexport type UpdateSessionResponse = { session: SessionInfo }\n\n/** Body of `POST {basePath}/sessions/:id/permissions/:requestId` — the REST counterpart\n * of the WS `permission_decision` command, for remote controllers without a socket\n * (e.g. answering a job's AskUserQuestion from a webhook consumer). 404 = the request\n * is unknown, already resolved, or expired. */\nexport type ResolvePermissionRequest =\n | { behavior: 'allow'; updatedInput?: Record<string, unknown> }\n | { behavior: 'deny'; message?: string; interrupt?: boolean }\nexport type ResolvePermissionResponse = { resolved: true }\n\n/**\n * Body of `POST {basePath}/executions/:executionId/result` — the way a deferred\n * executor (a remote worker, a batch job, a human) delivers the outcome of an\n * execution the session parked on. The session is rehydrated if its runner was\n * torn down, and the result is folded back into the agent loop; a `failed` result\n * is ordinary tool output the agent adapts to, not a session error.\n *\n * Applied **idempotently by `executionId`**: a duplicate or late delivery (one\n * racing the execution watchdog) answers 200 with `applied: false` rather than\n * erroring or applying twice. 404 means no session is parked on that id.\n */\nexport type SubmitExecutionResultRequest =\n | { status: 'ok'; output: ToolExecutionOutput; logs?: string[] }\n | { status: 'failed'; reason: string; error: string; logs?: string[] }\nexport type SubmitExecutionResultResponse = {\n /** False when the id was already settled — the delivery was a no-op. */\n applied: boolean\n /** Session the execution belonged to. */\n sessionId: string\n}\n\nexport type ListSdkSessionsResponse = { sdkSessions: SdkSessionSummary[] }\n/** `GET {basePath}/profiles` — filtered to the profiles the caller may use. */\nexport type ListProfilesResponse = {\n profiles: ProfileInfo[]\n /** Whether this caller may create profiles here — true only when the server has\n * a profile store AND the principal carries `canManageProfiles`. Lets a UI hide\n * controls that would always be refused. */\n canManage?: boolean\n}\n\n/**\n * `POST {basePath}/profiles` — create a managed profile. Available only when the\n * server was given a profile store, and only to a principal with\n * `canManageProfiles`. Profiles declared in server options are code, not data:\n * they cannot be created, edited, or deleted through these routes.\n */\nexport type CreateProfileRequest = ProfileInfo\n\n/** `PATCH {basePath}/profiles/:name` — merge into a managed profile. The name is\n * the route, not the body; pass `null` to clear an optional field. */\nexport type UpdateProfileRequest = Omit<Partial<ProfileInfo>, 'name'>\n\n// ---------------------------------------------------------------------------\n// Host filesystem (`{basePath}/fs/*`)\n// ---------------------------------------------------------------------------\n\n/**\n * The **host's real project tree**, not a session's in-memory VFS — the two are\n * unrelated despite both being \"files\". {@link SessionFileInfo} is a deliverable\n * the agent produced inside a session; these routes read and write the operator's\n * actual disk.\n *\n * That makes them **operator-privileged**: they are authorized by the server's auth\n * key alone and deliberately sit outside the agent permission flow, because the\n * caller *is* the operator, not the model. A client holding the key can already\n * start a session with any allowed cwd; browsing that same tree grants it nothing\n * new. Writing does, which is why writes are separately enabled server-side.\n *\n * The whole surface is opt-in and root-scoped: with no roots configured every route\n * below 404s. There is no \"unset means anything\" default here — a phone on a tailnet\n * must never be one request away from `~/.ssh`.\n */\nexport type HostFileRoot = {\n /** Absolute, canonical (symlinks resolved) path of the root. */\n path: string\n /** Last path segment, for display — roots are not named by the operator. */\n name: string\n}\n\n/** `GET {basePath}/fs/roots` — where a client may start browsing. Empty `roots`\n * never happens: the routes are absent entirely when none are configured. */\nexport type ListHostRootsResponse = {\n roots: HostFileRoot[]\n /** Whether `PUT /fs/write` is enabled here; lets a UI hide an editor it can't save from. */\n canWrite: boolean\n}\n\n/** One entry in a host directory listing. Classified with `lstat` semantics, so a\n * `symlink` is reported as itself and never silently resolved — following it is the\n * *next* request's problem, and that request is refused if it escapes the roots. */\nexport type HostDirEntry = {\n name: string\n /** Absolute path, ready to pass back as `?path=`. */\n path: string\n type: 'file' | 'dir' | 'symlink' | 'other'\n /** Regular files only. */\n bytes?: number\n /** Epoch ms mtime. */\n modifiedAt?: number\n}\n\n/** `GET {basePath}/fs/list?path=<abs>` — one directory, not recursive. */\nexport type ListHostDirResponse = {\n /** Canonical path actually listed (the request's path after symlink resolution). */\n path: string\n /** Directories first, then files, each alphabetical. */\n entries: HostDirEntry[]\n /** Set when the directory held more entries than the server will return. */\n truncated?: boolean\n}\n\n/** One hit from `GET {basePath}/fs/find`. */\nexport type HostFileMatch = {\n /** Absolute path, for a follow-up read. */\n path: string\n /** Path relative to the searched directory — what a picker shows and inserts. */\n relative: string\n}\n\n/**\n * `GET {basePath}/fs/find?path=<dir>&q=<query>&limit=<n>` — recursive fuzzy file\n * search under one directory, which is what an `@file` picker needs and\n * `/fs/list` is not: listing answers \"what is in this directory\", this answers\n * \"which file in this tree did you mean\".\n *\n * Subsequence matching (`seslist` finds `SessionListView.swift`), filename hits\n * ranked above path hits, shallow files above deep ones. An empty `q` returns the\n * shallowest files. Build directories (`.git`, `node_modules`, …) are skipped, as\n * is anything behind a symlink — so every path returned is one `/fs/read` will\n * accept.\n */\nexport type FindHostFilesResponse = {\n /** Canonical directory the search ran under; `relative` paths are relative to it. */\n base: string\n matches: HostFileMatch[]\n /** More matched, or the tree was larger than the server would walk. */\n truncated: boolean\n}\n\n/** `GET {basePath}/fs/read?path=<abs>` — one file's contents. Binary files come back\n * base64; 413 rather than a truncated read when the file exceeds the server's cap. */\nexport type ReadHostFileResponse = {\n path: string\n content: string\n encoding: 'utf8' | 'base64'\n bytes: number\n /** sha256 (hex) of the bytes on disk. Pass it back as `expectedHash` to write. */\n hash: string\n modifiedAt: number\n}\n\n/**\n * `PUT {basePath}/fs/write` — replace or create one file.\n *\n * The agent is editing this same tree, so a write is **conditional, always**:\n * `expectedHash` must be the hash from the read this edit is based on, and the\n * server 409s if the file has changed since. Omitting it means \"create\" and 409s\n * if the path already exists — there is no unconditional overwrite, by design.\n * Directories are never created implicitly: writing under a missing parent is a 404.\n */\nexport type WriteHostFileRequest = {\n path: string\n content: string\n /** Default 'utf8'. */\n encoding?: 'utf8' | 'base64'\n /** Required to overwrite; omit only to create a new file. */\n expectedHash?: string\n}\n\nexport type WriteHostFileResponse = {\n path: string\n bytes: number\n /** Hash of what was just written — carry it into the next edit. */\n hash: string\n modifiedAt: number\n}\n\nexport type SaveProfileResponse = { profile: ProfileInfo }\n/** `GET {basePath}/profiles/:name` — the profile plus a fresh config snapshot. */\nexport type GetProfileResponse = { profile: ProfileInfo; config: ProfileConfigSnapshot }\nexport type ErrorResponse = { error: string }\n\n// ---------------------------------------------------------------------------\n// Session notifications (the out-of-band \"something wants you\" channel)\n// ---------------------------------------------------------------------------\n\n/**\n * The moments in an *interactive* session a person needs to hear about when they\n * are not watching it — the whole point being that a phone cannot hold a\n * WebSocket open in the background, so the server has to reach out.\n *\n * Deliberately four: this is a human-attention channel, not an event mirror. The\n * event log stays on the session WS (attach with `afterSeq` to catch up); if you\n * want every assistant message, subscribe there instead.\n */\nexport type SessionNotificationType =\n /** The agent is blocked on an approval — the one that matters most. */\n | 'permission_requested'\n /** A turn finished; the session is idle and waiting for the human. */\n | 'turn_completed'\n /** The session failed (`session_error`). */\n | 'session_error'\n /** The session ended (`session_closed`), whoever ended it. */\n | 'session_closed'\n\n/** One delivery on the session-notification channel (JSON body of a webhook POST). */\nexport type SessionNotification = {\n type: SessionNotificationType\n sessionId: string\n /** Snapshot at notification time — status, title, cwd, cost, `lastSeq`. */\n session: SessionInfo\n /** Seq of the event behind this notification; attach with `afterSeq: seq - 1` to\n * land on it. */\n seq: number\n ts: number\n /** One line fit for a notification body: the permission title, the turn's final\n * text, the error message. */\n preview?: string\n /** `permission_requested` only: the full request, so a consumer can answer it via\n * `POST {basePath}/sessions/:id/permissions/:requestId` — which is what makes an\n * Approve/Deny action on a lock-screen notification possible. */\n request?: PermissionRequest\n /** `turn_completed` only. */\n result?: { isError: boolean; durationMs: number; numTurns: number; totalCostUsd: number }\n /** `session_closed` only. */\n reason?: 'client' | 'server' | 'error'\n}\n\n/** Where session notifications are POSTed (JSON body = {@link SessionNotification}).\n * Server-wide, not per session: the point is to hear about sessions you did not\n * create yourself and are not attached to. */\nexport type SessionWebhookConfig = {\n url: string\n /** Extra headers sent with every delivery (auth tokens etc.). */\n headers?: Record<string, string>\n /** Types to deliver. Default: all of them. */\n events?: SessionNotificationType[]\n}\n\n// ---------------------------------------------------------------------------\n// Job queue (one-shot scheduled runs over the session runner)\n// ---------------------------------------------------------------------------\n\n/**\n * - `queued` — accepted, waiting for a concurrency slot (or the daily token budget)\n * - `running` — a session is executing the prompt\n * - `parked` — waiting on an external event (a deferred tool execution). Not\n * terminal and not consuming a concurrency slot; resumes to `running` when the\n * result arrives, or fails via the execution watchdog if it never does.\n * - `succeeded` / `failed` — terminal; `result` (and `error` on failure) are set\n * - `canceled` — terminal; canceled by a client before or during the run\n */\nexport type JobStatus = 'queued' | 'running' | 'parked' | 'succeeded' | 'failed' | 'canceled'\n\n/** Where job progress/completion deliveries are POSTed (JSON body = {@link JobEvent}). */\nexport type WebhookConfig = {\n url: string\n /** Extra headers sent with every delivery (auth tokens etc.). */\n headers?: Record<string, string>\n /** Delivery granularity: 'messages' also POSTs job_progress per assistant message /\n * permission request; 'completion' only job_started + job_completed. Default 'messages'. */\n progress?: 'messages' | 'completion'\n}\n\n/**\n * Schedule a one-shot run: the session executes `prompt` unattended and the job\n * completes with that run's result. `session.prompt` is the task and is required;\n * `resume`/`forkSession` are not supported for queued jobs.\n */\nexport type CreateJobRequest = {\n session: CreateSessionRequest & { prompt: string }\n webhook?: WebhookConfig\n /** Per-job token cap; the effective cap is min(this, the server's sessionTokenLimit). */\n maxTokens?: number\n /** Per-job wall-clock cap; the effective cap is min(this, the server's maxJobDurationMs). */\n maxDurationMs?: number\n /** Total run attempts: failed (not canceled) runs re-queue until this many attempts\n * have been made. Default 1 (no retries). */\n attempts?: number\n /** Delay before the first retry, doubled for each subsequent one. Default 5000. */\n retryDelayMs?: number\n /** Host bookkeeping echoed back on JobInfo. */\n meta?: Record<string, unknown>\n}\n\n/** Cumulative resource usage of a job's run. `tokens` counts input + output +\n * cache-creation + cache-read tokens across all turns. */\nexport type JobUsage = {\n tokens: number\n totalCostUsd: number\n numTurns: number\n}\n\n/** Terminal outcome of the job's run (mirrors the final turn_result). */\nexport type JobResult = {\n subtype: string\n isError: boolean\n /** Final text of the run (success only). */\n result?: string\n errors?: string[]\n durationMs: number\n}\n\nexport type JobInfo = {\n id: string\n status: JobStatus\n /** `''` when the run's engine has no host filesystem (see\n * {@link CreateSessionRequest.cwd}). */\n cwd: string\n /** Profile the run executes under (resolved name, present even when implicit). */\n profile?: string\n prompt: string\n /** Server session id once started — attach via the sessions WS to watch the run live. */\n sessionId?: string\n sdkSessionId?: string\n createdAt: number\n startedAt?: number\n finishedAt?: number\n /** 1-based run attempt this info reflects. */\n attempt?: number\n /** Total attempts configured on the request (see CreateJobRequest.attempts). */\n maxAttempts?: number\n /** For a job re-queued by retry backoff: earliest time the next attempt may start. */\n nextRunAt?: number\n /** Set while `status` is 'parked': when the run parked, and the execution it is\n * waiting on — the id to POST a result to. Cleared when it resumes. */\n parkedAt?: number\n parkedExecutionId?: string\n /** Cumulative across attempts. */\n usage: JobUsage\n result?: JobResult\n /** Failure or cancellation reason (for a queued retry: the previous attempt's error). */\n error?: string\n meta?: Record<string, unknown>\n /** Scope tags of the session this job runs (see\n * {@link CreateSessionRequest.scope}) — copied from the request at submit so\n * the job routes can be gated by the same rule as the session routes. Without\n * it the queue would be a side door into an unscoped session. */\n scope?: Record<string, string>\n}\n\n/** Latest mid-run activity, carried on job_progress deliveries. */\nexport type JobProgress = {\n kind: 'assistant_text' | 'tool_use' | 'permission_requested' | 'permission_resolved'\n /** Short human-readable preview (message excerpt, tool name, permission title). */\n preview?: string\n /** 'permission_requested' only: the full request (including AskUserQuestion input) so\n * webhook consumers can answer via POST /sessions/:sessionId/permissions/:requestId. */\n request?: PermissionRequest\n}\n\n/** Webhook delivery payload (also the queue's local event shape). `job_submitted` goes\n * to local observers and the queue WS only — the submitter already has the POST\n * response, so webhooks start at `job_started`. `job_retrying` marks a failed run that\n * was re-queued (`job.nextRunAt` says when); `job_completed` is always terminal. */\nexport type JobEvent =\n | { type: 'job_submitted'; job: JobInfo; ts: number }\n | { type: 'job_started'; job: JobInfo; ts: number }\n | { type: 'job_progress'; job: JobInfo; progress: JobProgress; ts: number }\n /** The run parked on a deferred execution; `executionId` says what it waits on —\n * the id to POST a result to. The *work itself* (tool name, input, VFS seed) went\n * to the executor's own dispatch hook, not over this channel: a webhook consumer\n * learns that a run is waiting, the worker learns what to do. */\n | { type: 'job_parked'; job: JobInfo; executionId: string; ts: number }\n /** A parked run resumed because its execution result arrived. */\n | { type: 'job_resumed'; job: JobInfo; executionId: string; ts: number }\n | { type: 'job_retrying'; job: JobInfo; ts: number }\n | { type: 'job_completed'; job: JobInfo; ts: number }\n\nexport type QueueStats = {\n maxConcurrency: number\n running: number\n queued: number\n /** Jobs waiting on a deferred execution. They hold no concurrency slot and\n * their wall-clock budget is not ticking. */\n parked: number\n sessionTokenLimit?: number\n dailyTokenLimit?: number\n /** Tokens consumed by queue jobs in the current UTC day. */\n dailyTokensUsed: number\n /** True when the daily budget is exhausted and queued jobs are being held. */\n paused: boolean\n}\n\n/** Frames sent on the queue WS (`{basePath}/queue/ws`). The stream is one-way\n * (server→client): every job's lifecycle as it happens, plus refreshed stats after\n * lifecycle changes. Clients send nothing; job mutations stay on REST. */\nexport type QueueServerFrame =\n | { type: 'queue_attached'; protocolVersion: number; stats: QueueStats }\n | { type: 'job_event'; event: JobEvent }\n | { type: 'queue_stats'; stats: QueueStats }\n\nexport type CreateJobResponse = { job: JobInfo }\nexport type GetJobResponse = { job: JobInfo }\nexport type ListJobsResponse = { jobs: JobInfo[] }\nexport type QueueStatsResponse = { stats: QueueStats }\n\nexport * from './session-list.ts'\nexport * from './watermarks.ts'\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["#store","#cache","#prune"],"sources":["../src/session-list.ts","../src/usage.ts","../src/watermarks.ts","../src/index.ts"],"sourcesContent":["import type { SessionInfo } from './index.ts'\n\n/**\n * How a sessions list is filtered, grouped and sorted — the whole of the view\n * config, kept pure and separate from the components so every surface renders\n * one derived list and nothing else decides what is visible.\n *\n * Framework-free like `transcript.ts`, and for the same reason: more than one\n * party has to agree. In the VS Code extension the webview renders the list and\n * the extension host counts the activity-bar badge over the same rows (a badge\n * that ignored the filter would announce work in sessions the list is\n * deliberately hiding); in the dashboard the list and its subset line derive\n * from it; on iOS it is mirrored to Swift the way the reducer is.\n *\n * Sessions are shown across ALL gateways by default; the gateway is a facet like\n * any other, not the frame the list lives in.\n */\n\n/** Coarse lifecycle bucket — what a person actually filters on. Raw statuses are\n * too many and too engine-shaped ('starting' vs 'running' is not a decision). */\nexport type SessionState = 'attention' | 'working' | 'idle' | 'ended'\n\nexport const STATE_ORDER: readonly SessionState[] = ['attention', 'working', 'idle', 'ended']\n\nexport const STATE_LABELS: Record<SessionState, string> = {\n attention: 'Needs attention',\n working: 'Working',\n idle: 'Idle',\n ended: 'Ended',\n}\n\nexport function sessionState(info: SessionInfo): SessionState {\n if (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') return 'attention'\n if (info.status === 'running' || info.status === 'starting') return 'working'\n if (info.status === 'failed' || info.status === 'closed') return 'ended'\n return 'idle'\n}\n\n/** The facets a session can be grouped or sorted by. */\nexport type Facet = 'gateway' | 'adapter' | 'state'\nexport type GroupBy = 'none' | Facet\nexport type SortBy = 'recent' | 'name' | Facet\n\nexport type ViewConfig = {\n search: string\n /** Empty = no filter. Ids, not names: names are editable. */\n gateways: string[]\n adapters: string[]\n states: SessionState[]\n /** Show only sessions inside the host's own folders. Inert where there is no\n * such notion (no folder open, a dashboard with no workspace), which is why it\n * can default on. */\n scoped: boolean\n groupBy: GroupBy\n sortBy: SortBy\n}\n\nexport const DEFAULT_VIEW_CONFIG: ViewConfig = {\n search: '',\n gateways: [],\n adapters: [],\n states: [],\n scoped: true,\n groupBy: 'state',\n sortBy: 'recent',\n}\n\n/**\n * One folder the surrounding host has open, as a place sessions can live in.\n *\n * `hostId` present = the folder belongs to exactly that gateway. Absent = a real\n * local folder, which only a loopback gateway's cwds can be inside: a remote\n * gateway's paths are on another machine, where an identical-looking path means\n * nothing.\n */\nexport type ScopeRoot = { hostId?: string; path: string }\n\n/** The host's own folders — the sessions list's intrinsic scope. */\nexport type WorkspaceScope = { label: string; roots: ScopeRoot[] }\n\n/** A session with everything the list needs to filter, group and label it. */\nexport type SessionRow = {\n hostId: string\n hostName: string\n /** Its gateway is loopback — its cwds are paths on this machine. */\n local: boolean\n adapter: string\n state: SessionState\n info: SessionInfo\n /** Transcript rows since this session was last on screen. 0 = nothing new (or\n * never visited, which is not the same as unread). */\n unseen: number\n}\n\nexport type SessionGroup = { key: string; label?: string; rows: SessionRow[] }\n\n/** The adapters actually present, for the filter chips — derived rather than\n * enumerated, so a new engine needs no change here. */\nexport function adaptersOf(rows: readonly SessionRow[]): string[] {\n return [...new Set(rows.map((r) => r.adapter))].sort()\n}\n\nexport function sessionLabel(info: SessionInfo): string {\n return info.title ?? info.id.slice(0, 8)\n}\n\n/**\n * This session is a job run — the queue created it, and `JobInfo.sessionId`\n * points at it.\n *\n * A job run is an ordinary registry session in every other respect, which is\n * what makes this worth spelling once: a client that renders jobs on their own\n * surface should not list them again among the sessions, and a client with no\n * jobs surface (the extension, the phone) should, or they would be invisible.\n * The queue stamps `meta.jobId`; nothing else may write that key.\n */\nexport function isJobRun(info: SessionInfo): boolean {\n return typeof info.meta?.jobId === 'string'\n}\n\nfunction matchesSearch(row: SessionRow, needle: string): boolean {\n if (!needle) return true\n return (\n sessionLabel(row.info).toLowerCase().includes(needle) ||\n row.info.cwd.toLowerCase().includes(needle) ||\n row.hostName.toLowerCase().includes(needle) ||\n row.adapter.toLowerCase().includes(needle) ||\n row.info.id.startsWith(needle)\n )\n}\n\n/** Trailing separators dropped and separators unified, so containment is a\n * plain prefix test on both a posix and a Windows gateway. */\nfunction normalizePath(path: string): string {\n return path.replace(/\\\\/g, '/').replace(/\\/+$/, '')\n}\n\nfunction isWithin(root: string, path: string): boolean {\n const base = normalizePath(root)\n const dir = normalizePath(path)\n // The separator matters: /a/project must not swallow /a/project-2.\n return dir === base || dir.startsWith(`${base}/`)\n}\n\n/**\n * Is this session inside one of the host's folders? A gateway-tagged root only\n * ever matches its own gateway; an untagged one only matches a loopback gateway,\n * because a remote gateway's identical-looking path is a different machine's\n * directory.\n */\nexport function inScope(row: SessionRow, scope: WorkspaceScope): boolean {\n return scope.roots.some(\n (root) =>\n (root.hostId ? root.hostId.toLowerCase() === row.hostId.toLowerCase() : row.local) &&\n isWithin(root.path, row.info.cwd),\n )\n}\n\n/** Whether the scope filter is actually hiding anything — it is inert with no\n * folder open, and that is the difference between a default and a filter. */\nexport function scopeActive(config: ViewConfig, scope: WorkspaceScope | undefined): boolean {\n return config.scoped && scope !== undefined\n}\n\nexport function filterRows(\n rows: readonly SessionRow[],\n config: ViewConfig,\n scope?: WorkspaceScope,\n): SessionRow[] {\n const needle = config.search.trim().toLowerCase()\n const scoping = scopeActive(config, scope) ? scope : undefined\n return rows.filter(\n (row) =>\n (config.gateways.length === 0 || config.gateways.includes(row.hostId)) &&\n (config.adapters.length === 0 || config.adapters.includes(row.adapter)) &&\n (config.states.length === 0 || config.states.includes(row.state)) &&\n (!scoping || inScope(row, scoping)) &&\n matchesSearch(row, needle),\n )\n}\n\nfunction facetKey(row: SessionRow, facet: Facet): string {\n return facet === 'gateway' ? row.hostId : facet === 'adapter' ? row.adapter : row.state\n}\n\nfunction facetLabel(row: SessionRow, facet: Facet): string {\n return facet === 'gateway'\n ? row.hostName\n : facet === 'adapter'\n ? row.adapter\n : STATE_LABELS[row.state]\n}\n\n/** Comparable rank for a facet: states run worst-first (attention before ended),\n * the rest alphabetically by their visible label. */\nfunction facetRank(row: SessionRow, facet: Facet): string {\n if (facet === 'state') return String(STATE_ORDER.indexOf(row.state))\n return facetLabel(row, facet).toLowerCase()\n}\n\nconst byRecency = (a: SessionRow, b: SessionRow) =>\n (b.info.lastActivityAt ?? b.info.createdAt) - (a.info.lastActivityAt ?? a.info.createdAt)\n\nfunction compare(a: SessionRow, b: SessionRow, sortBy: SortBy): number {\n if (sortBy === 'recent') return byRecency(a, b)\n if (sortBy === 'name') {\n return (\n sessionLabel(a.info).localeCompare(sessionLabel(b.info), undefined, {\n sensitivity: 'base',\n }) || byRecency(a, b)\n )\n }\n return facetRank(a, sortBy).localeCompare(facetRank(b, sortBy)) || byRecency(a, b)\n}\n\n/**\n * The list as rendered: filtered, grouped, and sorted within each group. Groups\n * themselves come out in the sort's own order — grouping by state and sorting by\n * name should still put \"Needs attention\" first, so groups are ordered by their\n * facet rank, never by the row sort.\n */\nexport function groupRows(rows: readonly SessionRow[], config: ViewConfig): SessionGroup[] {\n const sorted = [...rows].sort((a, b) => compare(a, b, config.sortBy))\n if (config.groupBy === 'none') return sorted.length ? [{ key: 'all', rows: sorted }] : []\n const facet = config.groupBy\n const groups = new Map<string, SessionGroup & { rank: string }>()\n for (const row of sorted) {\n const key = facetKey(row, facet)\n const group = groups.get(key)\n if (group) group.rows.push(row)\n else {\n groups.set(key, {\n key,\n label: facetLabel(row, facet),\n rank: facetRank(row, facet),\n rows: [row],\n })\n }\n }\n return [...groups.values()].sort((a, b) => a.rank.localeCompare(b.rank))\n}\n\n/**\n * What the list is hiding, and why — the one \"you are seeing a subset\" signal.\n *\n * There used to be two: a dot on the funnel and a scope line above the list.\n * They competed (the scope line said one thing, the dot counted a superset of\n * it) and neither said how much was missing. This is the single rule both the\n * count and the wording come from: absent when nothing is hidden, and otherwise\n * naming every cause, so the line is never \"12 of 30\" with no way to guess why.\n *\n * Search is a cause like any other. Its box is visible, but the *consequence*\n * of it — rows gone from the list — is the thing being reported, and leaving it\n * out would make the arithmetic wrong.\n */\nexport type SubsetSummary = { shown: number; total: number; causes: string[] }\n\nexport function subsetSummary(\n config: ViewConfig,\n scope: WorkspaceScope | undefined,\n shown: number,\n total: number,\n): SubsetSummary | undefined {\n if (shown >= total) return undefined\n const causes: string[] = []\n if (scope && scopeActive(config, scope)) causes.push(scope.label)\n // The facets collapse to a count: naming three of them would wrap the line in\n // a sidebar, and the funnel beside it is where their detail already lives.\n const facets =\n (config.gateways.length ? 1 : 0) +\n (config.adapters.length ? 1 : 0) +\n (config.states.length ? 1 : 0)\n if (facets > 0) causes.push(`${facets} filter${facets === 1 ? '' : 's'}`)\n if (config.search.trim()) causes.push('search')\n return { shown, total, causes }\n}\n\n/**\n * Is anything OTHER than the workspace scope narrowing the list?\n *\n * The distinction an empty list turns on: \"this project has no sessions\" wants a\n * different sentence, and a different way out, from \"your filters match none\".\n * Scope is excluded because it is on by default — it is the state, not a choice\n * someone made.\n */\nexport function hasFacetFilter(config: ViewConfig): boolean {\n return (\n config.search.trim().length > 0 ||\n config.gateways.length > 0 ||\n config.adapters.length > 0 ||\n config.states.length > 0\n )\n}\n\n/** \"Show me everything\": every filter off, including scope. The group/sort\n * choices are a layout preference and survive. */\nexport function clearFilters(config: ViewConfig): ViewConfig {\n return {\n ...DEFAULT_VIEW_CONFIG,\n scoped: false,\n groupBy: config.groupBy,\n sortBy: config.sortBy,\n }\n}\n","import type { ProfileUsage, RateLimitInfo } from './index.ts'\n\n/**\n * What one session was last told about the plan's windows: the transcript's own\n * rate-limit state, and the event clock of the newest reading in it.\n *\n * Deliberately structural rather than `TranscriptState` — protocol may not\n * import a client — and it is exactly the two fields the reducer keeps.\n */\nexport type SessionUsage = {\n /** Keyed by `rateLimitType`, as the reducer stores it. */\n rateLimits?: Record<string, RateLimitInfo>\n /** Epoch ms of the newest `rate_limit` event this session saw — one clock for\n * the whole map, which is all the reducer records. */\n updatedAt?: number\n}\n\n/**\n * The usage a client should render: the gateway's per-profile state where it has\n * the window, this session's own reading where it does not.\n *\n * Why the profile wins outright rather than by comparing timestamps: the\n * gateway's `ProfileUsageTracker` is fed from **every** session on the profile —\n * including this one, from seq 0 — and keeps the newest reading per window by\n * the event's own `ts`. So for any window it holds, it holds a reading at least\n * as new as the one in this transcript, and a timestamp comparison could only\n * ever go wrong: the reducer keeps a *single* `updatedAt` for the whole map, so\n * a `five_hour` reading from this morning is dated with the afternoon's\n * `seven_day` event and would beat a genuinely fresher profile entry.\n *\n * The session half is not a fallback for correctness but for *coverage*: the\n * profile map is in-memory, so a restarted gateway serves nothing until a\n * session reports again, and a session with no profile has no account state at\n * all. In both cases the transcript's reading is the only one there is, and it\n * is dated honestly (see {@link SessionUsage.updatedAt}) rather than as now.\n *\n * Absent stays absent throughout: a window nobody has reported is **unknown,\n * never 0%**, and this returns an empty map rather than inventing entries.\n */\nexport function mergeUsage(session: SessionUsage, profile: ProfileUsage | undefined): ProfileUsage {\n const out: ProfileUsage = {}\n for (const [key, info] of Object.entries(session.rateLimits ?? {})) {\n out[key] = { info, updatedAt: session.updatedAt ?? 0 }\n }\n for (const [key, window] of Object.entries(profile ?? {})) out[key] = window\n return out\n}\n\n/** One window as a surface draws it: the reading, its own date, and whether the\n * gateway is the one that zeroed it. */\nexport type UsageWindowRow = {\n key: string\n info: RateLimitInfo\n /** Epoch ms of the reading. Absent only for a hand-built state with no clock. */\n updatedAt?: number\n inferredReset?: boolean\n}\n\n/**\n * The windows in reading order: the session window, the weekly one, then the\n * per-model weeklies alphabetically.\n *\n * Discovered rather than hardcoded — the engine's set of windows is an open\n * union and has grown before — but ordered, so the first two always mean the\n * same thing wherever they are drawn. A window with no `utilization` is\n * **unknown, not zero**, and is dropped entirely rather than rendered as an\n * empty bar that reads as \"plenty left\".\n *\n * Here rather than in a client because two surfaces now render the same windows\n * from different sources — the session panel from its merged state, the\n * dashboard's profile page straight off `ProfileInfo.usage` — and a list that\n * ordered or filtered differently would be the same account described two ways.\n */\nexport function orderUsageWindows(usage: ProfileUsage | undefined): UsageWindowRow[] {\n const all = Object.entries(usage ?? {})\n .filter(([, w]) => w.info.utilization !== undefined)\n .map(([key, w]) => ({ key, info: w.info, updatedAt: w.updatedAt, inferredReset: w.inferredReset }))\n const named = ['five_hour', 'seven_day'].flatMap((key) => all.filter((w) => w.key === key))\n const perModel = all\n .filter((w) => w.key.startsWith('seven_day_'))\n .sort((a, b) => a.key.localeCompare(b.key))\n return [...named, ...perModel]\n}\n\n/** The flat `rateLimitType → reading` map every existing renderer takes, out of\n * the dated form. Undefined in, undefined out — so a surface can keep telling\n * \"no reading\" apart from \"an empty one\". */\nexport function usageInfos(\n usage: ProfileUsage | undefined,\n): Record<string, RateLimitInfo> | undefined {\n if (!usage) return undefined\n const out: Record<string, RateLimitInfo> = {}\n for (const [key, window] of Object.entries(usage)) out[key] = window.info\n return out\n}\n","/**\n * \"What had you seen, and when\" — per session, across reloads.\n *\n * Two numbers because two surfaces ask different questions. A session list has\n * only the REST rollup for sessions it isn't showing, so it compares **rows the\n * gateway counted**; a panel has the whole transcript, so it compares **rows it\n * rendered** and can put the mark in the right place. Keeping both means neither\n * surface has to attach to something it isn't rendering.\n *\n * A watermark is only written while a session is genuinely on screen. A surface\n * nobody can see is not being read, and marking it read is how an unread badge\n * silently stops working.\n *\n * Storage is a seam (`WatermarkStore`) rather than a dependency: the VS Code\n * extension backs it with `globalState`, the dashboard with `localStorage`, and\n * neither belongs in this package.\n */\nexport type Watermark = {\n /** Transcript rows seen (`SessionVitals.itemCount`). */\n itemCount: number\n /** Rows the gateway had counted (`SessionInfo.activityCount`) — the same unit\n * as `itemCount`, but from the rollup, so it is knowable for a session this\n * client is not showing. */\n activity: number\n /** Completed turns seen. The fallback unit for a gateway too old to report\n * `activityCount`; five tool calls in one turn count as one. */\n turns: number\n /** When this was last true. */\n seenAt: number\n}\n\n/** Where the marks are kept. Reads happen once at construction; writes are\n * whole-map and may be async — nothing here awaits them. */\nexport type WatermarkStore = {\n read(): Record<string, Watermark> | undefined\n write(marks: Record<string, Watermark>): void\n}\n\n/** Entries older than this are dropped on write — a session deleted months ago\n * should not keep a row in storage forever. */\nconst MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\n/** How stale \"last here\" is allowed to get before a write happens anyway. */\nconst TOUCH_MS = 60_000\n\nexport const watermarkKey = (hostId: string, sessionId: string) => `${hostId}:${sessionId}`\n\nexport class Watermarks {\n readonly #store: WatermarkStore\n #cache: Record<string, Watermark>\n\n constructor(store: WatermarkStore) {\n this.#store = store\n this.#cache = { ...store.read() }\n }\n\n get(hostId: string, sessionId: string): Watermark | undefined {\n return this.#cache[watermarkKey(hostId, sessionId)]\n }\n\n /** Every mark, for a caller deriving unread counts over a whole list. */\n all(): Readonly<Record<string, Watermark>> {\n return this.#cache\n }\n\n /**\n * Record what is on screen now. Monotonic on purpose: a transcript that\n * *shrank* (a compaction, a fresh attach mid-replay) must not walk the mark\n * backwards and resurrect rows the user already read.\n *\n * Returns whether the mark actually moved, because an unread badge is computed\n * from it and nothing else will say so: rows read in a panel do not touch the\n * sessions poll, so a caller that doesn't hear about this has no other way to\n * learn the count is now wrong.\n */\n mark(\n hostId: string,\n sessionId: string,\n seen: { itemCount?: number; activity?: number; turns?: number },\n now = Date.now(),\n ): boolean {\n const id = watermarkKey(hostId, sessionId)\n const previous = this.#cache[id]\n const next: Watermark = {\n itemCount: Math.max(previous?.itemCount ?? 0, seen.itemCount ?? 0),\n activity: Math.max(previous?.activity ?? 0, seen.activity ?? 0),\n turns: Math.max(previous?.turns ?? 0, seen.turns ?? 0),\n seenAt: now,\n }\n if (\n previous &&\n previous.itemCount === next.itemCount &&\n previous.activity === next.activity &&\n previous.turns === next.turns &&\n // Still worth a write once a minute so \"last here\" stays honest without\n // hammering storage on every streamed row.\n next.seenAt - previous.seenAt < TOUCH_MS\n ) {\n return false\n }\n this.#cache[id] = next\n this.#store.write(this.#prune(now))\n return true\n }\n\n /** Forget a session — it was deleted, and its mark is now noise. */\n forget(hostId: string, sessionId: string): void {\n const id = watermarkKey(hostId, sessionId)\n if (!(id in this.#cache)) return\n delete this.#cache[id]\n this.#store.write(this.#cache)\n }\n\n #prune(now: number): Record<string, Watermark> {\n const cutoff = now - MAX_AGE_MS\n for (const [id, mark] of Object.entries(this.#cache)) {\n if (mark.seenAt < cutoff) delete this.#cache[id]\n }\n return this.#cache\n }\n}\n\n/**\n * Rows this client has not seen, from the rollup alone.\n *\n * `activityCount` is the unit that makes an honest badge: turns undercount badly\n * (five tool calls in one turn is one turn) and a stream sequence overcounts\n * absurdly (every delta). Turns stay the fallback for a gateway too old to\n * report it.\n *\n * A session never visited returns 0 — \"never opened\" is not \"unread\", and a\n * badge that counted every session's whole history on first launch would be\n * noise on the one day it should be quiet.\n */\nexport function unseenCount(\n mark: Watermark | undefined,\n info: { activityCount?: number; turns?: number },\n): number {\n if (!mark) return 0\n if (info.activityCount !== undefined) return Math.max(0, info.activityCount - mark.activity)\n return Math.max(0, (info.turns ?? 0) - mark.turns)\n}\n","/**\n * @workerdeck/protocol — the wire protocol between a workerdeck server and its clients.\n *\n * One session = one ordered stream of {@link SessionEvent}s (each stamped with a monotonically\n * increasing `seq`) plus a small command set ({@link SessionCommand}). Clients attach over\n * WebSocket, optionally replaying from a known `seq`, and drive the session with commands.\n *\n * This package is dependency-free and browser-safe. Anthropic API message content is modeled\n * structurally (see {@link ApiMessage}) so clients don't need the Agent SDK to render transcripts.\n */\n\n/** Bumped on any breaking change to events, commands, or REST shapes. */\nexport const PROTOCOL_VERSION = 7\n\n// ---------------------------------------------------------------------------\n// Session lifecycle\n// ---------------------------------------------------------------------------\n\n/**\n * - `starting` — runner spawned, waiting for the SDK init handshake\n * - `running` — a turn is in progress\n * - `awaiting_approval` — blocked on at least one pending permission request\n * - `idle` — between turns; accepting user messages\n * - `parked` — waiting on a deferred tool execution. The live runner has been torn\n * down and the session's state persisted; delivering the execution's result\n * (`POST {basePath}/executions/:executionId/result`) rehydrates it under the same\n * id and the run continues. Not terminal.\n * - `failed` — the underlying query errored; terminal\n * - `closed` — closed by a client or the host; terminal\n */\nexport type SessionStatus =\n | 'starting'\n | 'running'\n | 'awaiting_approval'\n | 'idle'\n | 'parked'\n | 'failed'\n | 'closed'\n\nexport type PermissionMode =\n | 'default'\n | 'acceptEdits'\n | 'bypassPermissions'\n | 'plan'\n | 'dontAsk'\n | 'auto'\n\n// ---------------------------------------------------------------------------\n// API message content (structural mirror of Anthropic message shapes)\n// ---------------------------------------------------------------------------\n\nexport type TextBlock = { type: 'text'; text: string }\nexport type ThinkingBlock = { type: 'thinking'; thinking: string }\nexport type ToolUseBlock = { type: 'tool_use'; id: string; name: string; input: unknown }\nexport type ToolResultBlock = {\n type: 'tool_result'\n tool_use_id: string\n content?: string | Array<{ type: string; text?: string; [key: string]: unknown }>\n is_error?: boolean\n}\n/** Forward-compatible fallback for block types this protocol version doesn't model. */\nexport type UnknownBlock = { type: string; [key: string]: unknown }\n\n/**\n * One hunk of a file edit, in unified-diff terms.\n *\n * The numbers are the engine's own, not the client's: `newStart` is where this\n * hunk begins in the file *after* the edit, which is what a reader needs to jump\n * to the change. A client cannot compute them — it has never seen the file — so\n * a diff rendered without this is a diff with no line numbers.\n */\nexport type PatchHunk = {\n oldStart: number\n oldLines: number\n newStart: number\n newLines: number\n /** Body lines, each prefixed ' ' (context), '-' (removed) or '+' (added), as\n * unified diff spells them. The prefix is part of the string. */\n lines: string[]\n}\n\n/**\n * What a file-editing tool changed — the renderable half of an engine's edit\n * output, and deliberately only that half.\n *\n * Both engines can say far more: the Claude SDK's `FileEditOutput` carries\n * `originalFile`, the **entire** contents of the file before the edit. That must\n * not travel here. This log is replayed to every attaching client and captured\n * into parking snapshots, so a whole file on every edit is paid for again on\n * every attach, forever — the same reason attachment bytes are references (see\n * {@link MessageAttachment}) rather than inline base64.\n *\n * So the runner projects the engine's output down to the hunks, which is exactly\n * what a diff renders and nothing more.\n */\nexport type FilePatch = {\n /** Absolute path the engine reported, when it named one. */\n path?: string\n /** `create` when the file did not exist before this edit. */\n kind?: 'create' | 'update'\n hunks: PatchHunk[]\n /** Hunks were dropped to keep the event small. A renderer must say so rather\n * than present a partial diff as the whole change. */\n truncated?: boolean\n}\n\nexport type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock\n\n/**\n * A file the user attached to a message — a photo, a screenshot, a document.\n *\n * The bytes never travel on this protocol. An attachment is uploaded first\n * (`POST {basePath}/sessions/:id/attachments`), and the command that sends the\n * message names it by id; what lands in the seq-numbered event log is this\n * reference. That is deliberate: the log is replayed to every attaching client\n * and captured into parking snapshots, so a few phone photos inlined as base64\n * would be paid for on every attach, forever. Clients render a thumbnail by\n * fetching `GET {basePath}/sessions/:id/attachments/:attachmentId`.\n *\n * Lifetime is the session's, like `/files` — the store is in-memory and an\n * attachment 404s after a server restart. The message itself is unaffected: the\n * model saw the bytes at send time.\n */\nexport type MessageAttachment = {\n /** Server-assigned; the path segment of the download URL. */\n id: string\n /** Display name from the file the user picked. A leaf name, never a path. */\n name: string\n /** IANA media type, e.g. 'image/jpeg'. The server decides how it reaches the\n * model (image block, document block, or inlined text) from this. */\n mediaType: string\n bytes: number\n}\n\nexport type ApiMessage = {\n role: 'user' | 'assistant'\n content: string | ContentBlock[]\n model?: string\n stop_reason?: string | null\n /** Per-API-call token usage when the message carries it (assistant messages do).\n * Enables mid-run token accounting; result-message usage stays authoritative. */\n usage?: {\n input_tokens?: number\n output_tokens?: number\n cache_creation_input_tokens?: number\n cache_read_input_tokens?: number\n }\n}\n\n// ---------------------------------------------------------------------------\n// Permission requests\n// ---------------------------------------------------------------------------\n\n/** A tool call promoted into a pending approval by the runner's canUseTool hook\n * (Claude), or an engine ask-channel request surfaced by its runner (codex).\n * The two do not share a tense: Claude asks BEFORE a tool runs; codex's command\n * approval is usually an escalation AFTER its sandbox already ran and refused\n * the command (\"command failed; retry without sandbox?\"). The runner authors\n * `title`/`description`/`decisionReason` to say which — clients should render\n * those fields rather than composing their own \"X wants to run Y\" sentence. */\nexport type PermissionRequest = {\n /** Server-assigned id; used by the `permission_decision` command. */\n id: string\n toolName: string\n input: Record<string, unknown>\n toolUseId: string\n /** Full prompt sentence from the SDK, e.g. \"Claude wants to read foo.txt\". */\n title?: string\n /** Short noun phrase for the tool action, e.g. \"Read file\". */\n displayName?: string\n /** Human-readable subtitle, e.g. \"Claude will have read access to ~/x\". */\n description?: string\n /** Why this permission request was triggered. */\n decisionReason?: string\n /** If raised from within a subagent, that subagent's id. */\n agentId?: string\n /** Epoch ms after which the server resolves it via its timeout policy. */\n expiresAt?: number\n}\n\nexport type PermissionDecisionSource = 'client' | 'timeout' | 'policy'\n\n// ---------------------------------------------------------------------------\n// User questions (the AskUserQuestion tool)\n// ---------------------------------------------------------------------------\n\n/** One choice of an AskUserQuestion question (SDK tool-input mirror). */\nexport type UserQuestionOption = {\n label: string\n description?: string\n /** Optional preview content (markdown unless the session configures html)\n * rendered when the option is focused. */\n preview?: string\n}\n\n/** One question from the AskUserQuestion tool's input. By the tool's convention the\n * first option is the model's recommended choice. */\nexport type UserQuestion = {\n question: string\n /** Short chip/tag label (max ~12 chars), e.g. \"Auth method\". */\n header: string\n options: UserQuestionOption[]\n multiSelect?: boolean\n}\n\n/** How a session treats the AskUserQuestion tool:\n * - 'ask' (default) — a pending permission like any other: interactive UIs render the\n * question form; job webhooks carry the full request so a remote controller can\n * answer over REST (POST /sessions/:id/permissions/:requestId).\n * - 'auto' — resolved immediately with each question's first (recommended) option.\n * - 'deny' — the tool is refused with guidance to decide autonomously (unattended runs).\n * Answers ride a permission allow as `updatedInput.answers`: question text → chosen\n * option label(s), multi-select labels comma-joined — the shape the CLI's own UI uses. */\nexport type QuestionBehavior = 'ask' | 'auto' | 'deny'\n\n// ---------------------------------------------------------------------------\n// Session capabilities (models / slash commands the CLI reports)\n// ---------------------------------------------------------------------------\n\n/** A model the session can switch to (SDK ModelInfo mirror; fields it may grow stay unknown). */\nexport type ModelOption = {\n /** Model id for createSession.model / set_model. */\n value: string\n /** Wire model id this row resolves to ('sonnet' → 'claude-sonnet-5'). What a\n * session actually reports as its model is the resolved form, so this is how a\n * client matches the running model back to the row that names it. */\n resolvedModel?: string\n displayName: string\n description?: string\n /** Whether this belongs in a picker's main list rather than behind a \"more\n * models\" step: the newest model of each family. Derived server-side — the CLI\n * reports one flat list — so that every client groups it the same way. */\n primary?: boolean\n /** Reasoning efforts this model supports at create time (codex catalogs carry\n * them, from the binary's own per-model list). Absent = the engine's default\n * set ({@link EngineCapabilities.reasoningEfforts}) applies. Open strings —\n * the binary's vocabulary outruns its SDK's union. */\n reasoningEfforts?: readonly string[]\n}\n\n/**\n * A skill the engine can decide to use — **not** a command.\n *\n * The distinction is the whole point of this type existing beside\n * {@link SlashCommandInfo}. A slash command is wire syntax: the CLI parses\n * `/wrapup` out of the message and runs it. A skill is a capability the model\n * *chooses* from its description; there is no `/skillname` the engine would\n * recognise, and sending one reaches the model as literal text.\n *\n * So a client may list these, and may offer them as a **typing aid** that\n * inserts ordinary editable prose ({@link SkillInfo.defaultPrompt}) — but it\n * must never render them as command chips, and must never put them in\n * `capabilities.commands`, which means \"the CLI accepts these as commands\".\n */\nexport type SkillInfo = {\n /** Directory name under the skills root — the identity the model refers to. */\n name: string\n /** What the skill is for, as its own manifest states it. This is the text the\n * MODEL selects on, so it is also the most honest thing to show a human. */\n description?: string\n /** A one-liner where the skill declares one, for a list row too narrow for\n * `description`. */\n shortDescription?: string\n /** Human-facing name from the skill's own interface block, when it differs\n * from `name`. */\n displayName?: string\n /**\n * The engine's own suggested opening message for this skill. A client that\n * offers a picker INSERTS this as plain editable text for the user to finish\n * and send; it is a draft, never something submitted on selection.\n */\n defaultPrompt?: string\n /** Where the skill came from: 'user' | 'repo' | 'system' | 'admin' — kept as\n * a string, the engine's set may grow. */\n scope?: string\n /** False when the operator has this skill switched off: still listed, because\n * \"installed but off\" is a different answer from \"not installed\". */\n enabled: boolean\n}\n\n/** A slash command the CLI accepts as user-message text (SDK SlashCommand mirror). */\nexport type SlashCommandInfo = {\n /** Command name without the leading slash. */\n name: string\n description?: string\n /** Hint for arguments, e.g. \"<file>\". */\n argumentHint?: string\n /** Alternate names resolving to this command. */\n aliases?: string[]\n}\n\n// ---------------------------------------------------------------------------\n// Usage telemetry (context window + subscription rate limits)\n// ---------------------------------------------------------------------------\n\n/** One category row from the CLI's context-usage breakdown (system prompt, tools, ...). */\nexport type ContextUsageCategory = {\n name: string\n tokens: number\n /** Color the CLI assigns the category. Often a CLI theme token name ('inactive',\n * 'promptBorder', ...), not a CSS color — validate before styling with it. */\n color: string\n}\n\n/** Context-window usage snapshot (SDK getContextUsage mirror), polled after each turn. */\nexport type ContextUsage = {\n categories: ContextUsageCategory[]\n totalTokens: number\n maxTokens: number\n /** Used share of the window, 0–100. */\n percentage: number\n /** Model the window sizing applies to. */\n model?: string\n}\n\n/**\n * One rate-limit window snapshot (SDK SDKRateLimitInfo mirror). Emitted only for\n * claude.ai subscription sessions — API-key sessions may never produce one, so\n * clients must render nothing (not 0%) until data arrives.\n */\nexport type RateLimitInfo = {\n /** 'allowed' | 'allowed_warning' | 'rejected' — kept as string, the SDK union may grow. */\n status: string\n /** Which window: 'five_hour' (session), 'seven_day' (weekly), 'seven_day_opus',\n * 'seven_day_sonnet', 'overage', ... — kept as string, the SDK union may grow. */\n rateLimitType?: string\n /** Used share of the window, 0–100. The CLI omits it on some updates — treat\n * absent as unknown, not 0. */\n utilization?: number\n /** Epoch **seconds** when the window resets (render countdowns client-side). */\n resetsAt?: number\n isUsingOverage?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Tool execution (bridged, deferred, and remote)\n// ---------------------------------------------------------------------------\n\n/**\n * Lifecycle of one tool execution, correlated by `executionId` end to end.\n *\n * - `pending` — dispatched, result not in yet (bridged to a client, or queued).\n * - `deferred` — parked beyond this turn/process; may outlive the session's\n * liveness and be applied on rehydration.\n * - `settled` / `failed` — terminal. Results are applied idempotently by id, so\n * a duplicate delivery is a no-op rather than a second application.\n */\nexport type ToolExecutionStatus = 'pending' | 'deferred' | 'settled' | 'failed'\n\n/** Where a tool execution ran (or is running). Advisory: for display and routing. */\nexport type ToolExecutionBackend = 'server' | 'browser' | 'managed' | 'remote'\n\n/** Result payload of a tool execution, by value — never a live host reference. */\nexport type ToolExecutionOutput =\n | { type: 'text'; value: string }\n | { type: 'json'; value: unknown }\n\n// ---------------------------------------------------------------------------\n// Session events (server -> client)\n// ---------------------------------------------------------------------------\n\nexport type SessionEventBody =\n /** SDK init handshake: what this session actually is. */\n | {\n type: 'system_init'\n sdkSessionId: string\n model: string\n cwd: string\n /** Where the session's Anthropic auth came from: 'oauth' means a claude.ai\n * subscription login; other values ('user' | 'project' | 'org' | 'temporary')\n * are API-key provenance. Kept as string — the SDK union may grow. */\n apiKeySource: string\n tools: string[]\n skills: string[]\n slashCommands: string[]\n permissionMode: PermissionMode\n claudeCodeVersion: string\n mcpServers: Array<{ name: string; status: string }>\n }\n | { type: 'status_changed'; status: SessionStatus; detail?: string }\n /** Models and slash commands available to this session; fetched from the CLI after\n * init. Late attachers get it via replay like any other event. */\n | {\n type: 'capabilities'\n models: ModelOption[]\n commands: SlashCommandInfo[]\n /** Wire id the session's *default* resolves to, from the CLI's own `default`\n * row. Answers \"what will this session answer as\" before it has answered\n * anything — `system_init` carries the model, but a promptless session gets\n * no `system_init` until its first message. */\n defaultModel?: string\n }\n /**\n * The skills this session's engine can reach ({@link SkillInfo}) — a full\n * replacement each time, not a delta, so a late attacher's replay of several\n * of these converges on the last one. Emitted once the session's engine has\n * enumerated them, and again whenever the engine reports the set changed\n * (a skill added or edited on disk).\n *\n * Deliberately NOT folded into `capabilities.commands`: skills are not\n * commands (see {@link SkillInfo}). Only engines whose record sets\n * {@link EngineCapabilities.skillsList} ever emit it.\n */\n | { type: 'skills'; skills: SkillInfo[] }\n /**\n * The engine wrote a file on the **host filesystem** and handed over its path\n * — codex's `image_gen` saving a PNG is the case that motivated it. The\n * host-filesystem sibling of `file_delivered` (which is the scratch-VFS one).\n *\n * Fetch it at `GET {basePath}/sessions/:id/produced/:fileId` for as long as\n * the session lives. That route has no root allowlist and no size cap, and\n * that is sound *because of where the path came from*: this event is authored\n * by the runner about a file the engine itself just wrote, not by the agent\n * about a path it chose. `/fs/*` gates the second kind and must keep doing so\n * — a file the agent merely *read* is not a produced file and does not belong\n * here.\n *\n * Re-emitting the same file is a no-op: `fileId` is derived from the path, so\n * a runner that learns the path twice (codex reports `savedPath` on both the\n * progress and completed item) registers it once.\n */\n | {\n type: 'file_produced'\n /** Opaque, stable per session+path. The route's path segment. */\n fileId: string\n /** Absolute host path, as the engine reported it. Shown to the operator,\n * and what a client matches against a tool card's own `savedPath`. */\n path: string\n /** Media type when the runner could determine one (usually from the\n * extension). Absent = let the route's own sniffing decide. */\n mediaType?: string\n /** Size at the time it was reported, when the runner knew it. */\n bytes?: number\n /** The tool call that produced it, when one did. */\n toolUseId?: string\n }\n /** The session's model changed via `set_model`. `model` undefined = back to default. */\n | { type: 'model_changed'; model?: string }\n /** The session's permission mode changed via `set_permission_mode`. */\n | { type: 'permission_mode_changed'; mode: PermissionMode }\n /** Context-window usage snapshot; the runner polls it after each turn. */\n | { type: 'context_usage'; usage: ContextUsage }\n /** Subscription rate-limit update for one window (see {@link RateLimitInfo}). */\n | { type: 'rate_limit'; info: RateLimitInfo }\n /** Which claude.ai plan the rate-limit windows belong to: 'pro' | 'max' | 'team' |\n * 'enterprise' — kept as string, the set may grow. Emitted from the same poll as\n * `rate_limit`, once per change, and never for an API-key session (which has no\n * plan). It names the windows; it does not size them — the tier suffix a\n * subscription page shows (\"Max 20x\") is not in the data. */\n | { type: 'plan_info'; subscriptionType: string }\n /**\n * The engine started a **fresh conversation inside the same session** — the\n * CLI's `/clear`, a plan-mode exit, and whatever fresh-conversation flows the\n * SDK grows. The session id, registry row, workspace and scope are all\n * unchanged; only the conversation is new. Clients empty the transcript and\n * keep every session-scoped fact (models, commands, skills, produced files,\n * rate limits, cwd, permission mode).\n *\n * The server's replay honours it too: an attach after a reset does not\n * resurrect the cleared rows, because the runner skips *transcript content*\n * below the latest reset (see {@link transcriptContent}) while still\n * replaying every state-bearing event. `SessionInfo.activityCount` stays\n * monotonic across a reset on purpose — it is an unread cursor, not an item\n * count, and winding it back would kill every stored watermark above it.\n */\n | {\n type: 'conversation_reset'\n /** The engine session id the fresh conversation runs under (the SDK's\n * `new_conversation_id`), when the engine reported one. The follow-up\n * `system_init` remains authoritative. */\n sdkSessionId?: string\n }\n | {\n type: 'assistant_message'\n message: ApiMessage\n /** Set when the message was produced inside a subagent (Task tool). */\n parentToolUseId: string | null\n /** True when backfilled from a resumed session's history. */\n replay?: boolean\n uuid: string\n }\n | {\n type: 'user_message'\n message: ApiMessage\n parentToolUseId: string | null\n /** True when replayed from a resumed session's history. */\n replay?: boolean\n /** True for tool results and other synthetic user-role messages. */\n synthetic?: boolean\n /** Files sent with this message, by reference (see {@link MessageAttachment}).\n * `message.content` carries the typed text only — the attachment bytes went\n * to the model, not into this log. */\n attachments?: MessageAttachment[]\n /**\n * What a file-editing tool changed, when this message carries that tool's\n * result (see {@link FilePatch}). Set by the runner from the engine's own\n * structured output — never derived by a client from the result text.\n *\n * Only when the message carries exactly one `tool_result` block, which is\n * what both engines send: with two, there is nothing that says which one\n * the patch belongs to, and guessing would attach a diff to the wrong call.\n */\n patch?: FilePatch\n uuid?: string\n }\n /** Raw Anthropic streaming event (message_start/content_block_delta/...); emitted only\n * when the session was created with `includePartialMessages`. */\n | {\n type: 'stream_delta'\n event: { type: string; [key: string]: unknown }\n parentToolUseId: string | null\n uuid: string\n }\n | {\n type: 'turn_result'\n subtype:\n | 'success'\n | 'error_during_execution'\n | 'error_max_turns'\n | 'error_max_budget_usd'\n | 'error_max_structured_output_retries'\n isError: boolean\n durationMs: number\n numTurns: number\n totalCostUsd: number\n /** Final text of the turn (success only). */\n result?: string\n errors?: string[]\n usage?: unknown\n }\n | { type: 'permission_requested'; request: PermissionRequest }\n | {\n type: 'permission_resolved'\n requestId: string\n behavior: 'allow' | 'deny'\n resolvedBy: PermissionDecisionSource\n /** Denial message, when denied. */\n message?: string\n }\n /** A tool execution was dispatched to a backend. For bridged executions this\n * precedes the `tool_call_request` frame; for deferred ones it is the record\n * that survives a teardown. */\n | {\n type: 'execution_dispatched'\n executionId: string\n toolName: string\n backend: ToolExecutionBackend\n /** True when the execution may outlive this turn or process. */\n deferred?: boolean\n /** Epoch ms after which the server applies its timeout policy. */\n expiresAt?: number\n }\n /** A dispatched execution produced a result. Applied idempotently by `executionId`. */\n | {\n type: 'execution_result'\n executionId: string\n output: ToolExecutionOutput\n /** Guest/agent-visible logs, if the backend captured any. */\n logs?: string[]\n durationMs?: number\n }\n /** A dispatched execution failed, timed out, or was orphaned. The failure is fed\n * back into the loop as tool output so the agent can adapt — it is not a session error. */\n | {\n type: 'execution_failed'\n executionId: string\n /** Machine-readable cause: 'timeout' | 'oom' | 'exception' | 'orphaned' | backend-specific. */\n reason: string\n error: string\n logs?: string[]\n durationMs?: number\n }\n /** The agent handed over a file from its session scratch filesystem (the\n * `deliver_file` tool). Download it via `GET {basePath}/sessions/:id/files/<path>`\n * for as long as the session lives (the VFS is in-memory). */\n | { type: 'file_delivered'; path: string; bytes: number; description?: string }\n /** Any SDKMessage this protocol version doesn't model first-class (task progress,\n * compaction boundaries, auth status, ...). Payload is the raw SDK message. */\n | { type: 'sdk_event'; payload: { type: string; [key: string]: unknown } }\n | { type: 'session_error'; message: string }\n | { type: 'session_closed'; reason: 'client' | 'server' | 'error' }\n\nexport type SessionEvent = SessionEventBody & {\n /** Monotonic per-session sequence number, starting at 1. */\n seq: number\n /** Epoch ms when the server emitted the event. */\n ts: number\n}\n\n// ---------------------------------------------------------------------------\n// Commands (client -> server)\n// ---------------------------------------------------------------------------\n\nexport type SessionCommand =\n | {\n type: 'user_message'\n text: string\n /** Ids from `POST {basePath}/sessions/:id/attachments`, in the order they\n * should reach the model. Unknown ids fail the command rather than sending\n * a message that quietly lost its picture. */\n attachmentIds?: string[]\n }\n | {\n type: 'permission_decision'\n requestId: string\n behavior: 'allow' | 'deny'\n /** allow only: modified tool input to run instead of the original. */\n updatedInput?: Record<string, unknown>\n /** deny only: reason surfaced to the model. */\n message?: string\n /** deny only: also interrupt the running turn. */\n interrupt?: boolean\n }\n | { type: 'interrupt' }\n | { type: 'set_permission_mode'; mode: PermissionMode }\n /** Switch the model for subsequent responses; omit `model` for the default. */\n | { type: 'set_model'; model?: string }\n /**\n * Result of a tool execution the server bridged to this client (see\n * {@link ToolCallRequestFrame}). Unknown or already-settled `executionId`s are\n * ignored — delivery is idempotent, and a late result after a timeout must not\n * re-open a settled call.\n *\n * Browser-returned results are UNTRUSTED input: acceptable for the user's own\n * data, never a source for server-authoritative state.\n */\n | {\n type: 'tool_call_result'\n executionId: string\n output: ToolExecutionOutput\n logs?: string[]\n }\n /** The client could not execute a bridged call (unsupported tool, guest error,\n * tab closing). Fed back to the agent as tool output. */\n | {\n type: 'tool_call_error'\n executionId: string\n reason: string\n error: string\n logs?: string[]\n }\n | { type: 'close' }\n\n// ---------------------------------------------------------------------------\n// WebSocket frames\n// ---------------------------------------------------------------------------\n\n/** First frame the server sends after a successful attach. */\nexport type AttachedFrame = {\n type: 'attached'\n protocolVersion: number\n session: SessionInfo\n /** Events with seq > the client's `afterSeq` follow as `event` frames. */\n replayingFrom: number\n}\n\n/**\n * Ask the attached client to execute a tool call in its own sandbox (browser\n * bridge). The client answers with `tool_call_result` or `tool_call_error`\n * carrying the same `executionId`.\n *\n * Only sandbox-benefiting tools are ever bridged. Authenticated/authoritative\n * tools (MCP, secret-bearing APIs) execute server-side and never appear here.\n */\nexport type ToolCallRequestFrame = {\n type: 'tool_call_request'\n executionId: string\n toolName: string\n input: unknown\n /** Files to seed the client's scratch VFS with, path → contents. */\n vfsSeed?: Record<string, string>\n limits?: { timeoutMs?: number; memoryLimitBytes?: number }\n /** Epoch ms after which the server gives up and fails the execution. */\n expiresAt?: number\n}\n\nexport type ServerFrame =\n | AttachedFrame\n | { type: 'event'; event: SessionEvent }\n | ToolCallRequestFrame\n /** A bridged execution no longer needs an answer (turn interrupted, timed out,\n * or the session closed) — the client should abandon it. */\n | { type: 'tool_call_canceled'; executionId: string; reason: string }\n | { type: 'protocol_error'; message: string }\n\nexport type ClientFrame = SessionCommand\n\n// ---------------------------------------------------------------------------\n// Profiles (named Claude Code config directories)\n// ---------------------------------------------------------------------------\n\n/** Per-profile fallbacks filled into session/job requests that leave the field\n * unset. Defaults, not enforced caps — an explicit request value always wins. */\nexport type ProfileDefaults = {\n model?: string\n permissionMode?: PermissionMode\n}\n\n/**\n * A named Claude Code config directory sessions can run under: the session's CLI\n * process gets it as CLAUDE_CONFIG_DIR, so the profile carries that directory's\n * settings, memory, skills, and whatever credentials the SDK/CLI resolves from it.\n * Profiles are declared in server options at startup (or a 'default' one is\n * auto-created from the operator's own config dir) — the API only reads them.\n */\n/**\n * Which engine a profile runs on. A **closed union, deliberately**: both clients\n * switch exhaustively, the Swift mirror ships in lockstep, and a closed set is\n * what lets this package carry per-engine capability defaults\n * ({@link ENGINE_CAPABILITIES}) browser-safe, with no server round-trip. Adding a\n * member is a versioned protocol event.\n *\n * - `claude` (default) — Claude Code via the Agent SDK, configured by a config dir.\n * - `codex` — OpenAI Codex over the codex CLI binary's `app-server` JSON-RPC\n * surface, configured by a CODEX_HOME (auth resolved by the binary itself,\n * like claude).\n * - `provider` — a model-agnostic provider over the AI SDK, assembled by the\n * host's `createEngineRunner` hook.\n */\nexport type ProfileEngine = 'claude' | 'codex' | 'provider'\n\n/**\n * What an engine does and does not do — one axis per real difference, each field\n * answering a concrete UI or gateway question. Clients render from this record\n * instead of switching on the engine name: an absent capability means the\n * affordance is *hidden*, never a control that silently does nothing.\n *\n * Reaches clients in two places, same shape: `ProfileInfo.capabilities` (stamped\n * by the server; the create form's source) and `SessionInfo.capabilities`\n * (reported by the runner; the session surface's source). When the field is\n * absent — an older server — {@link ENGINE_CAPABILITIES} keyed by the engine\n * name is the browser-safe default.\n */\nexport type EngineCapabilities = {\n /** PermissionRequest / permission_resolved can occur; approval UI is live.\n * False: hide approval affordances entirely (and `questionBehavior` on jobs). */\n interactiveApprovals: boolean\n /** Modes this engine can honor. A stored choice outside the set is coerced to\n * {@link EngineCapabilities.defaultPermissionMode}, not submitted. */\n permissionModes: readonly PermissionMode[]\n /** Coercion target for a stored/unsupported mode choice (always ∈ permissionModes). */\n defaultPermissionMode: PermissionMode\n /** CreateSessionRequest.resume works (an engine session id continues). */\n resume: boolean\n /** Resume replays prior history into the transcript (Claude's backfill).\n * False + resume: show a \"history predates this attach\" notice instead of\n * treating an empty transcript as a bug. */\n resumeBackfill: boolean\n /** GET /sdk-sessions offers a resume picker for this engine. */\n listSessions: boolean\n /** context_usage events can occur. False: render nothing — never a 0% ring. */\n contextUsage: boolean\n /** rate_limit / plan_info events can occur. False: render nothing. */\n rateLimits: boolean\n /** GET /sessions/:id/mcp works (else 501) — the engine can *list* its MCP\n * servers. Gates the MCP panel's existence. */\n mcpStatus: boolean\n /**\n * POST /sessions/:id/mcp/:name works — the engine can reconnect, enable and\n * disable a server. Separate from {@link EngineCapabilities.mcpStatus}\n * because listing and acting are genuinely different powers: codex reports\n * rich status but exposes no per-server action on this transport, and a panel\n * that rendered the buttons anyway would present three controls that do\n * nothing and then report success. False: render the panel read-only.\n */\n mcpServerActions: boolean\n /** A session request may bring its own mcpServers. */\n sessionMcpServers: boolean\n /** capabilities events carry slash commands (composer popover). */\n slashCommands: boolean\n /** `skills` events can occur — the engine can enumerate its skills. False:\n * hide the skills panel entirely rather than showing an empty one. Orthogonal\n * to `slashCommands`: an engine can have skills and no commands (codex), or\n * commands and no skill listing (claude, whose skills reach clients only as\n * `system_init.skills` names). */\n skillsList: boolean\n /** settingSources / allowDangerouslySkipPermissions-style CLI options apply. */\n settingSources: boolean\n /** maxTurns / maxBudgetUsd are honored (else the gateway 400s them). */\n budgets: boolean\n /** Attachment kinds sendMessage can deliver to the model. Filter the attach\n * menu by kind; refuse locally before the server's 415. */\n attachments: ReadonlyArray<'image' | 'pdf' | 'text'>\n /** Efforts offerable at create time; absent = not settable (hide the control).\n * Open strings — Codex's own binary already outruns its SDK's union. */\n reasoningEfforts?: readonly string[]\n /** Sessions expose a scratch VFS (GET /sessions/:id/files, deliverables panel). */\n vfs: boolean\n /**\n * The engine runs against a host directory, so `CreateSessionRequest.cwd` is\n * required and meaningful (and a create form should ask for it). False: the\n * engine has no host filesystem — the gateway accepts a session with no\n * `cwd`, `SessionInfo.cwd` reports `''`, and there is no path to validate.\n *\n * Absent = true, so a wire copy from an older gateway keeps the old\n * always-required behaviour rather than silently relaxing it.\n */\n hostCwd?: boolean\n /** stream_delta granularity: per-token, coarse item updates (no typing\n * cursor), or none. */\n streaming: 'token' | 'item' | 'none'\n}\n\n/**\n * The static capability record of each engine — the browser-safe default for\n * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place\n * the values are written down. Core's adapters *reference* this record and a\n * conformance test compares runner behaviour against it, so it cannot silently\n * diverge from the code. When both a wire copy and this default exist, the wire\n * copy wins.\n */\nexport const ENGINE_CAPABILITIES: Record<ProfileEngine, EngineCapabilities> = {\n claude: {\n interactiveApprovals: true,\n permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk', 'auto'],\n defaultPermissionMode: 'default',\n resume: true,\n resumeBackfill: true,\n listSessions: true,\n contextUsage: true,\n rateLimits: true,\n mcpStatus: true,\n mcpServerActions: true,\n sessionMcpServers: true,\n slashCommands: true,\n // The CLI reports skill NAMES on `system_init` and nothing more — no\n // descriptions, no scope, no suggested prompt. That is not enough to fill a\n // picker honestly, and the SDK exposes no listing call, so the panel stays\n // off here rather than rendering a list of bare words.\n skillsList: false,\n settingSources: true,\n budgets: true,\n attachments: ['image', 'pdf', 'text'],\n // The engine-wide set (SDK Options.effort); per-model narrowing rides the\n // catalog rows, and the CLI silently downgrades an effort a model lacks.\n reasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],\n vfs: false,\n hostCwd: true,\n streaming: 'token',\n },\n codex: {\n // The app-server ask channels (server→client JSON-RPC requests: command\n // escalations, file changes, permission grants, tool questions, MCP\n // elicitations) are wired to the permission surface: they arrive as\n // `permission_requested` and are answered by `permission_decision`.\n // NOTE the semantic shift a client should not paper over: codex's command\n // approval is usually an ESCALATION after the sandbox already refused the\n // command (\"command failed; retry without sandbox?\"), not a gate before\n // execution — the runner authors `title`/`decisionReason` from codex's own\n // reason sentence, so render those rather than composing \"wants to use X\".\n interactiveApprovals: true,\n // 'default' = read-only sandbox + ask (a blocked action becomes a real\n // question instead of a silent refusal); acceptEdits = workspace-write +\n // ask (in-workspace writes sail through, escalations still ask); bypass =\n // full access, asking nothing. plan/dontAsk/auto name CLI workflows codex\n // cannot deliver.\n permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],\n defaultPermissionMode: 'default',\n resume: true,\n // A resume replays the thread's prior turns from `thread/resume`'s\n // `thread.turns` (topped up via `thread/read {includeTurns: true}` when the\n // resume page is partial) as `replay: true` events — same contract as the\n // Claude engine's backfill.\n resumeBackfill: true,\n // `GET /sdk-sessions?profile=<codex profile>` lists CODEX_HOME's threads\n // over a short-lived `thread/list` connection; no live session required.\n listSessions: true,\n // From `thread/tokenUsage/updated.last` against `modelContextWindow`, after\n // each turn. Its `categories` is always empty — codex publishes no\n // breakdown — so a client must not draw an empty breakdown section.\n contextUsage: true,\n // From `account/rateLimits/updated`, which app-server pushes during a turn\n // (no poll needed). Windows are positional there and named here by their\n // measured duration — see `docs/GOTCHAS.md` §Codex.\n rateLimits: true,\n // `mcpServerStatus/list` answers with each server, its `serverInfo`, its\n // auth status and — unlike the Agent SDK — the full JSON Schema of every\n // tool. Live status rides the `mcpServer/startupStatus/updated`\n // notification rather than the list response, so the runner tracks it.\n mcpStatus: true,\n // …but nothing on this transport reconnects or toggles ONE server. The\n // reload RPC is server-wide, and enable/disable would mean writing the\n // operator's config.toml — a different act from Claude's session-scoped\n // switch. So the panel is read-only here instead of offering buttons that\n // would lie.\n mcpServerActions: false,\n // MCP belongs to CODEX_HOME's config.toml; a session request cannot add servers.\n sessionMcpServers: false,\n // There is no command-listing RPC in the app-server surface at all: codex's\n // own `/model`, `/approvals` etc. are TUI-local and never reach this\n // transport. This is settled, not pending.\n slashCommands: false,\n // …but `skills/list` does exist, and `skills/changed` says when to re-read\n // it. What comes back is metadata rich enough to render (description,\n // scope, and codex's own `defaultPrompt`) — see {@link SkillInfo} for why\n // that is still not a command.\n skillsList: true,\n settingSources: false,\n budgets: false,\n // Images travel as localImage host paths, text is inlined into the prompt\n // envelope; pdf has no representation and 415s at upload.\n attachments: ['image', 'text'],\n // The engine-wide floor; per-model supersets (max, ultra) ride\n // ModelOption.reasoningEfforts from the catalog.\n reasoningEfforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],\n vfs: false,\n hostCwd: true,\n // item/agentMessage/delta and the reasoning deltas arrive token-by-token.\n streaming: 'token',\n },\n provider: {\n interactiveApprovals: false,\n permissionModes: ['default', 'bypassPermissions', 'dontAsk'],\n defaultPermissionMode: 'default',\n resume: false,\n resumeBackfill: false,\n listSessions: false,\n contextUsage: false,\n rateLimits: false,\n // The one engine whose MCP is entirely host-wired, and so the one that can\n // always answer: `AiSdkRunner` reports what the host assembled the session\n // from (an empty list when that was nothing). Acting on a server is a\n // different power and stays absent — the host owns those connections and\n // this engine has no channel to renegotiate one.\n mcpStatus: true,\n mcpServerActions: false,\n sessionMcpServers: false,\n slashCommands: false,\n skillsList: false,\n settingSources: false,\n budgets: false,\n attachments: ['image', 'pdf', 'text'],\n vfs: true,\n // No host filesystem at all: the tools run against the in-memory VFS, and\n // the runner never opens a path. A required `cwd` here would be a field\n // nothing reads, and `allowedCwdRoots` would look like the sandbox boundary\n // when the capability wiring is what actually bounds this engine.\n hostCwd: false,\n streaming: 'token',\n },\n}\n\n/**\n * Permission modes the model-agnostic provider engine understands.\n * @deprecated Read `ENGINE_CAPABILITIES.provider.permissionModes` (this is an\n * alias of it, kept for protocol-5 consumers).\n */\nexport const PROVIDER_PERMISSION_MODES: readonly PermissionMode[] =\n ENGINE_CAPABILITIES.provider.permissionModes\n\n/**\n * Whether a profile's engine can run a permission mode. The single source of\n * truth for the restriction: create forms filter what they offer with it, the\n * gateway rejects with it. An absent `engine` means 'claude' (every mode).\n */\nexport function supportsPermissionMode(\n engine: ProfileEngine | undefined,\n mode: PermissionMode,\n): boolean {\n return ENGINE_CAPABILITIES[engine ?? 'claude'].permissionModes.includes(mode)\n}\n\n/**\n * A model provider a `provider` profile can run on. Credentials are ALWAYS\n * resolved from the operator's environment — never carried on the wire, never\n * stored here. `apiKeyEnv` names the variable to read, it does not hold a key.\n */\nexport type ProviderConfig = {\n /** Provider adapter to use, e.g. 'anthropic' | 'openai' | 'moonshotai' |\n * 'openai-compatible'. Kept as a string: the set is host-extensible. */\n id: string\n /** Default model id, e.g. 'kimi-k3'. Overridable per session. */\n model?: string\n /** Model ids this profile offers, for the dashboard's picker. Operator-declared\n * rather than discovered: provider engines have no equivalent of the CLI's\n * `supportedModels()`, and only the operator knows which ids their endpoint and\n * key actually serve. Unset → the picker offers {@link ProviderConfig.model} alone. */\n models?: string[]\n /** Base URL for OpenAI-compatible providers. */\n baseUrl?: string\n /** Environment variable the operator put the key in. Never the key itself. */\n apiKeyEnv?: string\n}\n\n/**\n * A grantable capability of the model-agnostic engine, named after the tool it\n * yields. The always-present tools (`fs_*`, `eval_script`) are not listed: they\n * are the engine's scratch filesystem and sandbox, not a grant.\n */\nexport type SessionCapability = 'web_search' | 'download' | 'web_fetch' | 'deliver_file'\n\n/**\n * What sessions under a `provider` profile get, declared by the operator. Meaning-\n * less for `claude` profiles, whose equivalents live in the config directory.\n *\n * MCP servers are named, never configured, here: a server's transport config can\n * carry credentials in its headers, and this type is served by `GET /profiles`.\n * The names refer to servers the host connected in `createEngineRunner`, which is\n * where the configs (and the credentials) stay.\n */\nexport type ProfileSessionDefaults = {\n /** Capabilities granted to sessions under this profile. Absent = no\n * declaration, so a session gets whatever backends the host wired. A session\n * request may narrow this set, never widen it. */\n capabilities?: SessionCapability[]\n /** MCP servers, by name, whose tools sessions under this profile may use.\n * Absent = no declaration (every server the host connected). */\n mcpServers?: string[]\n /** Prepended to the session's system prompt. */\n instructions?: string\n}\n\n/**\n * One rate-limit window of a profile's plan, as the *gateway* last saw it — the\n * newest {@link RateLimitInfo} any session on the profile reported, across every\n * session, live or since closed. The profile is the account boundary (one config\n * dir / codex home / provider key = one plan), so this is the single usage state\n * per account, where a session's own transcript only knows what *it* was last\n * told.\n *\n * Two rules a client must keep:\n * - An absent window (or an absent {@link ProfileInfo.usage} entirely) is\n * **unknown, not 0%** — render nothing, exactly as for session-level readings.\n * The map is in-memory and starts empty on a cold server.\n * - `inferredReset` marks a reading the server zeroed at serve time because the\n * reading's own `resetsAt` passed with nothing newer: the pre-reset number is\n * then provably wrong, and 0 is the truthful *floor* (the account may have\n * been used outside this gateway since). Distinguishable on the wire from an\n * engine-reported 0, which carries no flag.\n */\nexport type ProfileUsageWindow = {\n /** The reading, exactly as the session event carried it — except after an\n * elapsed reset, when `utilization` is 0 and `resetsAt` is dropped (the old\n * one names the *previous* window; a countdown from it would be nonsense). */\n info: RateLimitInfo\n /** Epoch ms of the event that carried the reading — honest for \"Updated …\"\n * lines even when the served utilization is inferred. */\n updatedAt: number\n /** Present (true) only on the served-as-0 inference described above. */\n inferredReset?: boolean\n}\n\n/** Per-window plan usage, keyed by `rateLimitType` ('five_hour', 'seven_day',\n * ...) — the same keying as a transcript's rate-limit state. */\nexport type ProfileUsage = Record<string, ProfileUsageWindow>\n\nexport type ProfileInfo = {\n /** Unique name, used as {@link CreateSessionRequest.profile}. */\n name: string\n /** Engine this profile runs on. Defaults to 'claude' when absent, so profiles\n * written before provider support keep working unchanged. */\n engine?: ProfileEngine\n /** Absolute path set as CLAUDE_CONFIG_DIR for the session's CLI process.\n * Required for 'claude' profiles; meaningless for the other engines. */\n configDir?: string\n /** Codex profiles: absolute path set as CODEX_HOME for the session's codex\n * process (auth, config.toml, thread storage) — the `configDir` analogue,\n * request-writable like it. Unset = the binary's own `~/.codex`. */\n codexHome?: string\n /** Provider wiring for 'provider' profiles. */\n provider?: ProviderConfig\n description?: string\n defaults?: ProfileDefaults\n /** Provider-engine session grants (capabilities, MCP servers, instructions). */\n session?: ProfileSessionDefaults\n /** Response-only: the engine's model catalog, shipped with the release and\n * served from the first request (no process spawned, no warm-up session).\n * For provider profiles the ids come from `provider.models` instead. Never\n * contains a 'default' sentinel row — forms add their own \"Profile default\"\n * row mapping to an unset model. Ignored on the way in. */\n models?: ModelOption[]\n /** Response-only: what this profile's default model resolves to. For claude\n * profiles this is the operator's CLI config — unknowable statically — so it\n * is absent until a session on this profile reports it. */\n defaultModel?: string\n /** Response-only: the engine's capability record (see {@link EngineCapabilities}).\n * Absent = use ENGINE_CAPABILITIES[engine]. Ignored on the way in. */\n capabilities?: EngineCapabilities\n /** Response-only: whether the profile's credentials probe as usable right now.\n * Absent = unknown/unchecked — treat as available. **Display-only**: create\n * against an unavailable profile still proceeds and fails with the engine's\n * own error (the probe can be stale in both directions). */\n available?: boolean\n /** Response-only: one operator-actionable line, present only when\n * `available === false`. */\n unavailableReason?: string\n /** Response-only: the plan's rate-limit windows as last reported by any\n * session on this profile (see {@link ProfileUsageWindow}). Absent = unknown\n * — no session has reported yet (API-key sessions never do), or the server\n * restarted. **Display-only**, like `available`: never a gate. */\n usage?: ProfileUsage\n /** Response-only, computed by the server: this profile came from the profile\n * store and can be edited or deleted through the API. Profiles declared in\n * server options are absent/false — they are code. Ignored on the way in. */\n managed?: boolean\n}\n\n/**\n * Curated, read-only snapshot of what a profile's config directory contains —\n * the parts relevant to running worker sessions. Values that could carry secrets\n * (env var values) never leave the server; only names are listed.\n */\nexport type ProfileConfigSnapshot = {\n /** From the config dir's settings.json; absent when missing or unparseable. */\n settings?: {\n /** Configured default model. */\n model?: string\n /** permissions.defaultMode — the CLI's default permission mode. */\n defaultPermissionMode?: string\n /** Rule counts from permissions.allow / ask / deny. */\n permissionRules?: { allow: number; ask: number; deny: number }\n /** Env var NAMES declared in settings.json env (values never included). */\n envKeys?: string[]\n /** Hook event names with at least one hook configured. */\n hooks?: string[]\n }\n /** CLAUDE.md (user memory) present in the config dir. */\n hasUserMemory: boolean\n /** Skill names (skills/<name>/). */\n skills: string[]\n /** Agent names (agents/<name>.md). */\n agents: string[]\n /** Custom slash-command names (commands/<name>.md). */\n commands: string[]\n}\n\n// ---------------------------------------------------------------------------\n// REST shapes\n// ---------------------------------------------------------------------------\n\nexport type McpServerConfigWire =\n | { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }\n | { type: 'http'; url: string; headers?: Record<string, string> }\n | { type: 'sse'; url: string; headers?: Record<string, string> }\n\n/** One tool an MCP server exposes, as the session's engine reports it.\n * Parameters are deliberately absent: the CLI's status payload names and\n * describes each tool but does not carry its input schema. */\nexport type McpServerToolInfo = {\n name: string\n description?: string\n annotations?: { readOnly?: boolean; destructive?: boolean; openWorld?: boolean }\n /**\n * The tool's JSON Schema, where the engine reports one. **Engine-dependent,\n * and that is not an oversight**: the Agent SDK's `McpServerStatus` names and\n * describes each tool but carries no schema at all, while codex's\n * `mcpServerStatus/list` returns the full one. So a client renders parameters\n * where they exist and says they are unavailable where they don't — rather\n * than either leaving a silent gap or claiming the absence is universal.\n *\n * Opaque on purpose: this is a JSON Schema document, not a shape this\n * protocol models.\n */\n inputSchema?: unknown\n}\n\n/**\n * Live status of one MCP server on a session — what `GET\n * {basePath}/sessions/:id/mcp` answers with, and what the `/mcp` screens render.\n *\n * The connection *identity* is here (transport, command, url, scope) but never\n * its secrets: the engine's config carries `env` for stdio servers and `headers`\n * for HTTP/SSE ones, and both are dropped on the way out. A client that can read\n * this is not thereby entitled to the tokens the operator configured.\n */\nexport type McpServerStatusInfo = {\n name: string\n /** 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled' — kept open,\n * the engine's set may grow. */\n status: string\n /** Where the server was configured: 'project' | 'user' | 'local' | 'dynamic' | … */\n scope?: string\n /** Present when `status` is 'failed'. */\n error?: string\n /** Name and version the server announced on connect. */\n serverInfo?: { name: string; version: string }\n transport?: 'stdio' | 'http' | 'sse' | 'sdk'\n /** stdio only. */\n command?: string\n /** stdio only. Secrets do occasionally ride argv; the operator's own client\n * shows them, and hiding them here would only mislead. `env` is not exposed. */\n args?: string[]\n /** http/sse only. */\n url?: string\n /** Present when connected. */\n tools?: McpServerToolInfo[]\n}\n\nexport type McpServersResponse = { servers: McpServerStatusInfo[] }\n\n/** `POST {basePath}/sessions/:id/mcp/:name` — answers with the refreshed status. */\nexport type McpServerActionRequest = { action: 'reconnect' | 'enable' | 'disable' }\n\n/** `POST {basePath}/sessions/:id/attachments?name=<name>` — the body is the raw\n * file, the `content-type` header its media type. Answers with the reference to\n * name on the next `user_message`. */\nexport type UploadAttachmentResponse = { attachment: MessageAttachment }\n\nexport type CreateSessionRequest = {\n /** Directory the session is rooted at. Required for any engine whose\n * capability record declares {@link EngineCapabilities.hostCwd} — `cwd` is\n * per-query in the SDK and the server re-pins it on every call. Omittable for\n * an engine that has no host filesystem at all (the provider engine, whose\n * tools run against the in-memory VFS): there the field would be a required\n * lie, and `allowedCwdRoots` would look like a sandbox boundary it is not. */\n cwd?: string\n /** Profile (named Claude Code config dir) to run under. Required when the server\n * declares more than one profile; implicit when exactly one exists. */\n profile?: string\n /** Optional initial prompt (may be a skill invocation like \"/verify-content 123\"). */\n prompt?: string\n permissionMode?: PermissionMode\n /** Pre-authorize 'bypassPermissions' (the CLI's --dangerously-skip-permissions\n * capability) so the mode can be switched on mid-session. Without it the CLI\n * rejects `set_permission_mode: 'bypassPermissions'` on a running session.\n * Implied when `permissionMode` is already 'bypassPermissions'. */\n allowDangerouslySkipPermissions?: boolean\n allowedTools?: string[]\n disallowedTools?: string[]\n mcpServers?: Record<string, McpServerConfigWire>\n /** Which filesystem settings the session loads. Include 'project' to pick up the\n * target repo's skills and CLAUDE.md (\"close-to-real\" fidelity). */\n settingSources?: Array<'user' | 'project' | 'local'>\n model?: string\n maxTurns?: number\n maxBudgetUsd?: number\n /** Resume an existing SDK session by id. */\n resume?: string\n /** With `resume`: fork to a new session id instead of continuing. */\n forkSession?: boolean\n /** Reasoning effort for the session's model (codex engine). Open string —\n * offerable values come from the profile's catalog/capability record. The\n * gateway 400s it when the engine's record declares no `reasoningEfforts`. */\n reasoningEffort?: string\n /** Emit `stream_delta` events for token-by-token rendering. Default true. */\n includePartialMessages?: boolean\n /** Per-session override of the server's permission-request timeout (ms). */\n approvalTimeoutMs?: number\n /** AskUserQuestion handling (see {@link QuestionBehavior}). Default 'ask'. */\n questionBehavior?: QuestionBehavior\n /** Provider engine only: run with fewer capabilities than the profile grants\n * (see {@link ProfileSessionDefaults.capabilities}). Narrowing only — naming a\n * capability the profile does not grant is a 400, not a silent upgrade. */\n capabilities?: SessionCapability[]\n /** Free-form metadata echoed back on SessionInfo (host app bookkeeping). */\n meta?: Record<string, unknown>\n /**\n * Opaque string tags naming what this session *belongs to* — the gateway's\n * only intra-deployment scoping primitive. Assigned at create, **immutable\n * afterwards** (no route writes it), echoed on {@link SessionInfo}, and\n * carried through parking/dormancy so a restart cannot un-scope a session.\n *\n * WorkerDeck never interprets a key: an embedder writes `{ space, user }` or\n * `{ tenant }` or nothing at all. What the tags *mean* is the host's\n * `authorizeSession` predicate; absent one, the default rule is that every\n * key the authenticated principal pins must match here (an unset principal\n * scope is unrestricted — the same \"unset means all\" rule `allowedProfiles`\n * uses, so an operator's dashboard keeps working unchanged).\n *\n * NOT `meta`: `meta` is free-form, client-settable and echoed, and an\n * enforcement rule whose input the caller supplies is not an enforcement\n * rule. Values are visible to any principal the policy admits a session to,\n * so use opaque ids rather than names you would not show that audience.\n */\n scope?: Record<string, string>\n}\n\nexport type SessionInfo = {\n /** Server-assigned id (stable across SDK session forks/resumes). */\n id: string\n /** Underlying Agent SDK session id, once known; use for `resume`. */\n sdkSessionId?: string\n status: SessionStatus\n /** Empty string for a session whose engine has no host filesystem (see\n * {@link CreateSessionRequest.cwd}). Deliberately not optional: every client\n * renders and searches it, and a synthetic path would send the workspace and\n * `@file` search probing a directory that does not exist. */\n cwd: string\n /** Profile the session runs under (resolved name, present even when implicit). */\n profile?: string\n /** Engine actually running this session, reported by the runner itself. Lets a\n * session surface gate CLI-only affordances (permission modes, context usage,\n * rate limits) without looking the profile back up. Absent = 'claude'. */\n engine?: ProfileEngine\n /** The engine's capability record, reported by the runner like `engine`. The\n * attach snapshot is the session-level source (no event carries it). Absent =\n * ENGINE_CAPABILITIES[engine]. */\n capabilities?: EngineCapabilities\n model?: string\n permissionMode?: PermissionMode\n /** Whether this session may be switched into `bypassPermissions`. The CLI only\n * allows it when the process was spawned for it, so it is decided at creation\n * and never changes: a session that did not ask for bypass up front cannot\n * gain it later. Lets a picker disable the mode instead of offering a switch\n * the engine will refuse. Absent = unknown (an older server). */\n canBypassPermissions?: boolean\n /** See the `system_init` event; 'oauth' = claude.ai subscription credentials. */\n apiKeySource?: string\n createdAt: number\n /** Highest event seq emitted so far; attach with `afterSeq` to catch up. */\n lastSeq: number\n pendingPermissionCount: number\n meta?: Record<string, unknown>\n /** Display title: `meta.title` if the host set one, else derived (e.g. first prompt). */\n title?: string\n /** Cumulative cost across all turns so far (sum of turn_result totals). */\n totalCostUsd?: number\n /** Cumulative turn count across the session. */\n numTurns?: number\n /**\n * How many transcript rows this session has produced (see\n * {@link transcriptActivity}) — a monotonic counter a client can diff against\n * a remembered value to answer \"how much happened while I wasn't looking\",\n * without attaching.\n *\n * `numTurns` cannot answer it: five tool calls inside one turn are one turn.\n * `lastSeq` cannot either — it counts every event, and with token streaming on\n * that is hundreds per reply. Absent on an older server; a client should fall\n * back to `numTurns` rather than showing nothing.\n *\n * Monotonic for the session's whole life, **including across a\n * `conversation_reset`**: after a `/clear` this deliberately exceeds the\n * number of rows a fresh attach renders. It is an unread *cursor* diffed\n * against stored monotonic watermarks (see `watermarks.ts`) — resetting it to\n * the new row count would leave every stored mark above it, and that\n * session's badge dead until the count caught back up.\n */\n activityCount?: number\n /** Epoch ms of the most recent emitted event. */\n lastActivityAt?: number\n /** Opaque scope tags this session was created with — see\n * {@link CreateSessionRequest.scope}. Echoed by the runner, re-stamped by the\n * gateway, and never editable. */\n scope?: Record<string, string>\n}\n\n/**\n * How many transcript rows an event materializes — the unit behind\n * {@link SessionInfo.activityCount}.\n *\n * Deliberately the *reducer's* rule (`@workerdeck/react`'s `transcript.ts`), not\n * a server-side approximation: one row per content block of an assistant\n * message (a text, a thought, each tool call), one for a user message, one per\n * turn result, delivered file or error. Everything else — status changes, usage\n * readings, stream deltas, permission bookkeeping — is state, not a row, and\n * counts zero.\n *\n * It lives in `protocol` because both sides need it and neither may import the\n * other: the runners count with it, and any client compares the totals. If the\n * reducer's row rule changes, change this with it.\n */\nexport function transcriptActivity(body: SessionEventBody): number {\n switch (body.type) {\n case 'assistant_message': {\n const content = body.message.content\n // A string body is one text row. Blocks are one row each, except tool\n // results (which land inside the call's own row) and unknown blocks.\n if (typeof content === 'string') return content.trim() === '' ? 0 : 1\n const rows = content.filter(\n (block) => block.type === 'text' || block.type === 'thinking' || block.type === 'tool_use',\n ).length\n return rows\n }\n case 'user_message':\n // Tool results arrive as synthetic user messages; they are not rows.\n return body.synthetic ? 0 : 1\n case 'turn_result':\n case 'file_delivered':\n case 'session_error':\n return 1\n default:\n return 0\n }\n}\n\n/**\n * Whether an event is **transcript content** — whether the reducer\n * (`@workerdeck/react`'s `transcript.ts`, and its Swift mirror) mutates\n * `items` when it applies it. The rule behind `conversation_reset`'s replay\n * semantics: the runner keeps its whole event log, but `subscribe()` skips\n * content below the latest reset so an attaching client does not resurrect a\n * cleared conversation — while every *state-bearing* event (`system_init`,\n * `capabilities`, `skills`, `status_changed`, usage and rate-limit readings,\n * `file_produced`, permission bookkeeping) still replays, because a fresh\n * attacher with no model list and no cwd is broken, not cleared.\n *\n * Deliberately **broader than `transcriptActivity() > 0`**: stream deltas,\n * tool results (synthetic user messages) and execution lifecycle events count\n * zero rows but still mutate items — replaying them across a reset would leave\n * orphaned deltas and results with no parent message.\n *\n * `conversation_reset` itself is content under this rule, and that is load-\n * bearing twice: a *superseded* reset (below a newer one) is skipped with the\n * conversation it cleared, while the latest reset always replays (the skip is\n * strictly-below), which is what clears a reconnecting client that still holds\n * pre-reset rows.\n *\n * Lives here beside {@link transcriptActivity} for the same reason: the\n * reducer owns the rule and the runners filter with it, and the two sides may\n * not import each other. If the reducer's items-mutating set changes, change\n * this with it. Unknown/future event types are NOT content — the safe failure\n * is replaying a stale row, never withholding state.\n */\nexport function transcriptContent(body: SessionEventBody): boolean {\n switch (body.type) {\n case 'user_message':\n case 'assistant_message':\n case 'stream_delta':\n case 'turn_result':\n case 'execution_dispatched':\n case 'execution_result':\n case 'execution_failed':\n case 'file_delivered':\n case 'session_error':\n case 'session_closed':\n case 'conversation_reset':\n return true\n default:\n return false\n }\n}\n\n/**\n * The dedupe key for an event that is **last-write-wins** on replay, or\n * `undefined` for one that must always be delivered.\n *\n * The problem: the runner polls context usage and the plan's rate limits after\n * every turn, so a fifty-turn session's log holds fifty context readings and\n * fifty per rate-limit window. Replaying all of them is not merely wasteful —\n * it is *visible*. A client applies each in turn, so opening a session shows\n * the usage meters counting up from the session's first reading to its last\n * over the length of the replay, announcing history as if it were news.\n *\n * The fix is a backwards scan over the buffered log keeping the first\n * occurrence of each key, which is `staleReplaySeqs` in `@workerdeck/core`.\n * The key is per *window* for rate limits, not per event type: the reducer\n * stores them keyed by window (\"so five_hour and seven_day updates don't\n * clobber each other\"), so a single key would keep only the most recently\n * polled window and silently drop the others.\n *\n * **This is a claim about the reducer**, which is why it lives here rather\n * than in core: only the server coalesces, but only `@workerdeck/react` can\n * prove the rule correct, and neither package may import the other. The\n * property that must hold is that coalescing is *unobservable* — folding the\n * full log and the coalesced log through `applyEvent` yields identical state.\n * `packages/react/test/replay-coalesce.test.ts` asserts exactly that, over\n * every event kind. Extend the rule only with a case that test still passes.\n *\n * Three kinds are deliberately **excluded** despite looking eligible:\n *\n * - `capabilities` — `defaultModel: event.defaultModel ?? base.defaultModel`\n * is a fallback *merge*, so a later event without one would erase an earlier\n * event's. (It is also emitted once per session, so there is nothing to win.)\n * - `model_changed` — `undefined` means \"reset to the server default\" and the\n * reducer *keeps* the last known model, so the last event alone is not the\n * same as the fold.\n * - `system_init` — pure replace for the reducer, but the server's\n * `watchAuthSource` reads the **first** one to decide an auth policy, and\n * parking treats each as a resume point.\n *\n * Coalescing never drops the highest-seq event, and that is load-bearing\n * rather than incidental: the globally-last event is by definition the last of\n * its own key, so it always survives. `useClaudeSession`'s replay hold waits\n * for `state.lastSeq` to reach the attach's `session.lastSeq`, and would hang\n * on a blank panel forever if a coalescer could swallow the final event.\n */\nexport function replayCoalesceKey(body: SessionEventBody): string | undefined {\n switch (body.type) {\n case 'context_usage':\n return 'context_usage'\n case 'rate_limit':\n // Per window. The reducer keys `rateLimits` by `rateLimitType`; an event\n // without one is dropped by the reducer, so it has no key here either.\n return body.info.rateLimitType ? `rate_limit:${body.info.rateLimitType}` : undefined\n case 'status_changed':\n // Pure replace in the reducer. Safe only because coalescing is opt-in at\n // the WS attach: `parking.ts` subscribes from seq 0 and *branches* on\n // this event (a `parked` status triggers a park), so a coalesced log\n // handed to every subscriber would silently skip that side effect.\n return 'status_changed'\n default:\n return undefined\n }\n}\n\n/**\n * A session in an engine's on-disk store (independent of this server's registry):\n * the Agent SDK's session files, or a codex profile's CODEX_HOME threads. Listed\n * so hosts can offer \"resume\" across server restarts: feed `sessionId` to\n * CreateSessionRequest.resume — under a profile of the SAME engine, since the id\n * only means something to the store it came from. `GET {basePath}/sdk-sessions`\n * takes an optional `profile` query parameter naming whose store to list; absent,\n * the profile is resolved implicitly when the server declares exactly one, else\n * the Claude engine's store is listed (the pre-engine-aware behavior). Mirrors\n * the SDK's SDKSessionInfo shape, kept browser-safe.\n */\nexport type SdkSessionSummary = {\n sessionId: string\n /** Custom title, auto summary, or first prompt — whichever the SDK has. */\n summary: string\n /** Epoch ms of last modification. */\n lastModified: number\n createdAt?: number\n customTitle?: string\n firstPrompt?: string\n gitBranch?: string\n cwd?: string\n}\n\n/** One deliverable in the session's scratch filesystem (see the `file_delivered` event). */\nexport type SessionFileInfo = { path: string; bytes: number }\n/** `GET {basePath}/sessions/:id/files` — every file currently in the session's VFS.\n * `GET {basePath}/sessions/:id/files/<path>` downloads one (attachment disposition).\n * 404 when the session's engine exposes no VFS (Claude-engine sessions). */\nexport type ListSessionFilesResponse = { files: SessionFileInfo[] }\nexport type ListSessionsResponse = { sessions: SessionInfo[] }\nexport type CreateSessionResponse = { session: SessionInfo }\nexport type GetSessionResponse = { session: SessionInfo }\n\n/**\n * Body of `PATCH {basePath}/sessions/:id` — the host-facing edits to a live\n * session. Today that is only its display name: `title` writes `meta.title`,\n * which {@link SessionInfo.title} prefers over the derived one, and `null` (or\n * an empty string) clears the override so the derived title comes back. Nothing\n * here reaches the engine — renaming does not speak to the model.\n *\n * 409 when the session is parked: a parked session has no runner to carry the\n * change, and its snapshot is the host's to rewrite, not this route's.\n */\nexport type UpdateSessionRequest = { title?: string | null }\nexport type UpdateSessionResponse = { session: SessionInfo }\n\n/** Body of `POST {basePath}/sessions/:id/permissions/:requestId` — the REST counterpart\n * of the WS `permission_decision` command, for remote controllers without a socket\n * (e.g. answering a job's AskUserQuestion from a webhook consumer). 404 = the request\n * is unknown, already resolved, or expired. */\nexport type ResolvePermissionRequest =\n | { behavior: 'allow'; updatedInput?: Record<string, unknown> }\n | { behavior: 'deny'; message?: string; interrupt?: boolean }\nexport type ResolvePermissionResponse = { resolved: true }\n\n/**\n * Body of `POST {basePath}/executions/:executionId/result` — the way a deferred\n * executor (a remote worker, a batch job, a human) delivers the outcome of an\n * execution the session parked on. The session is rehydrated if its runner was\n * torn down, and the result is folded back into the agent loop; a `failed` result\n * is ordinary tool output the agent adapts to, not a session error.\n *\n * Applied **idempotently by `executionId`**: a duplicate or late delivery (one\n * racing the execution watchdog) answers 200 with `applied: false` rather than\n * erroring or applying twice. 404 means no session is parked on that id.\n */\nexport type SubmitExecutionResultRequest =\n | { status: 'ok'; output: ToolExecutionOutput; logs?: string[] }\n | { status: 'failed'; reason: string; error: string; logs?: string[] }\nexport type SubmitExecutionResultResponse = {\n /** False when the id was already settled — the delivery was a no-op. */\n applied: boolean\n /** Session the execution belonged to. */\n sessionId: string\n}\n\nexport type ListSdkSessionsResponse = { sdkSessions: SdkSessionSummary[] }\n/** `GET {basePath}/profiles` — filtered to the profiles the caller may use. */\nexport type ListProfilesResponse = {\n profiles: ProfileInfo[]\n /** Whether this caller may create profiles here — true only when the server has\n * a profile store AND the principal carries `canManageProfiles`. Lets a UI hide\n * controls that would always be refused. */\n canManage?: boolean\n}\n\n/**\n * `POST {basePath}/profiles` — create a managed profile. Available only when the\n * server was given a profile store, and only to a principal with\n * `canManageProfiles`. Profiles declared in server options are code, not data:\n * they cannot be created, edited, or deleted through these routes.\n */\nexport type CreateProfileRequest = ProfileInfo\n\n/** `PATCH {basePath}/profiles/:name` — merge into a managed profile. The name is\n * the route, not the body; pass `null` to clear an optional field. */\nexport type UpdateProfileRequest = Omit<Partial<ProfileInfo>, 'name'>\n\n// ---------------------------------------------------------------------------\n// Host filesystem (`{basePath}/fs/*`)\n// ---------------------------------------------------------------------------\n\n/**\n * The **host's real project tree**, not a session's in-memory VFS — the two are\n * unrelated despite both being \"files\". {@link SessionFileInfo} is a deliverable\n * the agent produced inside a session; these routes read and write the operator's\n * actual disk.\n *\n * That makes them **operator-privileged**: they are authorized by the server's auth\n * key alone and deliberately sit outside the agent permission flow, because the\n * caller *is* the operator, not the model. A client holding the key can already\n * start a session with any allowed cwd; browsing that same tree grants it nothing\n * new. Writing does, which is why writes are separately enabled server-side.\n *\n * The whole surface is opt-in and root-scoped: with no roots configured every route\n * below 404s. There is no \"unset means anything\" default here — a phone on a tailnet\n * must never be one request away from `~/.ssh`.\n */\nexport type HostFileRoot = {\n /** Absolute, canonical (symlinks resolved) path of the root. */\n path: string\n /** Last path segment, for display — roots are not named by the operator. */\n name: string\n}\n\n/** `GET {basePath}/fs/roots` — where a client may start browsing. Empty `roots`\n * never happens: the routes are absent entirely when none are configured. */\nexport type ListHostRootsResponse = {\n roots: HostFileRoot[]\n /** Whether `PUT /fs/write` is enabled here; lets a UI hide an editor it can't save from. */\n canWrite: boolean\n}\n\n/** One entry in a host directory listing. Classified with `lstat` semantics, so a\n * `symlink` is reported as itself and never silently resolved — following it is the\n * *next* request's problem, and that request is refused if it escapes the roots. */\nexport type HostDirEntry = {\n name: string\n /** Absolute path, ready to pass back as `?path=`. */\n path: string\n type: 'file' | 'dir' | 'symlink' | 'other'\n /** Regular files only. */\n bytes?: number\n /** Epoch ms mtime. */\n modifiedAt?: number\n}\n\n/** `GET {basePath}/fs/list?path=<abs>` — one directory, not recursive. */\nexport type ListHostDirResponse = {\n /** Canonical path actually listed (the request's path after symlink resolution). */\n path: string\n /** Directories first, then files, each alphabetical. */\n entries: HostDirEntry[]\n /** Set when the directory held more entries than the server will return. */\n truncated?: boolean\n}\n\n/** One hit from `GET {basePath}/fs/find`. */\nexport type HostFileMatch = {\n /** Absolute path, for a follow-up read. */\n path: string\n /** Path relative to the searched directory — what a picker shows and inserts. */\n relative: string\n}\n\n/**\n * `GET {basePath}/fs/find?path=<dir>&q=<query>&limit=<n>` — recursive fuzzy file\n * search under one directory, which is what an `@file` picker needs and\n * `/fs/list` is not: listing answers \"what is in this directory\", this answers\n * \"which file in this tree did you mean\".\n *\n * Subsequence matching (`seslist` finds `SessionListView.swift`), filename hits\n * ranked above path hits, shallow files above deep ones. An empty `q` returns the\n * shallowest files. Build directories (`.git`, `node_modules`, …) are skipped, as\n * is anything behind a symlink — so every path returned is one `/fs/read` will\n * accept.\n */\nexport type FindHostFilesResponse = {\n /** Canonical directory the search ran under; `relative` paths are relative to it. */\n base: string\n matches: HostFileMatch[]\n /** More matched, or the tree was larger than the server would walk. */\n truncated: boolean\n}\n\n/** `GET {basePath}/fs/read?path=<abs>` — one file's contents. Binary files come back\n * base64; 413 rather than a truncated read when the file exceeds the server's cap. */\nexport type ReadHostFileResponse = {\n path: string\n content: string\n encoding: 'utf8' | 'base64'\n bytes: number\n /** sha256 (hex) of the bytes on disk. Pass it back as `expectedHash` to write. */\n hash: string\n modifiedAt: number\n}\n\n/**\n * `PUT {basePath}/fs/write` — replace or create one file.\n *\n * The agent is editing this same tree, so a write is **conditional, always**:\n * `expectedHash` must be the hash from the read this edit is based on, and the\n * server 409s if the file has changed since. Omitting it means \"create\" and 409s\n * if the path already exists — there is no unconditional overwrite, by design.\n * Directories are never created implicitly: writing under a missing parent is a 404.\n */\nexport type WriteHostFileRequest = {\n path: string\n content: string\n /** Default 'utf8'. */\n encoding?: 'utf8' | 'base64'\n /** Required to overwrite; omit only to create a new file. */\n expectedHash?: string\n}\n\nexport type WriteHostFileResponse = {\n path: string\n bytes: number\n /** Hash of what was just written — carry it into the next edit. */\n hash: string\n modifiedAt: number\n}\n\nexport type SaveProfileResponse = { profile: ProfileInfo }\n/** `GET {basePath}/profiles/:name` — the profile plus a fresh config snapshot. */\nexport type GetProfileResponse = { profile: ProfileInfo; config: ProfileConfigSnapshot }\nexport type ErrorResponse = { error: string }\n\n// ---------------------------------------------------------------------------\n// Session notifications (the out-of-band \"something wants you\" channel)\n// ---------------------------------------------------------------------------\n\n/**\n * The moments in an *interactive* session a person needs to hear about when they\n * are not watching it — the whole point being that a phone cannot hold a\n * WebSocket open in the background, so the server has to reach out.\n *\n * Deliberately four: this is a human-attention channel, not an event mirror. The\n * event log stays on the session WS (attach with `afterSeq` to catch up); if you\n * want every assistant message, subscribe there instead.\n */\nexport type SessionNotificationType =\n /** The agent is blocked on an approval — the one that matters most. */\n | 'permission_requested'\n /** A turn finished; the session is idle and waiting for the human. */\n | 'turn_completed'\n /** The session failed (`session_error`). */\n | 'session_error'\n /** The session ended (`session_closed`), whoever ended it. */\n | 'session_closed'\n\n/** One delivery on the session-notification channel (JSON body of a webhook POST). */\nexport type SessionNotification = {\n type: SessionNotificationType\n sessionId: string\n /** Snapshot at notification time — status, title, cwd, cost, `lastSeq`. */\n session: SessionInfo\n /** Seq of the event behind this notification; attach with `afterSeq: seq - 1` to\n * land on it. */\n seq: number\n ts: number\n /** One line fit for a notification body: the permission title, the turn's final\n * text, the error message. */\n preview?: string\n /** `permission_requested` only: the full request, so a consumer can answer it via\n * `POST {basePath}/sessions/:id/permissions/:requestId` — which is what makes an\n * Approve/Deny action on a lock-screen notification possible. */\n request?: PermissionRequest\n /** `turn_completed` only. */\n result?: { isError: boolean; durationMs: number; numTurns: number; totalCostUsd: number }\n /** `session_closed` only. */\n reason?: 'client' | 'server' | 'error'\n}\n\n/** Where session notifications are POSTed (JSON body = {@link SessionNotification}).\n * Server-wide, not per session: the point is to hear about sessions you did not\n * create yourself and are not attached to. */\nexport type SessionWebhookConfig = {\n url: string\n /** Extra headers sent with every delivery (auth tokens etc.). */\n headers?: Record<string, string>\n /** Types to deliver. Default: all of them. */\n events?: SessionNotificationType[]\n}\n\n// ---------------------------------------------------------------------------\n// Job queue (one-shot scheduled runs over the session runner)\n// ---------------------------------------------------------------------------\n\n/**\n * - `queued` — accepted, waiting for a concurrency slot (or the daily token budget)\n * - `running` — a session is executing the prompt\n * - `parked` — waiting on an external event (a deferred tool execution). Not\n * terminal and not consuming a concurrency slot; resumes to `running` when the\n * result arrives, or fails via the execution watchdog if it never does.\n * - `succeeded` / `failed` — terminal; `result` (and `error` on failure) are set\n * - `canceled` — terminal; canceled by a client before or during the run\n */\nexport type JobStatus = 'queued' | 'running' | 'parked' | 'succeeded' | 'failed' | 'canceled'\n\n/** Where job progress/completion deliveries are POSTed (JSON body = {@link JobEvent}). */\nexport type WebhookConfig = {\n url: string\n /** Extra headers sent with every delivery (auth tokens etc.). */\n headers?: Record<string, string>\n /** Delivery granularity: 'messages' also POSTs job_progress per assistant message /\n * permission request; 'completion' only job_started + job_completed. Default 'messages'. */\n progress?: 'messages' | 'completion'\n}\n\n/**\n * Schedule a one-shot run: the session executes `prompt` unattended and the job\n * completes with that run's result. `session.prompt` is the task and is required;\n * `resume`/`forkSession` are not supported for queued jobs.\n */\nexport type CreateJobRequest = {\n session: CreateSessionRequest & { prompt: string }\n webhook?: WebhookConfig\n /** Per-job token cap; the effective cap is min(this, the server's sessionTokenLimit). */\n maxTokens?: number\n /** Per-job wall-clock cap; the effective cap is min(this, the server's maxJobDurationMs). */\n maxDurationMs?: number\n /** Total run attempts: failed (not canceled) runs re-queue until this many attempts\n * have been made. Default 1 (no retries). */\n attempts?: number\n /** Delay before the first retry, doubled for each subsequent one. Default 5000. */\n retryDelayMs?: number\n /** Host bookkeeping echoed back on JobInfo. */\n meta?: Record<string, unknown>\n}\n\n/** Cumulative resource usage of a job's run. `tokens` counts input + output +\n * cache-creation + cache-read tokens across all turns. */\nexport type JobUsage = {\n tokens: number\n totalCostUsd: number\n numTurns: number\n}\n\n/** Terminal outcome of the job's run (mirrors the final turn_result). */\nexport type JobResult = {\n subtype: string\n isError: boolean\n /** Final text of the run (success only). */\n result?: string\n errors?: string[]\n durationMs: number\n}\n\nexport type JobInfo = {\n id: string\n status: JobStatus\n /** `''` when the run's engine has no host filesystem (see\n * {@link CreateSessionRequest.cwd}). */\n cwd: string\n /** Profile the run executes under (resolved name, present even when implicit). */\n profile?: string\n prompt: string\n /** Server session id once started — attach via the sessions WS to watch the run live. */\n sessionId?: string\n sdkSessionId?: string\n createdAt: number\n startedAt?: number\n finishedAt?: number\n /** 1-based run attempt this info reflects. */\n attempt?: number\n /** Total attempts configured on the request (see CreateJobRequest.attempts). */\n maxAttempts?: number\n /** For a job re-queued by retry backoff: earliest time the next attempt may start. */\n nextRunAt?: number\n /** Set while `status` is 'parked': when the run parked, and the execution it is\n * waiting on — the id to POST a result to. Cleared when it resumes. */\n parkedAt?: number\n parkedExecutionId?: string\n /** Cumulative across attempts. */\n usage: JobUsage\n result?: JobResult\n /** Failure or cancellation reason (for a queued retry: the previous attempt's error). */\n error?: string\n meta?: Record<string, unknown>\n /** Scope tags of the session this job runs (see\n * {@link CreateSessionRequest.scope}) — copied from the request at submit so\n * the job routes can be gated by the same rule as the session routes. Without\n * it the queue would be a side door into an unscoped session. */\n scope?: Record<string, string>\n}\n\n/** Latest mid-run activity, carried on job_progress deliveries. */\nexport type JobProgress = {\n kind: 'assistant_text' | 'tool_use' | 'permission_requested' | 'permission_resolved'\n /** Short human-readable preview (message excerpt, tool name, permission title). */\n preview?: string\n /** 'permission_requested' only: the full request (including AskUserQuestion input) so\n * webhook consumers can answer via POST /sessions/:sessionId/permissions/:requestId. */\n request?: PermissionRequest\n}\n\n/** Webhook delivery payload (also the queue's local event shape). `job_submitted` goes\n * to local observers and the queue WS only — the submitter already has the POST\n * response, so webhooks start at `job_started`. `job_retrying` marks a failed run that\n * was re-queued (`job.nextRunAt` says when); `job_completed` is always terminal. */\nexport type JobEvent =\n | { type: 'job_submitted'; job: JobInfo; ts: number }\n | { type: 'job_started'; job: JobInfo; ts: number }\n | { type: 'job_progress'; job: JobInfo; progress: JobProgress; ts: number }\n /** The run parked on a deferred execution; `executionId` says what it waits on —\n * the id to POST a result to. The *work itself* (tool name, input, VFS seed) went\n * to the executor's own dispatch hook, not over this channel: a webhook consumer\n * learns that a run is waiting, the worker learns what to do. */\n | { type: 'job_parked'; job: JobInfo; executionId: string; ts: number }\n /** A parked run resumed because its execution result arrived. */\n | { type: 'job_resumed'; job: JobInfo; executionId: string; ts: number }\n | { type: 'job_retrying'; job: JobInfo; ts: number }\n | { type: 'job_completed'; job: JobInfo; ts: number }\n\nexport type QueueStats = {\n maxConcurrency: number\n running: number\n queued: number\n /** Jobs waiting on a deferred execution. They hold no concurrency slot and\n * their wall-clock budget is not ticking. */\n parked: number\n sessionTokenLimit?: number\n dailyTokenLimit?: number\n /** Tokens consumed by queue jobs in the current UTC day. */\n dailyTokensUsed: number\n /** True when the daily budget is exhausted and queued jobs are being held. */\n paused: boolean\n}\n\n/** Frames sent on the queue WS (`{basePath}/queue/ws`). The stream is one-way\n * (server→client): every job's lifecycle as it happens, plus refreshed stats after\n * lifecycle changes. Clients send nothing; job mutations stay on REST. */\nexport type QueueServerFrame =\n | { type: 'queue_attached'; protocolVersion: number; stats: QueueStats }\n | { type: 'job_event'; event: JobEvent }\n | { type: 'queue_stats'; stats: QueueStats }\n\nexport type CreateJobResponse = { job: JobInfo }\nexport type GetJobResponse = { job: JobInfo }\nexport type ListJobsResponse = { jobs: JobInfo[] }\nexport type QueueStatsResponse = { stats: QueueStats }\n\nexport * from './session-list.ts'\nexport * from './usage.ts'\nexport * from './watermarks.ts'\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workerdeck/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The WorkerDeck wire protocol: typed session events, commands, and REST shapes shared by server and clients. Dependency-free, browser-safe. This protocol is the product boundary — versioned from day one.",
|
|
6
6
|
"license": "MIT",
|