@cplieger/web-terminal-engine 3.0.7 → 3.3.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 CHANGED
@@ -84,12 +84,50 @@ document.addEventListener("keydown", (ev) => {
84
84
  - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
85
85
  - **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
86
86
  - **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
87
- - **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract.
87
+ - **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
88
88
  - **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
89
89
  - **`controlFrame(msg)` / `wsURL(proto, host, path?)`** — Low-level helpers for the client → server protocol (0x00-prefixed JSON control frames, WebSocket URL building). Used internally by `connection`; exported for advanced consumers.
90
90
 
91
91
  Wire types (`WireRun`, `ScreenMessage`, `ScrollMessage`, `ModesMessage`, `TitleMessage`, `ResumeAckMessage`, `ServerMessage`, `ControlMessage`) are re-exported from the package root and match the Go server's wire format byte-for-byte.
92
92
 
93
+ ## Wire compatibility manifest
94
+
95
+ The same compatibility numbers are also published as a language-neutral JSON artifact, so a consumer that cannot import TypeScript — a Dockerfile, a shell release gate, a CI script in any language — can read them without scraping source:
96
+
97
+ ```sh
98
+ # npm consumer
99
+ jq -r .wireCompatibility.protocolVersion \
100
+ node_modules/@cplieger/web-terminal-engine/wire-compatibility.json
101
+
102
+ # JSR consumer (published as an included file; JSR exports are modules only)
103
+ curl -fsSL https://jsr.io/@cplieger/web-terminal-engine/<version>/wire-compatibility.json
104
+ ```
105
+
106
+ ```json
107
+ {
108
+ "schemaVersion": 1,
109
+ "generatedBy": "web/src/wire-manifest.ts",
110
+ "wireCompatibility": {
111
+ "protocolVersion": 4,
112
+ "minimumServerProtocolVersion": 3,
113
+ "incompatibleCloseCode": 4002
114
+ }
115
+ }
116
+ ```
117
+
118
+ It is generated from `WIRE_COMPATIBILITY` (there is no second copy of the numbers), checked into the repo because the publish pipeline runs no build step, and guarded by a regenerate-and-diff test so it cannot go stale. Its values are pinned to both the TypeScript constants and the Go `terminal` constants, so the three surfaces cannot diverge.
119
+
120
+ **What you may rely on** (semver-governed public artifact, versioned by the package version):
121
+
122
+ - The file is present at the package root of every npm tarball and JSR publish, at path `wire-compatibility.json`, and is importable from npm as `@cplieger/web-terminal-engine/wire-compatibility.json`.
123
+ - `schemaVersion` is an integer describing this file's LAYOUT, not the wire protocol. Read it first and reject a value you do not understand.
124
+ - Within a `schemaVersion`, the fields under `wireCompatibility` keep their names, types and meanings, and equal the correspondingly named `WIRE_COMPATIBILITY` members of the same release.
125
+ - New fields may be added under either object in a MINOR release, so parse permissively (ignore unknown keys).
126
+
127
+ **What is not part of the contract**: `generatedBy` is an informational provenance note. The manifest deliberately carries no package version — the publish step injects that into `package.json` / `jsr.json` after checkout, so a version here would ship frozen at the repo placeholder; read the package version from those files.
128
+
129
+ **Breaking changes to the manifest** (MAJOR, and always release-noted): removing or renaming a field, changing a field's type or meaning, moving the file, or bumping `schemaVersion`. A change to the wire revision NUMBERS is not a manifest break — reporting them is what the file is for.
130
+
93
131
  ## Browser-only
94
132
 
95
133
  This package depends on `document`, `HTMLElement`, `MessageChannel`, and other DOM APIs, so it only runs in browser-like environments. The companion Go server runs anywhere Go does.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.7",
3
+ "version": "3.3.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
@@ -8,7 +8,8 @@
8
8
  "include": [
9
9
  "LICENSE",
10
10
  "README.md",
11
- "src/**/*.ts"
11
+ "src/**/*.ts",
12
+ "wire-compatibility.json"
12
13
  ],
13
14
  "exclude": [
14
15
  "src/**/*.test.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.7",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -11,7 +11,7 @@
11
11
  "devDependencies": {
12
12
  "@cplieger/web-terminal-engine-v3": "npm:@cplieger/web-terminal-engine@2.8.0",
13
13
  "@eslint/js": "10.0.1",
14
- "@playwright/test": "1.61.1",
14
+ "@playwright/test": "1.62.0",
15
15
  "@stryker-mutator/core": "9.6.1",
16
16
  "@stryker-mutator/typescript-checker": "9.6.1",
17
17
  "@stryker-mutator/vitest-runner": "9.6.1",
@@ -19,19 +19,23 @@
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
20
  "@vitest/coverage-v8": "4.1.10",
21
21
  "esbuild": "0.28.1",
22
- "eslint": "10.7.0",
22
+ "eslint": "10.8.0",
23
23
  "fast-check": "4.9.0",
24
- "happy-dom": "20.11.0",
24
+ "happy-dom": "20.11.1",
25
25
  "pngjs": "7.0.0",
26
26
  "prettier": "3.9.6",
27
27
  "typescript": "npm:@typescript/typescript6@6.0.2",
28
28
  "typescript-eslint": "8.65.0",
29
29
  "vitest": "4.1.10"
30
30
  },
31
+ "overrides": {
32
+ "qs": "^6.15.2"
33
+ },
31
34
  "description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
32
35
  "license": "GPL-3.0-or-later",
33
36
  "exports": {
34
- ".": "./src/index.ts"
37
+ ".": "./src/index.ts",
38
+ "./wire-compatibility.json": "./wire-compatibility.json"
35
39
  },
36
40
  "types": "./src/index.ts",
37
41
  "publishConfig": {
@@ -47,6 +51,7 @@
47
51
  "!src/**/*.test.ts",
48
52
  "!src/**/test-helpers",
49
53
  "!src/**/fc-strict-setup.ts",
50
- "jsr.json"
54
+ "jsr.json",
55
+ "wire-compatibility.json"
51
56
  ]
52
57
  }
package/src/connection.ts CHANGED
@@ -278,12 +278,16 @@ export interface Callbacks {
278
278
  * transient treatment (onClose + backoff reconnect), so existing
279
279
  * consumers are unaffected until they opt in. */
280
280
  onProcessExit?(): void;
281
- /** Fired when the server's boot-epoch in resumeAck differs from the
282
- * one observed on a previous connection — i.e. the server has
283
- * restarted. By the time this fires, the connection module has
284
- * already reset bytesSent/bytesAcked/outbox so subsequent input
285
- * starts from zero. UI should clear scrollback and surface a
286
- * banner so the user knows old input may have been lost. */
281
+ /** Fired when the client's queued input can no longer be trusted, which
282
+ * happens two ways: the server's boot-epoch in resumeAck differs from the
283
+ * one observed on a previous connection (the server restarted), or the
284
+ * server no longer holds this session's input ledger AND the outbox still
285
+ * held unacked bytes. A forgotten ledger whose every byte was already acked
286
+ * does NOT fire this — nothing was lost, and reporting it made a phone
287
+ * waking from sleep announce a restart that never happened. By the time this
288
+ * fires, the connection module has already reset bytesSent/bytesAcked/outbox
289
+ * so subsequent input starts from zero. UI should surface a banner so the
290
+ * user knows recent input may have been lost. */
287
291
  onServerRestart?(): void;
288
292
  /** Fired when a resumeAck carries an explicit server revision outside
289
293
  * [MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_PROTOCOL_VERSION]. A newer
@@ -415,18 +419,51 @@ export function sendResize(): void {
415
419
  sendControl({ type: "resize", cols, rows });
416
420
  }
417
421
 
418
- // resetSessionAfterRestart clears a session's reliable-input accounting and
419
- // notifies the UI. Called when the server's boot epoch changes (restart) or when
420
- // the server no longer recognizes the session (received=0 with prior acks) -- both
421
- // invalidate the local bytesSent/bytesAcked/outbox state.
422
- function resetSessionAfterRestart(st: ResumeState): void {
422
+ // resetLedger drops a session's reliable-input accounting. Called when the
423
+ // server's boot epoch changes (restart) or when the server no longer recognizes
424
+ // the session -- both invalidate the local bytesSent/bytesAcked/outbox state.
425
+ // The three entry points below differ only in whether the UI hears about it.
426
+ // resetLedger drops a session's reliable-input accounting. Called whenever the
427
+ // server no longer holds the ledger these counters were keyed to (a restart, an
428
+ // idle-GC'd resume key, a session the server does not recognize): its
429
+ // replacement counts from zero, so keeping non-zero counters would make every
430
+ // later ack read as stale in applyAck and the outbox would never trim again.
431
+ function resetLedger(st: ResumeState): void {
423
432
  st.bytesSent = 0;
424
433
  st.bytesAcked = 0;
425
434
  st.outbox.length = 0;
426
435
  st.outboxBytes = 0;
436
+ }
437
+
438
+ // resetSessionAfterRestart resets the ledger and tells the UI the server
439
+ // restarted. Reserved for a boot-epoch change, which is the one cause where
440
+ // that sentence is literally true.
441
+ function resetSessionAfterRestart(st: ResumeState): void {
442
+ resetLedger(st);
427
443
  cb?.onServerRestart?.();
428
444
  }
429
445
 
446
+ // resetForgottenLedger handles a ledger the server no longer holds (the
447
+ // explicit ledgerLost flag, or the old-server received=0 heuristic). The server
448
+ // forgetting a ledger is not a restart and is not by itself a loss: only the
449
+ // UNACKED remainder was ever at risk. So the UI is notified only when there was
450
+ // one, and a session whose every sent byte was already acked resets silently —
451
+ // nothing can be replayed and nothing was lost.
452
+ //
453
+ // The silent case is the common one, and reporting it was a real defect: an
454
+ // iPad whose screen slept for half an hour woke, reconnected, and announced a
455
+ // server restart that never happened. Server-side, the ledger it claimed had
456
+ // received EXACTLY as many bytes as the client claimed to have sent (10531 ==
457
+ // 10531 in the incident logs), i.e. an empty outbox and a fully-applied
458
+ // session, and the banner still fired.
459
+ function resetForgottenLedger(st: ResumeState): void {
460
+ const lostUnacked = st.bytesSent > st.bytesAcked;
461
+ resetLedger(st);
462
+ if (lostUnacked) {
463
+ cb?.onServerRestart?.();
464
+ }
465
+ }
466
+
430
467
  // applyAck drops chunks from the front of the session's outbox until the
431
468
  // running total of unacked bytes matches (bytesSent - newAck). Runs in
432
469
  // O(chunks_dropped) by tracking outboxBytes incrementally rather than
@@ -901,11 +938,12 @@ export function connect(): void {
901
938
  // tail): the resume key missed the server's registry while we claimed
902
939
  // sent bytes — our ledger was reclaimed (idle GC / cap eviction). The
903
940
  // server cannot vouch for ANY previously sent input, so replaying the
904
- // outbox risks duplicate execution. Deterministic drop-and-notify,
941
+ // outbox risks duplicate execution. Deterministic drop, plus a notify
942
+ // only when unacked bytes were actually dropped (resetForgottenLedger),
905
943
  // covering the bytesAcked === 0 case the heuristic below cannot see
906
944
  // (acks that never reached us before the disconnect).
907
945
  if (msg.ledgerLost) {
908
- resetSessionAfterRestart(st);
946
+ resetForgottenLedger(st);
909
947
  return;
910
948
  }
911
949
  // Server-doesn't-recognize-this-session safeguard (old-server
@@ -914,12 +952,12 @@ export function connect(): void {
914
952
  // our session (idle GC kicked in, or sessionId persistence failed and
915
953
  // a reload created a new one). Replaying the outbox would deliver
916
954
  // every queued chunk again, causing the iOS tab-suspend
917
- // duplicate-resend bug. Drop the outbox and notify the UI as if the
918
- // server restarted — input since the last successful ack is
955
+ // duplicate-resend bug. Drop the outbox, and notify only if it held
956
+ // unacked bytes — input since the last successful ack is
919
957
  // irrecoverable but at least not duplicated. Skip this branch when
920
958
  // bytesSent = 0 (genuine first-connect; received=0 is correct).
921
959
  if (msg.received === 0 && st.bytesAcked > 0) {
922
- resetSessionAfterRestart(st);
960
+ resetForgottenLedger(st);
923
961
  return;
924
962
  }
925
963
  applyAck(st, msg.received);
package/src/render.ts CHANGED
@@ -669,8 +669,80 @@ function scheduleFlush(): void {
669
669
  pendingFrame = requestAnimationFrame(flushRender);
670
670
  }
671
671
 
672
+ // --- Read-position anchoring (manual scroll anchoring) ---
673
+ //
674
+ // A flush can change the content height ABOVE the reading position: rows evicted
675
+ // from the top of history once the retention cap is reached, and the trim marker
676
+ // appearing. Every such change moves whatever the user is reading, unless the
677
+ // viewport is shifted by the same amount.
678
+ //
679
+ // Chrome and Firefox do that natively (scroll anchoring, `overflow-anchor`).
680
+ // WebKit does not implement it at all, so on Safari — including the iPad, where
681
+ // this UI mostly runs — a user scrolled up to read had their position dragged
682
+ // one line per evicted row for as long as output kept arriving.
683
+ //
684
+ // The anchor is the first row at or below the viewport top, found by binary
685
+ // search over the container's children: they are in document order, so their
686
+ // offsetTop is monotonic. ~13 reads for a full 5000-row buffer, and only while
687
+ // the user is actually scrolled up — a following viewport takes this path not at
688
+ // all, because the bottom pin owns its position.
689
+ interface ReadAnchor {
690
+ el: HTMLElement;
691
+ /** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
692
+ * the screen position rather than the container offset is what makes the
693
+ * correction idempotent — see restoreReadAnchor. */
694
+ screenTop: number;
695
+ }
696
+
697
+ function captureReadAnchor(): ReadAnchor | null {
698
+ if (!scroll.isUserScrolledUp()) {
699
+ return null; // following: stickToBottom owns the position
700
+ }
701
+ const kids = output.children;
702
+ if (kids.length === 0) {
703
+ return null;
704
+ }
705
+ const offset = scroll.currentScrollTop();
706
+ let lo = 0;
707
+ let hi = kids.length - 1;
708
+ let found: HTMLElement | null = null;
709
+ while (lo <= hi) {
710
+ const mid = (lo + hi) >> 1;
711
+ const el = kids[mid] as HTMLElement;
712
+ if (el.offsetTop >= offset) {
713
+ found = el;
714
+ hi = mid - 1;
715
+ } else {
716
+ lo = mid + 1;
717
+ }
718
+ }
719
+ // Everything is above the viewport top (a shrink already clamped past the
720
+ // last row): anchor on the last row rather than giving up.
721
+ const el = found ?? (kids[kids.length - 1] as HTMLElement);
722
+ return { el, screenTop: el.offsetTop - offset };
723
+ }
724
+
725
+ function restoreReadAnchor(anchor: ReadAnchor | null): void {
726
+ if (anchor?.el.parentElement !== output) {
727
+ return; // not holding, or the anchor row was itself evicted (mass trim)
728
+ }
729
+ // Correct by how far the row DRIFTED ON SCREEN, not by how much the content
730
+ // above it changed. The two differ exactly when the browser already did this
731
+ // itself: Chrome and Firefox have native scroll anchoring, so their offsetTop
732
+ // change comes with a matching scrollTop change and the screen position is
733
+ // already right — this then measures zero drift and does nothing. Correcting
734
+ // the content delta instead would double-compensate there and throw the view
735
+ // the other way. On Safari, which has no native anchoring, the drift is the
736
+ // whole content delta and this is the only thing that fixes it.
737
+ const drift = anchor.el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
738
+ scroll.adjustForContentShift(drift);
739
+ }
740
+
672
741
  function flushRender(): void {
673
742
  pendingFrame = undefined;
743
+ // Measured BEFORE any DOM mutation, so the post-mutation read below reveals
744
+ // exactly how much the content above the reading position moved.
745
+ const anchor = captureReadAnchor();
674
746
  try {
675
747
  flushRenderInner();
676
748
  // A clean pass means the error condition (if any) has cleared, so give a
@@ -704,6 +776,12 @@ function flushRender(): void {
704
776
  console.error("vterm: giving up render retry after repeated no-progress errors");
705
777
  }
706
778
  }
779
+ // Two position invariants, applied after every DOM mutation and in this order:
780
+ // hold the reader's line against content that shifted above it, then (only if
781
+ // following) pin to the new bottom. They are mutually exclusive by
782
+ // construction — the anchor is null while following — so the order is for
783
+ // readability, not correctness.
784
+ restoreReadAnchor(anchor);
707
785
  // Single auto-follow invariant, applied after every DOM mutation.
708
786
  stickToBottomIfFollowing();
709
787
  }
package/src/scroll.ts CHANGED
@@ -10,8 +10,13 @@
10
10
  // failure mode; see the #web-terminal-engine steering doc, "Design rationale").
11
11
  //
12
12
  // The renderer calls stickToBottom() once after each flush: if following, pin
13
- // to the new bottom; if holding, do nothing and let native scroll anchoring
14
- // (overflow-anchor) hold the reading position when history is inserted above.
13
+ // to the new bottom; if holding, do nothing. Holding the reading position when
14
+ // content ABOVE it changes height (top-of-history eviction at the retention cap,
15
+ // the trim marker appearing) is adjustForContentShift's job, called by the
16
+ // renderer around its DOM mutations. That used to be left to native scroll
17
+ // anchoring (overflow-anchor), which WebKit has never shipped — so on Safari and
18
+ // iPadOS the read position slid up one line per evicted row while the user was
19
+ // scrolled up reading.
15
20
  // Appending content at the bottom does not fire a scroll event, so following
16
21
  // stays true across new output and the post-flush pin lands correctly. Pinning
17
22
  // to the bottom produces a scroll event whose recomputation yields
@@ -148,6 +153,34 @@ export function currentScrollTop(): number {
148
153
  return scrollEl ? scrollEl.scrollTop : 0;
149
154
  }
150
155
 
156
+ /**
157
+ * Shift the viewport by a content-height change that happened ABOVE the reading
158
+ * position, so the user keeps looking at the same line. A no-op while following
159
+ * (the bottom pin owns the position then) and when the delta is zero.
160
+ *
161
+ * This is scroll anchoring, done by hand. Chrome and Firefox do it natively
162
+ * (`overflow-anchor`), and this module's design leaned on that: "if holding, do
163
+ * nothing and let native scroll anchoring hold the reading position when history
164
+ * is inserted above". WebKit has never shipped scroll anchoring, so on Safari
165
+ * — including iPadOS, where this UI mostly lives — nothing compensated, and
166
+ * every row evicted from the top of history while the user was scrolled up
167
+ * reading slid their reading position one line further up. Over a streaming
168
+ * agent session at the retention cap that reads as "the view scrolls itself
169
+ * while I am trying to read".
170
+ *
171
+ * `following` is deliberately NOT touched, and neither is `lastScrollTop`: the
172
+ * state stays derived purely from the resulting scroll event, exactly as it is
173
+ * for the bottom pin. Both directions recompute to holding (an upward shift
174
+ * leaves a gap below; a downward one is not at the bottom), so there is no churn
175
+ * and no follow flip.
176
+ */
177
+ export function adjustForContentShift(deltaPx: number): void {
178
+ if (!scrollEl || following || deltaPx === 0) {
179
+ return;
180
+ }
181
+ scrollEl.scrollTop += deltaPx;
182
+ }
183
+
151
184
  /**
152
185
  * Restore a previously saved scroll offset (per-view scroll memory: a tabbed
153
186
  * shell re-entering a tab whose user had scrolled up to read). The offset is
@@ -9,16 +9,42 @@ import { nextBackoffDelay } from "./reconnect.js";
9
9
 
10
10
  /** SessionInfo is one session's wire shape: the JSON object the session REST
11
11
  * API (GET/POST /api/sessions) returns per session. Mirrors the Go
12
- * terminal.SessionInfo — the two are kept in lockstep by hand (single 6-field
13
- * type; flip to wiregen if this surface grows). */
12
+ * terminal.SessionInfo — the two are kept in lockstep by hand (single 7-field
13
+ * type; flip to wiregen if this surface grows).
14
+ *
15
+ * Three of the fields are title-shaped and they are not interchangeable:
16
+ * `title` is the RESOLVED display title, `pinnedTitle` is the USER's name (set
17
+ * and cleared only by a human action, outranking everything else), and
18
+ * `clientTitle` is a CLIENT-DERIVED automatic title a client asked the server to
19
+ * remember. */
14
20
  export interface SessionInfo {
15
21
  readonly id: string;
16
- readonly status: "working" | "idle" | "input" | "done" | "exited";
22
+ /** the session's current status. "working"/"idle"/"exited"/"crashed" are
23
+ * derived by the server; "input"/"done" are latched from a classified OSC 9
24
+ * notification; "failed" is OSC 9;4 progress state 2 and "warning" is state 4
25
+ * (iTerm2 semantics), both of which PERSIST until the program changes the
26
+ * progress state. "exited" and "crashed" split the session's end: "exited" is
27
+ * an ordinary one (status 0, or any exit the server itself caused — a closed
28
+ * session, the idle reaper, a server shutdown), "crashed" is a non-zero exit
29
+ * status or a terminating signal the program was not asked for, so a routine
30
+ * restart never renders as a failure. Neither is a progress state: they
31
+ * outrank everything, and nothing clears them. A consumer must tolerate an
32
+ * UNKNOWN status string from a newer server: the stream is parsed, not
33
+ * validated, and an unrecognised value is forwarded rather than dropped
34
+ * (forward compatibility across the wire floor). */
35
+ readonly status:
36
+ "working" | "idle" | "input" | "done" | "exited" | "crashed" | "failed" | "warning";
17
37
  readonly title: string;
18
- /** the raw client-set title (before the OSC fallback baked into `title`); a
19
- * consumer that treats the program's OSC window title as unreliable reads
20
- * this instead of `title`. */
38
+ /** the raw client-derived automatic title (before the precedence baked into
39
+ * `title`); a consumer that treats the program's OSC window title as
40
+ * unreliable reads this instead of `title`. */
21
41
  readonly clientTitle?: string;
42
+ /** the user's pinned name, set through PUT /api/sessions/{id}/pinned-title and
43
+ * cleared through DELETE on the same path. It outranks every automatic source
44
+ * in `title`; it is carried raw so a UI can tell that a pin EXISTS (to offer
45
+ * the automatic name again) rather than only seeing its effect. Absent or
46
+ * empty means the session has no user-set name. */
47
+ readonly pinnedTitle?: string;
22
48
  readonly createdAt: string;
23
49
  /** true once the session has emitted a genuine activity signal — OSC 9;4
24
50
  * progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
@@ -34,6 +60,18 @@ export interface SessionInfo {
34
60
  export interface SessionStatus extends SessionInfo {
35
61
  /** true when the session is gone (closed or reaped); the consumer drops it. */
36
62
  readonly removed?: boolean;
63
+ /** the OSC 9;4 percentage: -1 when absent or unknown (no sequence seen, or a
64
+ * state that carries none — clear/indeterminate), else 0-100. Paired with
65
+ * `status` to render a determinate bar. */
66
+ readonly progressValue?: number;
67
+ /** the OSC 9 notification message, delivered to EVERY subscriber whether or
68
+ * not the server has a status classifier installed. A notification is an
69
+ * EVENT, not a state: it does not latch a status, so a consumer that wants a
70
+ * status out of it decides that itself. */
71
+ readonly notification?: string;
72
+ /** the notification's sequence number (monotonic per session), so a repeated
73
+ * message is still recognised as a new event. */
74
+ readonly notificationSeq?: number;
37
75
  }
38
76
 
39
77
  /** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
package/src/types.ts CHANGED
@@ -123,9 +123,11 @@ export interface ResumeAckMessage {
123
123
  * claimed sent bytes: the input ledger was reclaimed (idle GC / cap
124
124
  * eviction), so the server cannot vouch for any previously sent input.
125
125
  * The connection module responds deterministically with its designed loss
126
- * semantic — drop the outbox and notify (`onServerRestart`) — instead of
127
- * replaying possibly-duplicate input. Absent on older servers (the
128
- * `received === 0 && bytesAcked > 0` heuristic remains as their fallback).
126
+ * semantic — drop the outbox rather than replay possibly-duplicate input —
127
+ * and notifies (`onServerRestart`) only when that outbox actually held
128
+ * unacked bytes; a fully-acked session lost nothing and resets silently.
129
+ * Absent on older servers (the `received === 0 && bytesAcked > 0` heuristic
130
+ * remains as their fallback).
129
131
  */
130
132
  ledgerLost?: boolean;
131
133
  }
