@native-sdk/core 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-sdk/core",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "The TypeScript authoring tier: the app-core subset, its dev-time transpiler to arena-backed Zig, and the SDK module cores import",
5
5
  "repository": {
6
6
  "type": "git",
package/sdk/core.ts CHANGED
@@ -103,6 +103,23 @@
103
103
  // forget control verbs whose consequences arrive
104
104
  // on the event stream; aimed at a key with no
105
105
  // open stream they no-op.
106
+ // Cmd.videoLoad(key, { surface, path?, url?, autoplay?, loop?, muted? }, { event })
107
+ // open (or replace — one player is the whole
108
+ // surface) the video event stream feeding the
109
+ // media-surface `surface` names (the same
110
+ // model-owned id the markup binds): every
111
+ // playback event dispatches the `event` arm (the
112
+ // seven-field record below) until Cmd.videoStop
113
+ // closes the stream. Pixels never ride the
114
+ // events — decoded frames flow platform-side
115
+ // into the bound surface.
116
+ // Cmd.videoPlay(key) / videoPause(key) / videoStop(key)
117
+ // Cmd.videoSeek(key, ms) / Cmd.videoSetVolume(key, volume)
118
+ // Cmd.videoSetMuted(key, muted) / Cmd.videoSetLoop(key, loop)
119
+ // drive the open playback in place — fire-and-
120
+ // forget control verbs whose consequences arrive
121
+ // on the event stream; aimed at a key with no
122
+ // open stream they no-op.
106
123
  // Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })
107
124
  // load an image at runtime by its model-owned
108
125
  // numeric ImageId (the id markup binds:
@@ -132,6 +149,67 @@
132
149
  // the id is untouched: its terminal still
133
150
  // registers — cancel the load first to keep
134
151
  // the slot free.
152
+ // Cmd.channelOpen(key, { event })
153
+ // open an EXTERNAL-SOURCE CHANNEL under the
154
+ // app's numeric key: the host stages a
155
+ // long-lived, thread-safe posting seam its
156
+ // NATIVE side feeds — embedders and
157
+ // platform-services extensions post bytes
158
+ // from their own threads (sockets, watchers,
159
+ // workers), and each accepted post arrives as
160
+ // one "data" event through the `event` arm
161
+ // (the five-field record below) with the
162
+ // honest back-pressure counters aboard. The
163
+ // TS tier opens, closes, and receives;
164
+ // POSTING is not a TS verb — transpiled cores
165
+ // are single-threaded by design, so the
166
+ // posting handle lives on the native side
167
+ // (`Effects.channelHandle(key)`). One channel
168
+ // per key at a time: a duplicate live key
169
+ // dispatches state "rejected". No timer
170
+ // polling anywhere: the source wakes the
171
+ // loop itself.
172
+ // Cmd.channelClose(key) close the open channel under the key, if
173
+ // any: staged posts flush, exactly one
174
+ // "closed" event (final drop totals)
175
+ // dispatches the event arm, and the key
176
+ // frees. A key with no open channel no-ops.
177
+ // Cmd.ptySpawn(argv, { key?, cols?, rows?, term?, event })
178
+ // open a PSEUDO-TERMINAL SESSION — a spawn
179
+ // with a different transport: run argv on a
180
+ // fresh pty whose initial grid is cols x rows
181
+ // (80x24 by default) and whose TERM is `term`
182
+ // (omitted = the engine's default). Every
183
+ // session event dispatches the `event` arm
184
+ // (the six-field record below): "output"
185
+ // events carry coalesced batches of child
186
+ // output across dispatches — feed them to the
187
+ // terminal emulator — until the exactly-one
188
+ // "exit" terminal (a refused spawn is one
189
+ // "exit" with reason "rejected"; a transport
190
+ // that could not start, one with
191
+ // "spawn_failed"). One session per key at a
192
+ // time, never replaced implicitly — kill it
193
+ // first, the spawn discipline.
194
+ // Cmd.ptyWrite(key, bytes) write bytes toward the session's child —
195
+ // keystrokes and pastes, fire-and-forget: a
196
+ // key with no open session no-ops, and
197
+ // refused payloads count into the exit's
198
+ // droppedWrites, never silence.
199
+ // Cmd.ptyResize(key, cols, rows)
200
+ // push a new grid to the session so the child
201
+ // receives SIGWINCH — fire-and-forget like
202
+ // ptyWrite; a key with no open session
203
+ // no-ops.
204
+ // Cmd.ptyKill(key) terminate the session's child — LOUD, the
205
+ // spawn cancel discipline: the session's one
206
+ // "exit" terminal arrives through its own
207
+ // event arm with reason "cancelled" and the
208
+ // key frees once it lands. A key with no open
209
+ // session no-ops. Sessions are their own
210
+ // family's to end: the string-keyed
211
+ // Cmd.cancel never touches them, the way
212
+ // Cmd.audioStop is audio's close.
135
213
  //
136
214
  // The window verbs (fire-and-forget, no result Msg — the window's own
137
215
  // frame event carries the state):
@@ -163,8 +241,8 @@
163
241
  // only in that function's return path (NS1025). Sub and the streaming Cmds
164
242
  // are different animals on purpose: a Sub is DECLARED from the model (the
165
243
  // host starts/stops it by reconciliation; the app never opens one), while
166
- // spawn/audioPlay streams are Cmd-INITIATED — imperative opens with a keyed
167
- // lifecycle the app drives and cancels.
244
+ // spawn/audioPlay/videoLoad streams are Cmd-INITIATED — imperative opens
245
+ // with a keyed lifecycle the app drives and cancels.
168
246
  //
169
247
  // The factories return plain frozen-shape objects so the same core runs
170
248
  // under node: a dev harness can interpret the `op` tags directly.
@@ -309,6 +387,54 @@ export type AudioEventKind<M extends Msgish> = M extends Msgish
309
387
  : never
310
388
  : never;
311
389
 
390
+ /// The video event states, the audio vocabulary without spectrum: `loaded`
391
+ /// acknowledges a successful load with the player's duration estimate and
392
+ /// the stream's decoded pixel dimensions; `position` ticks at the
393
+ /// platform's honest cadence (~500ms) while playing; `completed` fires
394
+ /// exactly once when a NON-LOOPING playback reaches its natural end (a
395
+ /// looping playback wraps and never completes); `failed` reports a
396
+ /// load/decode/device failure; `rejected` a command the effects layer
397
+ /// refused (an empty or over-long source, a non-http(s) url, an invalid
398
+ /// surface id).
399
+ export type VideoState = "loaded" | "position" | "completed" | "failed" | "rejected";
400
+
401
+ /// The payload shape of a video event arm — seven fields, matched by NAME
402
+ /// (the AudioEventArm convention). `state` must be a named
403
+ /// string-literal-union alias carrying exactly the five VideoState members
404
+ /// (any declaration order — the host matches members by name).
405
+ /// `positionMs`/`durationMs` are milliseconds; `playing` is the player's
406
+ /// transport state; `buffering` is true while a streamed source is stalled
407
+ /// waiting for network bytes; `width`/`height` are the stream's decoded
408
+ /// pixel dimensions (delivered on "loaded", 0 elsewhere).
409
+ export type VideoEventArm = {
410
+ readonly state: VideoState;
411
+ readonly positionMs: number;
412
+ readonly durationMs: number;
413
+ readonly playing: boolean;
414
+ readonly buffering: boolean;
415
+ readonly width: number;
416
+ readonly height: number;
417
+ };
418
+
419
+ /// The Msg arms a video event stream may target: arms whose payload is
420
+ /// exactly the seven VideoEventArm fields. The `state` check runs BOTH
421
+ /// directions (the AudioEventKind convention): the `&` constraint holds
422
+ /// the arm's states to VideoState, and the tuple-wrapped reverse check
423
+ /// holds VideoState to the arm's states — a narrower union would
424
+ /// silently drop event states the host emits, so it is refused here,
425
+ /// not discovered at runtime.
426
+ export type VideoEventKind<M extends Msgish> = M extends Msgish
427
+ ? [Exclude<keyof M, "kind">] extends [keyof VideoEventArm]
428
+ ? [keyof VideoEventArm] extends [Exclude<keyof M, "kind">]
429
+ ? M extends Msgish & VideoEventArm
430
+ ? [VideoState] extends [M["state"]]
431
+ ? M["kind"]
432
+ : never
433
+ : never
434
+ : never
435
+ : never
436
+ : never;
437
+
312
438
  /// The image load result states, mirroring the engine's outcome vocabulary:
313
439
  /// "loaded" means the pixels are registered under the requested id (width and
314
440
  /// height carry what the codec decoded); every other state is the failure
@@ -378,6 +504,130 @@ export type ImageEventKind<M extends Msgish> = M extends Msgish
378
504
  : never
379
505
  : never;
380
506
 
507
+ /// The external-source channel event states, mirroring the engine's
508
+ /// vocabulary: "data" is one delivered post from the native side's
509
+ /// thread-safe handle; "closed" is the exactly-one terminal
510
+ /// `Cmd.channelClose` produces (final drop totals aboard); "rejected" is
511
+ /// the exactly-one terminal of a refused open (duplicate live key, a
512
+ /// full channel table, an occupied engine key).
513
+ export type ChannelState = "data" | "closed" | "rejected";
514
+
515
+ /// The payload shape of a channel event arm — five fields, matched by
516
+ /// NAME (the AudioEventArm convention). `key` is the channel key echoed
517
+ /// verbatim, so concurrent channels sharing one arm stay
518
+ /// distinguishable (a key the wire cannot carry exactly echoes 0);
519
+ /// `state` must be a named string-literal-union alias carrying exactly
520
+ /// the three ChannelState members (any declaration order — the host
521
+ /// matches members by name); `bytes` is the post's payload ("data"
522
+ /// events only, empty otherwise); `droppedPending`/`droppedTotal` are
523
+ /// the back-pressure counters — posts the native side's handle refused
524
+ /// since the previous delivered event, and over the channel's whole
525
+ /// life. Never silent drops: the counters ride every event.
526
+ export type ChannelEventArm = {
527
+ readonly key: number;
528
+ readonly state: ChannelState;
529
+ readonly bytes: Uint8Array;
530
+ readonly droppedPending: number;
531
+ readonly droppedTotal: number;
532
+ };
533
+
534
+ /// The Msg arms a channel event stream may target: arms whose payload is
535
+ /// exactly the five ChannelEventArm fields. The `state` check runs BOTH
536
+ /// directions (the AudioEventKind convention): the `&` constraint holds
537
+ /// the arm's states to ChannelState, and the tuple-wrapped reverse check
538
+ /// holds ChannelState to the arm's states — a narrower union would
539
+ /// silently drop event states the host emits, so it is refused here,
540
+ /// not discovered at runtime.
541
+ export type ChannelEventKind<M extends Msgish> = M extends Msgish
542
+ ? [Exclude<keyof M, "kind">] extends [keyof ChannelEventArm]
543
+ ? [keyof ChannelEventArm] extends [Exclude<keyof M, "kind">]
544
+ ? M extends Msgish & ChannelEventArm
545
+ ? [ChannelState] extends [M["state"]]
546
+ ? M["kind"]
547
+ : never
548
+ : never
549
+ : never
550
+ : never
551
+ : never;
552
+
553
+ /// `Cmd.channelOpen` routing: every channel event dispatches the `event`
554
+ /// arm (the five-field ChannelEventArm record, matched by field name).
555
+ export interface ChannelRoute<M extends Msgish> {
556
+ readonly event: ChannelEventKind<M>;
557
+ }
558
+
559
+ /// The pty session event states, mirroring the engine's vocabulary:
560
+ /// "output" is one coalesced batch of child output; "exit" is the
561
+ /// exactly-one terminal every session produces — a clean end, a signal,
562
+ /// a `Cmd.ptyKill`, a refused spawn, or a transport that could not
563
+ /// start (the `reason` field tells which).
564
+ export type PtyState = "output" | "exit";
565
+
566
+ /// How a pty session ended, the spawn exit vocabulary: "exited" with
567
+ /// the child's code, "signaled" with the signal, "cancelled" after
568
+ /// `Cmd.ptyKill`, "rejected" for a spawn refused before a child existed
569
+ /// (duplicate live key, full table, bad grid), "spawn_failed" when the
570
+ /// pty or exec could not start.
571
+ export type PtyExitReason = "exited" | "signaled" | "cancelled" | "rejected" | "spawn_failed";
572
+
573
+ /// The payload shape of a pty event arm — seven fields, matched by NAME
574
+ /// (the AudioEventArm convention). `key` is the app's own session key
575
+ /// (the `Cmd.ptySpawn` `key`, or "" when the spawn named none) — two
576
+ /// sessions routing one event arm are told apart by this field. `state`
577
+ /// must be a named string-literal-union alias carrying exactly the two
578
+ /// PtyState members and `reason` one carrying exactly the five
579
+ /// PtyExitReason members (any declaration order — the host matches
580
+ /// members by name); `bytes` is the coalesced output batch ("output"
581
+ /// events only, empty otherwise); `code` is the child's exit code on an
582
+ /// "exited" end, -1 otherwise; `signal` is the fatal signal after a
583
+ /// "signaled" end, else 0; `droppedWrites` counts `Cmd.ptyWrite`
584
+ /// payloads refused over the session's life — zero means every write
585
+ /// reached the child, never a silent drop.
586
+ export type PtyEventArm = {
587
+ readonly key: string;
588
+ readonly state: PtyState;
589
+ readonly bytes: Uint8Array;
590
+ readonly code: number;
591
+ readonly reason: PtyExitReason;
592
+ readonly signal: number;
593
+ readonly droppedWrites: number;
594
+ };
595
+
596
+ /// The Msg arms a pty session may target: arms whose payload is exactly
597
+ /// the six PtyEventArm fields. The `state` and `reason` checks run BOTH
598
+ /// directions (the AudioEventKind convention): the `&` constraint holds
599
+ /// the arm's unions to PtyState/PtyExitReason, and the tuple-wrapped
600
+ /// reverse checks hold them to the arm's — a narrower union would
601
+ /// silently drop events the host emits, so it is refused here, not
602
+ /// discovered at runtime.
603
+ export type PtyEventKind<M extends Msgish> = M extends Msgish
604
+ ? [Exclude<keyof M, "kind">] extends [keyof PtyEventArm]
605
+ ? [keyof PtyEventArm] extends [Exclude<keyof M, "kind">]
606
+ ? M extends Msgish & PtyEventArm
607
+ ? [PtyState] extends [M["state"]]
608
+ ? [PtyExitReason] extends [M["reason"]]
609
+ ? M["kind"]
610
+ : never
611
+ : never
612
+ : never
613
+ : never
614
+ : never
615
+ : never;
616
+
617
+ /// `Cmd.ptySpawn` routing: every session event dispatches the `event`
618
+ /// arm (the six-field PtyEventArm record, matched by field name).
619
+ /// `cols`/`rows` are the initial grid the child observes (80x24 when
620
+ /// omitted); `term` is the TERM the child starts with (omitted = the
621
+ /// engine's default). The optional `key` names the session for
622
+ /// ptyWrite/ptyResize/ptyKill.
623
+ export interface PtyRoute<M extends Msgish> {
624
+ readonly key?: string;
625
+ readonly cols?: number;
626
+ readonly rows?: number;
627
+ readonly term?: string;
628
+ readonly event: PtyEventKind<M>;
629
+ }
630
+
381
631
  /// One field of a host record payload; see hostRecordBytes for the encoding.
382
632
  export type HostScalar = number | boolean | Uint8Array;
383
633
 
@@ -454,6 +704,30 @@ export interface AudioRoute<M extends Msgish> {
454
704
  readonly event: AudioEventKind<M>;
455
705
  }
456
706
 
707
+ /// A `Cmd.videoLoad` source. `surface` is the model-owned media-surface id
708
+ /// the markup binds — the texture channel the decoded frames feed. The
709
+ /// local `path` is tried first; a missing file falls through to `url`
710
+ /// (streamed progressively, playable before the download finishes). At
711
+ /// least one of path/url must be present. `autoplay` (default true) starts
712
+ /// playback as soon as the load lands — false loads paused at position
713
+ /// zero, the poster-frame shape; `loop` wraps from the natural end back to
714
+ /// zero (a looping playback never delivers "completed"); `muted` starts
715
+ /// the audio track muted, independent of the remembered volume.
716
+ export interface VideoSource {
717
+ readonly surface: number;
718
+ readonly path?: Uint8Array;
719
+ readonly url?: Uint8Array;
720
+ readonly autoplay?: boolean;
721
+ readonly loop?: boolean;
722
+ readonly muted?: boolean;
723
+ }
724
+
725
+ /// `Cmd.videoLoad` routing: every playback event dispatches the `event`
726
+ /// arm (the seven-field VideoEventArm record, matched by field name).
727
+ export interface VideoRoute<M extends Msgish> {
728
+ readonly event: VideoEventKind<M>;
729
+ }
730
+
457
731
  /// A `Cmd.imageLoad` source: the audio cascade's shape exactly. The local
458
732
  /// `path` is tried first; a missing file falls through to `url` (fetched
459
733
  /// whole, installed at `cachePath` when given and verified against
@@ -570,6 +844,25 @@ export type Cmd<M extends Msgish> =
570
844
  /// Seek position (ms) / volume (0..1); 0 for the value-less verbs.
571
845
  readonly value: number;
572
846
  }
