@kangentic/protocol 0.14.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.
@@ -1,8 +1,37 @@
1
- import type { BridgeMessage } from './messages';
1
+ import type { BridgeMessage, CapabilityErrorCode } from './messages';
2
2
  export declare const MAX_FRAME_LENGTH: number;
3
3
  /** Upper bound on the JSON a compressed frame may declare/inflate to. */
4
4
  export declare const MAX_DECODED_LENGTH: number;
5
5
  /** JSON below this size ships raw - deflate overhead is not worth it. */
6
6
  export declare const COMPRESSION_THRESHOLD: number;
7
+ /** The `code` a desktop puts on its refusal of a verb its build does not know. */
8
+ export declare const UNSUPPORTED_VERB_ERROR_CODE: CapabilityErrorCode;
9
+ /**
10
+ * Thrown by `decodeMessage` for a capability-request whose envelope is
11
+ * well-formed (a string `requestId`, a string `verb`, a JSON `payload`) but
12
+ * whose verb this build's `CAPABILITY_VERBS` does not carry. It is the one
13
+ * decode failure that names its request, so the receiver can ANSWER it
14
+ * (`{ ok: false, code: 'unsupported-verb' }`) instead of dropping the frame.
15
+ *
16
+ * Without this a desktop that predates a verb sent nothing back at all, and
17
+ * the phone that sent it timed out, unable to tell an old desktop from an
18
+ * unreachable one. Every verb added to the protocol has that failure mode on
19
+ * a mixed-version pair. A frame that is NOT a well-formed request (a missing
20
+ * `requestId`, a non-string `verb`, a non-JSON `payload`) still throws a
21
+ * plain `Error` and stays a silent rejection: there is nothing to answer, and
22
+ * answering unstructured input would hand a probe to whoever sent it.
23
+ */
24
+ export declare class UnsupportedVerbError extends Error {
25
+ readonly requestId: string;
26
+ readonly verb: string;
27
+ constructor(requestId: string, verb: string);
28
+ }
29
+ /**
30
+ * Keyed on `name`, not `instanceof`: the desktop consumes this package from
31
+ * source through a workspace alias while the mobile app resolves the published
32
+ * `dist`, and a bundler can hold two copies of the class, which `instanceof`
33
+ * would tell apart.
34
+ */
35
+ export declare function isUnsupportedVerbError(error: unknown): error is UnsupportedVerbError;
7
36
  export declare function encodeMessage(message: BridgeMessage): Uint8Array;
8
37
  export declare function decodeMessage(bytes: Uint8Array): BridgeMessage;
@@ -30,6 +30,15 @@ export interface CapabilityRequestMessage {
30
30
  verb: CapabilityVerb;
31
31
  payload: JsonValue;
32
32
  }