@@ -0,0 +1,77 @@
1
+ // Generator for the published, language-neutral wire-compatibility manifest
2
+ // (`web/wire-compatibility.json`).
3
+ //
4
+ // Why it exists: the wire revision and directional floor are published as
5
+ // TypeScript constants (wire-compatibility.ts) and as Go constants
6
+ // (terminal.WireProtocolVersion / MinSupportedClientWireVersion). A consumer
7
+ // that is neither — a Dockerfile, a shell release gate, a Python CI script —
8
+ // had no machine-readable surface to read them from and resorted to scraping
9
+ // the TypeScript source with `sed`, which breaks silently on any reformat.
10
+ //
11
+ // This module renders that surface FROM the existing WIRE_COMPATIBILITY export,
12
+ // so the numbers keep exactly one source of truth in the TypeScript half. The
13
+ // rendered text is checked into the repo (see the drift guard in
14
+ // wire-manifest.test.ts) because the publish pipeline runs no build step: npm
15
+ // and JSR publish the working tree as-is, so a manifest generated at publish
16
+ // time would never exist in the tarball.
17
+ //
18
+ // The manifest deliberately does NOT carry the package version. `npm pkg set
19
+ // version` injects that at publish time, after checkout, with no regeneration
20
+ // step — an embedded version would be frozen at the repo's placeholder (0.1.0)
21
+ // in every published artifact. Consumers read the package version from
22
+ // package.json / jsr.json, which the pipeline does rewrite.
23
+ //
24
+ // Public-artifact contract: see the "Wire compatibility manifest" section of
25
+ // web/README.md for what a consumer may rely on and what counts as a breaking
26
+ // change.
27
+
28
+ import { WIRE_COMPATIBILITY, type WireCompatibility } from "./wire-compatibility.js";
29
+
30
+ /**
31
+ * Schema revision of the manifest FILE, independent of the wire protocol
32
+ * revision it reports. A consumer must reject a `schemaVersion` it does not
33
+ * understand rather than guess at the shape. Bumped only for a breaking
34
+ * change to the manifest's own layout (a removed or repurposed field).
35
+ */
36
+ export const WIRE_MANIFEST_SCHEMA_VERSION = 1;
37
+
38
+ /** Path of the checked-in artifact this module renders, relative to `web/`. */
39
+ export const WIRE_MANIFEST_PATH = "wire-compatibility.json";
40
+
41
+ /** Shape of the generated wire-compatibility manifest. */
42
+ export interface WireManifest {
43
+ /** Schema revision of this file's layout (see WIRE_MANIFEST_SCHEMA_VERSION). */
44
+ readonly schemaVersion: number;
45
+ /** Informational provenance note; not part of the parsed contract. */
46
+ readonly generatedBy: string;
47
+ /** The same values the TypeScript `WIRE_COMPATIBILITY` export carries. */
48
+ readonly wireCompatibility: WireCompatibility;
49
+ }
50
+
51
+ /**
52
+ * Builds the manifest object from the live TypeScript constants. Nesting the
53
+ * protocol values under `wireCompatibility` keeps file-level metadata
54
+ * (`schemaVersion`) in a separate namespace from the payload, so a future
55
+ * metadata field can never collide with a protocol field name.
56
+ */
57
+ export function buildWireManifest(): WireManifest {
58
+ return {
59
+ schemaVersion: WIRE_MANIFEST_SCHEMA_VERSION,
60
+ generatedBy: "web/src/wire-manifest.ts",
61
+ wireCompatibility: {
62
+ protocolVersion: WIRE_COMPATIBILITY.protocolVersion,
63
+ minimumServerProtocolVersion: WIRE_COMPATIBILITY.minimumServerProtocolVersion,
64
+ incompatibleCloseCode: WIRE_COMPATIBILITY.incompatibleCloseCode,
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Renders the exact bytes of the checked-in manifest: two-space indentation and
71
+ * a trailing newline, which is what `prettier --check` requires of a JSON file
72
+ * in this repo. Comparing rendered text (not parsed objects) is what makes the
73
+ * drift guard able to fail on a hand edit that happens to parse the same.
74
+ */
75
+ export function renderWireManifest(): string {
76
+ return `${JSON.stringify(buildWireManifest(), null, 2)}\n`;
77
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "generatedBy": "web/src/wire-manifest.ts",
4
+ "wireCompatibility": {
5
+ "protocolVersion": 4,
6
+ "minimumServerProtocolVersion": 3,
7
+ "incompatibleCloseCode": 4002
8
+ }
9
+ }