847
+ | {
848
+ readonly op: "video_load";
849
+ readonly key: string;
850
+ readonly eventKind: string;
851
+ readonly surface: number;
852
+ readonly path: Uint8Array;
853
+ readonly url: Uint8Array;
854
+ readonly autoplay: boolean;
855
+ readonly loop: boolean;
856
+ readonly muted: boolean;
857
+ }
858
+ | {
859
+ readonly op: "video_ctl";
860
+ readonly key: string;
861
+ readonly verb: "play" | "pause" | "stop" | "seek" | "volume" | "muted" | "loop";
862
+ /// Seek position (ms) / volume (0..1) / the muted-loop switch
863
+ /// (0 = off, 1 = on); 0 for the value-less verbs.
864
+ readonly value: number;
865
+ }
573
866
  | { readonly op: "window_show"; readonly label: string }
574
867
  | { readonly op: "quit_app" }
575
868
  | {
@@ -583,6 +876,21 @@ export type Cmd<M extends Msgish> =
583
876
  }
584
877
  | { readonly op: "image_cancel"; readonly id: number }
585
878
  | { readonly op: "image_unregister"; readonly id: number }
879
+ | { readonly op: "channel_open"; readonly key: number; readonly eventKind: string }
880
+ | { readonly op: "channel_close"; readonly key: number }
881
+ | {
882
+ readonly op: "pty_spawn";
883
+ readonly key: string;
884
+ readonly eventKind: string;
885
+ readonly cols: number;
886
+ readonly rows: number;
887
+ /// "" = the engine's default TERM (the wire never bakes it in).
888
+ readonly term: string;
889
+ readonly argv: readonly Uint8Array[];
890
+ }
891
+ | { readonly op: "pty_write"; readonly key: string; readonly bytes: Uint8Array }
892
+ | { readonly op: "pty_resize"; readonly key: string; readonly cols: number; readonly rows: number }
893
+ | { readonly op: "pty_kill"; readonly key: string }
586
894
  | { readonly op: "batch"; readonly cmds: readonly Cmd<M>[] };