33
+ /**
34
+ * A stable key a client can branch on, beside the human-readable `error`.
35
+ * Today the one member is the refusal a desktop sends for a verb its build
36
+ * does not know (see `UnsupportedVerbError` in framing.ts), so a newer phone
37
+ * can tell "desktop is old" from "desktop is unreachable" and show the right
38
+ * copy. A union rather than a bare string so a second code is a deliberate
39
+ * addition here, not a typo at a call site.
40
+ */
41
+ export type CapabilityErrorCode = 'unsupported-verb';
33
42
  export interface CapabilityResponseMessage {
34
43
  type: 'capability-response';
35
44
  requestId: string;
@@ -37,6 +46,12 @@ export interface CapabilityResponseMessage {
37
46
  payload?: JsonValue;
38
47
  /** Present only when ok is false. */
39
48
  error?: string;
49
+ /**
50
+ * Present only when ok is false AND the refusal has a stable key. Additive
51
+ * on the wire: a peer whose decoder predates the field rebuilds the message
52
+ * from the keys it knows and keeps `error`, so the text still shows.
53
+ */
54
+ code?: CapabilityErrorCode;
40
55
  }
41
56
  export interface EventMessage {
42
57
  type: 'event';
@@ -53,6 +53,8 @@ export interface ReadStreamRequestPayload {
53
53
  * snapshot races the session's teardown.
54
54
  */
55
55
  export type ReadStreamSessionStatusWire = 'running' | 'queued' | 'suspended' | 'exited';
56
+ /** True for a value that is one of the four session lifecycle statuses. */
57
+ export declare function isReadStreamSessionStatusWire(value: unknown): value is ReadStreamSessionStatusWire;
56
58
  /** Initial snapshot returned on subscribe; live updates arrive as TerminalEvent/ActivityEvent/TranscriptEvent. */
57
59
  export interface ReadStreamResponsePayload {
58
60
  scrollback: string;
@@ -80,9 +82,34 @@ export interface ReadStreamResponsePayload {
80
82
  ptyDimensions?: TerminalDimensionsWire;
81
83
  /**
82
84
  * The session's lifecycle status at snapshot time. Absent from
83
- * pre-0.5.0 desktops; the phone then assumes 'running'.
85
+ * pre-0.5.0 desktops; the phone then assumes 'running'. Later changes for
86
+ * the same session id arrive as the `status` activity payload.
84
87
  */
85
88
  sessionStatus?: ReadStreamSessionStatusWire;
89
+ /**
90
+ * True when the desktop spawned this session as a resume of an earlier
91
+ * conversation. The desktop card then reads "Resuming agent..." where it
92
+ * would read "Starting agent...", for as long as a running session has no
93
+ * model name yet. Fixed for the life of a session id. Absent from pre-0.16.0
94
+ * desktops; read absent as false.
95
+ */
96
+ resuming?: boolean;
97
+ /**
98
+ * The streamed session's copy of `BoardTaskWire.resumable`: true when this
99
+ * session is paused (`suspended`) and the desktop's own task view would
100
+ * offer Resume for it. Gate a Resume control on the BOARD ROW's field, not
101
+ * this one: a desktop pause clears the task's `session_id`, so a phone holds
102
+ * no stream on a paused session once its board refreshes. This copy keeps a
103
+ * session screen that is open during the suspend current until then, and it
104
+ * carries the same promise: `start-session` resumes rather than starts.
105
+ *
106
+ * Recomputed on each lifecycle edge of this session, and a `status` push
107
+ * carries it even when it is the only value that changed. A move to Done or
108
+ * an archive of a paused task is no edge of its row, so this copy can stay
109
+ * true after the board row's turns false. Absent from pre-0.16.0 desktops,
110
+ * which a client reads as "offer no Resume".
111
+ */
112
+ resumable?: boolean;
86
113
  }
87
114
  /** Phone-side narrowing of a read-stream subscribe response. Throws on a malformed required field. */
88
115
  export declare function parseReadStreamResponsePayload(payload: JsonValue): ReadStreamResponsePayload;
@@ -105,7 +132,10 @@ export declare function parseTranscriptWindowResponsePayload(payload: JsonValue)
105
132
  * - 'sessions': columns, per-column task counts, and only the non-archived
106
133
  * tasks that carry a `session_id`. This is what an agent feed renders: it
107
134
  * watches every project at once but only ever draws the handful of tasks
108
- * with a live agent on them.
135
+ * with a live agent on them. From 0.16.0 it also keeps a task with a
136
+ * `spawn_progress` label in flight (a first start, or a respawn that has
137
+ * nulled `session_id`) and a `resumable` paused task (a desktop pause
138
+ * nulls `session_id` too), so the feed can draw "Paused" with a Resume.
109
139
  * - 'full': every non-archived task, for the one project whose board the user
110
140
  * actually has open.
111
141
  *
@@ -195,7 +225,7 @@ export interface ReadBoardProjectListResponsePayload {
195
225
  export interface ReadBoardSnapshotResponsePayload {
196
226
  projectId: string;
197
227
  columns: BoardColumnWire[];
198
- /** Non-archived tasks; filtered to the ones carrying a `session_id` when `view` is 'sessions'. */
228
+ /** Non-archived tasks; filtered when `view` is 'sessions' (see ReadBoardView for which tasks it keeps). */
199
229
  tasks: BoardTaskWire[];
200
230
  /** Absent whenever the request carried a `view` (protocol 0.9.0) - no phone has ever rendered it. */
201
231
  backlog?: BacklogItemWire[];
@@ -278,6 +308,38 @@ export interface MoveTaskRequestPayload {
278
308
  export interface MoveTaskResponsePayload {
279
309
  ok: boolean;
280
310
  }
311
+ /**
312
+ * Keyed by task, not by session: the state this verb exists for is a task
313
+ * whose session has ENDED, so there is no live session id to name. The
314
+ * desktop spawns or resumes in the task's current column with that column's
315
+ * settings; a live session is an idempotent no-op.
316
+ */
317
+ export interface StartSessionRequestPayload {
318
+ taskId: string;
319
+ projectId: string;
320
+ }
321
+ /**
322
+ * What the desktop did with an accepted start. `starting`: the worktree and
323
+ * PTY work run behind this response and the successor's arrival reaches the
324
+ * phone as a board / stream event, the same way a column move's does. `live`:
325
+ * the task already had a running session, nothing was spawned, and NO event
326
+ * is coming, so a phone that tapped Start from a stale screen must refresh
327
+ * its board and stream itself rather than wait.
328
+ */
329
+ export type StartSessionOutcome = 'starting' | 'live';
330
+ /**
331
+ * `ok` means the start was ACCEPTED (the task exists, is in a column that may
332
+ * run an agent, and either has no live session or already has one), not that
333
+ * a successor is up: the desktop answers before the worktree and PTY work so
334
+ * the response fits the phone's per-verb budget. `outcome` says which of the
335
+ * two accepted shapes this was.
336
+ */
337
+ export interface StartSessionResponsePayload {
338
+ ok: boolean;
339
+ outcome: StartSessionOutcome;
340
+ }
341
+ /** Phone-side guard: narrows a decoded start-session response before the phone acts on it. */
342
+ export declare function parseStartSessionResponsePayload(payload: JsonValue): StartSessionResponsePayload;
281
343
  export interface AnswerPermissionPromptRequestPayload {
282
344
  sessionId: string;
283
345
  /** The prompt id the phone believes is outstanding - the handler rejects a stale/replayed answer whose id no longer matches the live awaited prompt. */
@@ -363,6 +425,7 @@ export interface CapabilityRequestPayloadMap {
363
425
  'board-tool-read': BoardToolRequestPayload;
364
426
  'board-tool-write': BoardToolRequestPayload;
365
427
  'register-push': RegisterPushRequestPayload;
428
+ 'start-session': StartSessionRequestPayload;
366
429
  }
367
430
  export interface CapabilityResponsePayloadMap {
368
431
  'read-stream': ReadStreamResponsePayload | TranscriptWindowResponsePayload;
@@ -375,6 +438,7 @@ export interface CapabilityResponsePayloadMap {
375
438
  'board-tool-read': BoardToolResponsePayload;
376
439
  'board-tool-write': BoardToolResponsePayload;
377
440
  'register-push': RegisterPushResponsePayload;
441
+ 'start-session': StartSessionResponsePayload;
378
442
  }
379
443
  /**
380
444
  * Validates and narrows a decoded capability-request's generic JsonValue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangentic/protocol",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Kangentic mobile bridge wire protocol: framing, Noise-based pairing and session handshakes, signed device roster, and capability verbs shared between desktop and mobile.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",