587
895
 
588
896
  /// The wire encoding of a host record payload, byte-identical to what the
@@ -811,6 +1119,81 @@ export const Cmd = {
811
1119
  return { op: "audio_ctl", key, verb: "volume", value: volume };
812
1120
  },
813
1121
 
1122
+ /// Open (or replace — one player is the whole surface) the keyed video
1123
+ /// event stream: claim the media-surface the source names, resolve the
1124
+ /// source cascade (local path, then url) and start playback (autoplay,
1125
+ /// the default). Every playback event dispatches the `event` arm until
1126
+ /// `Cmd.videoStop(key)` closes the stream. Pixels never ride the
1127
+ /// events: decoded frames flow platform-side into the bound surface.
1128
+ /// Failure is never silent: an unplayable source arrives as a "failed"
1129
+ /// event, a refused command as "rejected". Replacing is not stopping:
1130
+ /// a replaced load still delivers the terminal it owes (its failure is
1131
+ /// never silent either), routed to ITS OWN event arm — only
1132
+ /// `Cmd.videoStop` cancels a stream's undelivered answers.
1133
+ videoLoad<M extends Msgish>(key: string, source: VideoSource, route: VideoRoute<M>): Cmd<M> {
1134
+ return {
1135
+ op: "video_load",
1136
+ key,
1137
+ eventKind: route.event,
1138
+ surface: source.surface,
1139
+ path: source.path ?? new Uint8Array(0),
1140
+ url: source.url ?? new Uint8Array(0),
1141
+ autoplay: source.autoplay ?? true,
1142
+ loop: source.loop ?? false,
1143
+ muted: source.muted ?? false,
1144
+ };
1145
+ },
1146
+
1147
+ /// Start or resume the loaded playback — the poster-frame counterpart
1148
+ /// of `autoplay: false`, and un-pause. A key with no open stream
1149
+ /// no-ops; a player that can no longer start reports one "failed"
1150
+ /// event on the stream instead of silence.
1151
+ videoPlay(key: string): Cmd<never> {
1152
+ return { op: "video_ctl", key, verb: "play", value: 0 };
1153
+ },
1154
+
1155
+ /// Pause the keyed playback in place; the surface keeps its last frame
1156
+ /// (no event echo — the caller commanded it). A key with no open
1157
+ /// stream no-ops.
1158
+ videoPause(key: string): Cmd<never> {
1159
+ return { op: "video_ctl", key, verb: "pause", value: 0 };
1160
+ },
1161
+
1162
+ /// Stop the keyed playback, release the surface claim, and CLOSE its
1163
+ /// event stream: no events for the key after this. Stop is the video
1164
+ /// stream's cancel, exactly `Cmd.audioStop`'s discipline.
1165
+ videoStop(key: string): Cmd<never> {
1166
+ return { op: "video_ctl", key, verb: "stop", value: 0 };
1167
+ },
1168
+
1169
+ /// Jump the keyed playback to `ms` (the platform clamps to the
1170
+ /// duration; a paused seek still pushes the sought frame, so scrubbing
1171
+ /// is visible). No event echo — the next position tick reports from
1172
+ /// there.
1173
+ videoSeek(key: string, ms: number): Cmd<never> {
1174
+ return { op: "video_ctl", key, verb: "seek", value: ms };
1175
+ },
1176
+
1177
+ /// Set playback volume, clamped to 0..1 and remembered across loads:
1178
+ /// the next videoLoad re-applies it. Independent of mute.
1179
+ videoSetVolume(key: string, volume: number): Cmd<never> {
1180
+ return { op: "video_ctl", key, verb: "volume", value: volume };
1181
+ },
1182
+
1183
+ /// Mute or unmute the playback's audio track without touching the
1184
+ /// remembered volume (a fresh load's `muted` option is the way to
1185
+ /// start muted).
1186
+ videoSetMuted(key: string, muted: boolean): Cmd<never> {
1187
+ return { op: "video_ctl", key, verb: "muted", value: muted ? 1 : 0 };
1188
+ },
1189
+
1190
+ /// Enable or disable looping on the open playback (a fresh load's
1191
+ /// `loop` option covers the start-looping case). A looping playback
1192
+ /// never delivers "completed".
1193
+ videoSetLoop(key: string, loop: boolean): Cmd<never> {
1194
+ return { op: "video_ctl", key, verb: "loop", value: loop ? 1 : 0 };
1195
+ },
1196
+
814
1197
  /// Show the window with the declared `label`: un-hide + activate — the
815
1198
  /// counterpart to a `close_policy = "hide"` hide and the tray "Open"
816
1199
  /// consequence; also restores a minimized window. Fire-and-forget: no
@@ -881,6 +1264,102 @@ export const Cmd = {
881
1264
  return { op: "image_unregister", id };
882
1265
  },
883
1266
 
1267
+ /// Open an external-source channel under your numeric `key`: the
1268
+ /// host stages a long-lived, thread-safe posting seam its NATIVE
1269
+ /// side feeds — an embedder or platform-services extension resolves
1270
+ /// the handle (`Effects.channelHandle(key)`) and posts bytes from
1271
+ /// its own threads (a socket reader, a file watcher, a worker), and
1272
+ /// each accepted post arrives as one "data" event through the
1273
+ /// `event` arm, waking the loop itself — no timer polling anywhere.
1274
+ /// POSTING is deliberately not a TS verb: transpiled cores are
1275
+ /// single-threaded, so the TS tier opens, closes, and receives while
1276
+ /// the native side feeds. Back-pressure is part of the contract:
1277
+ /// the native `post` answers a four-way `ChannelHandle.PostResult`
1278
+ /// (accepted / dropped_full / dropped_oversized / closed), so a
1279
+ /// producer tells transient back-pressure from closure, and refused
1280
+ /// posts count into `droppedPending`/`droppedTotal` on the next
1281
+ /// delivered event, never silence. The native post never blocks its
1282
+ /// producer given a conforming host wake — the platform's `wake_fn`
1283
+ /// is contractually a bounded, enqueue-only nudge (see
1284
+ /// `PlatformServices.wake_fn`; every first-party host conforms), and
1285
+ /// the runtime holds no channel lock across it. One channel per key at a
1286
+ /// time — a duplicate live key dispatches state "rejected" — and the
1287
+ /// key shares the engine's effect-key space (a same-key fetch is
1288
+ /// blocked while the channel lives). Keys are positive integers
1289
+ /// below 2^53; the events are journaled at the effect boundary, so
1290
+ /// a recorded session replays the whole stream from the journal and
1291
+ /// never NEEDS the source — under replay the open parks and the
1292
+ /// native handle is inert (every post answers closed). The opening
1293
+ /// update still re-executes, so a native producer launched
1294
+ /// unconditionally really starts and is stopped at its first post;
1295
+ /// one that consults the handle's live() before launching never
1296
+ /// starts, keeping replay fully offline.
1297
+ channelOpen<M extends Msgish>(key: number, route: ChannelRoute<M>): Cmd<M> {
1298
+ return { op: "channel_open", key, eventKind: route.event };
1299
+ },
1300
+
1301
+ /// Close the open channel under `key`: posts stop landing, the
1302
+ /// staged backlog flushes, and exactly one "closed" event (final
1303
+ /// drop totals aboard) dispatches the event arm — then the key is
1304
+ /// free again. A key with no open channel no-ops. Channels are keyed
1305
+ /// by their numeric key, so the string-keyed `Cmd.cancel` never
1306
+ /// touches them — this is their close, the way `Cmd.audioStop` is
1307
+ /// audio's.
1308
+ channelClose(key: number): Cmd<never> {
1309
+ return { op: "channel_close", key };
1310
+ },
1311
+
1312
+ /// Open a pseudo-terminal session — a spawn with a different
1313
+ /// transport: run `argv` on a fresh pty (same argv budgets, same
1314
+ /// child environment policy) whose initial grid is `cols` x `rows`
1315
+ /// (80x24 by default) and whose TERM is `term` (omitted = the
1316
+ /// engine's default). Every session event dispatches the `event` arm:
1317
+ /// "output" events carry coalesced batches of child output across
1318
+ /// dispatches — feed them to the terminal emulator and move on —
1319
+ /// until the exactly-one "exit" terminal retires the session (a
1320
+ /// refused spawn is one "exit" with reason "rejected"; a transport
1321
+ /// that could not start, one with "spawn_failed" — failure is never
1322
+ /// silent). One session per key at a time, never replaced implicitly:
1323
+ /// a running terminal's child is a running subprocess — kill it
1324
+ /// first, the spawn discipline.
1325
+ ptySpawn<M extends Msgish>(argv: readonly Uint8Array[], route: PtyRoute<M>): Cmd<M> {
1326
+ return {
1327
+ op: "pty_spawn",
1328
+ key: route.key ?? "",
1329
+ eventKind: route.event,
1330
+ cols: route.cols ?? 80,
1331
+ rows: route.rows ?? 24,
1332
+ term: route.term ?? "",
1333
+ argv,
1334
+ };
1335
+ },
1336
+
1337
+ /// Write bytes toward the keyed session's child — keystrokes and
1338
+ /// pastes, fire-and-forget: a key with no open session no-ops (the
1339
+ /// exit was already on its way), and refused payloads (over the
1340
+ /// engine's per-write bound, or a child that stopped reading) count
1341
+ /// into the exit event's droppedWrites — never silence.
1342
+ ptyWrite(key: string, bytes: Uint8Array): Cmd<never> {
1343
+ return { op: "pty_write", key, bytes };
1344
+ },
1345
+
1346
+ /// Push a new grid to the keyed session so the child receives
1347
+ /// SIGWINCH — fire-and-forget like ptyWrite; a key with no open
1348
+ /// session no-ops.
1349
+ ptyResize(key: string, cols: number, rows: number): Cmd<never> {
1350
+ return { op: "pty_resize", key, cols, rows };
1351
+ },
1352
+
1353
+ /// Terminate the keyed session's child — LOUD, the spawn cancel
1354
+ /// discipline: the session's one "exit" terminal arrives through its
1355
+ /// own event arm with reason "cancelled" and the key frees once it
1356
+ /// lands. A key with no open session no-ops. Sessions are their own
1357
+ /// family's to end: the string-keyed `Cmd.cancel` never touches them
1358
+ /// — this is their kill, the way `Cmd.audioStop` is audio's close.
1359
+ ptyKill(key: string): Cmd<never> {
1360
+ return { op: "pty_kill", key };
1361
+ },
1362
+
884
1363
  /// Several commands from one dispatch, performed in order.
885
1364
  batch<M extends Msgish>(cmds: readonly Cmd<M>[]): Cmd<M> {
886
1365
  return { op: "batch", cmds };
package/sdk/events.ts CHANGED
@@ -40,15 +40,24 @@
40
40
  export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
41
41
 
42
42
  /// The scroll-state mirror markup's `on-scroll` matches structurally: a
43
- /// record of exactly these four numeric fields. Offsets and extents are
44
- /// canvas points; `velocity` is points per second while a fling decays.
45
- /// Echo `offset` into the model field bound as the scroll's `value` to
46
- /// keep the region model-driven (setting that field in update scrolls it).
43
+ /// record of exactly these eight numeric fields the TWO-AXIS shape, one
44
+ /// offset/velocity/viewport/content quartet per axis. Offsets and extents
45
+ /// are canvas points; velocities are points per second while a fling
46
+ /// decays. A vertical list carries live `...Y` fields and quiet `...X`
47
+ /// ones (offset 0, content pinned to the viewport width); a horizontal
48
+ /// shelf the reverse. Echo `offsetY` into the model field bound as the
49
+ /// scroll's `value` (and `offsetX` into `value-x` on horizontal-capable
50
+ /// regions) to keep the region model-driven — setting those fields in
51
+ /// update scrolls it.
47
52
  export interface ScrollState {
48
- readonly offset: number;
49
- readonly velocity: number;
50
- readonly viewportExtent: number;
51
- readonly contentExtent: number;
53
+ readonly offsetX: number;
54
+ readonly offsetY: number;
55
+ readonly velocityX: number;
56
+ readonly velocityY: number;
57
+ readonly viewportExtentX: number;
58
+ readonly viewportExtentY: number;
59
+ readonly contentExtentX: number;
60
+ readonly contentExtentY: number;
52
61
  }
53
62
 
54
63
  /// The presented-frame channel's record (`frameMsg(model, frame)`): the