@kangentic/protocol 0.3.0 → 0.5.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.
@@ -9,7 +9,7 @@
9
9
  * wire a handler, and a device's roster entry still has to include it in
10
10
  * its CapabilitySet.
11
11
  */
12
- export declare const CAPABILITY_VERBS: readonly ["read-stream", "read-board", "read-diff", "send-user-message", "move-task", "answer-permission-prompt", "interactive-terminal", "board-tool-read", "board-tool-write"];
12
+ export declare const CAPABILITY_VERBS: readonly ["read-stream", "read-board", "read-diff", "send-user-message", "move-task", "answer-permission-prompt", "interactive-terminal", "board-tool-read", "board-tool-write", "register-push"];
13
13
  export type CapabilityVerb = (typeof CAPABILITY_VERBS)[number];
14
14
  export declare function isCapabilityVerb(value: string): value is CapabilityVerb;
15
15
  /** Deny-by-default: a verb absent from the set is not authorized. */
@@ -0,0 +1,38 @@
1
+ export declare const PUSH_CATEGORIES: readonly ["permission-needed", "agent-question", "turn-complete", "session-failed"];
2
+ export type PushCategory = (typeof PUSH_CATEGORIES)[number];
3
+ export declare function isPushCategory(value: unknown): value is PushCategory;
4
+ /** Reject an opened envelope whose sentAt is older than this (stale replay). */
5
+ export declare const PUSH_ENVELOPE_MAX_AGE_MS: number;
6
+ /** Tolerated forward clock skew; anything claiming to be further in the future is rejected. */
7
+ export declare const PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS: number;
8
+ /**
9
+ * The real notification content, visible only on the sealing desktop and
10
+ * the opening device. Everything the OS-visible push payload carries
11
+ * instead is a static placeholder.
12
+ */
13
+ export interface PushEnvelopePlaintext {
14
+ category: PushCategory;
15
+ projectId: string;
16
+ taskId: string;
17
+ sessionId: string;
18
+ taskTitle: string;
19
+ detail: string;
20
+ /** Wall-clock ms at seal time; openPushEnvelope enforces the freshness window from it. */
21
+ sentAt: number;
22
+ }
23
+ /** Narrows a decrypted envelope's JSON to PushEnvelopePlaintext. Throws on any malformed field. */
24
+ export declare function parsePushEnvelopePlaintext(value: unknown): PushEnvelopePlaintext;
25
+ /**
26
+ * Seals a notification into an opaque base64url blob:
27
+ * base64url(nonce[24] || XChaCha20-Poly1305(pushKey, nonce, aad=recipientStaticPublicKey, JSON(plaintext))).
28
+ */
29
+ export declare function sealPushEnvelope(pushKey: Uint8Array, recipientStaticPublicKey: Uint8Array, plaintext: PushEnvelopePlaintext): string;
30
+ /**
31
+ * Opens a sealed push blob. Throws on: a malformed blob, a tampered
32
+ * ciphertext, the wrong key, the wrong recipient key (AAD mismatch),
33
+ * non-JSON or malformed plaintext, an unknown category, or a sentAt
34
+ * outside the freshness window (older than 24h, or more than 5min in the
35
+ * future). The caller (the device-side notification handler) catches and
36
+ * shows the generic placeholder.
37
+ */
38
+ export declare function openPushEnvelope(pushKey: Uint8Array, recipientStaticPublicKey: Uint8Array, blob: string): PushEnvelopePlaintext;
@@ -2,7 +2,7 @@
2
2
  * The event contract the phone consumes, pushed as EventMessage.event over
3
3
  * an established BridgeSession once Phase 2's capability handlers (desktop
4
4
  * bridge module) subscribe SessionManager/transcript-service/DiffService to
5
- * a session's live state. Five kinds:
5
+ * a session's live state. Six kinds:
6
6
  *
7
7
  * - transcript: a revision-gated delta from resolveTaskTranscript, for the
8
8
  * phone's transcript-styled conversation view.
@@ -13,6 +13,9 @@
13
13
  * - terminal: raw PTY output from the unfiltered data-tap, for the phone's
14
14
  * raw-terminal-mirror view (a distinct consumer from the parsed
15
15
  * transcript).
16
+ * - terminal-resize: the PTY grid changed (desktop pane refit, phone fit
17
+ * request, or restore-on-release); the phone re-sizes its renderer to
18
+ * match before the TUI's repaint bytes arrive on the terminal stream.
16
19
  * - board: a board-mutation notification, filterable by project and
17
20
  * (optionally) task.
18
21
  * - diff: a payload-less "something under this task's worktree changed,
@@ -24,7 +27,7 @@
24
27
  * event before its router dispatches on `kind`.
25
28
  */
26
29
  import type { JsonValue } from '../wire/messages';
27
- import { type ActivityReasonWire, type ActivityStateWire, type SessionEventWire, type SessionUsageWire, type TranscriptEventPayload } from './payloads';
30
+ import { type ActivityReasonWire, type ActivityStateWire, type SessionEventWire, type SessionUsageWire, type TerminalDimensionsWire, type TranscriptEventPayload } from './payloads';
28
31
  export interface TranscriptEvent {
29
32
  kind: 'transcript';
30
33
  sessionId: string;
@@ -46,6 +49,17 @@ export type ActivityEventPayload = {
46
49
  type: 'permission';
47
50
  promptId: string;
48
51
  pending: boolean;
52
+ }
53
+ /**
54
+ * The streamed session's PTY exited. Pushed once by the desktop's
55
+ * read-stream subscription right before it tears itself down, so the
56
+ * phone learns "this session is over" from the feed itself instead of
57
+ * inferring it from silence. `intentional` distinguishes a deliberate
58
+ * stop (desktop Stop button, suspend, shutdown) from a crash.
59
+ */
60
+ | {
61
+ type: 'session-ended';
62
+ intentional: boolean;
49
63
  };
50
64
  export interface ActivityEvent {
51
65
  kind: 'activity';
@@ -61,6 +75,13 @@ export interface TerminalEvent {
61
75
  data: string;
62
76
  };
63
77
  }
78
+ export interface TerminalResizeEvent {
79
+ kind: 'terminal-resize';
80
+ sessionId: string;
81
+ taskId: string;
82
+ /** The PTY's new grid; every terminal byte sent after this event is laid out for it. */
83
+ payload: TerminalDimensionsWire;
84
+ }
64
85
  export interface BoardEventPayload {
65
86
  change: 'task-created' | 'task-updated' | 'task-deleted' | 'swimlane-updated' | 'backlog-changed';
66
87
  ids: string[];
@@ -76,7 +97,7 @@ export interface DiffEvent {
76
97
  taskId: string;
77
98
  payload: null;
78
99
  }
79
- export type BridgeEvent = TranscriptEvent | ActivityEvent | TerminalEvent | BoardEvent | DiffEvent;
100
+ export type BridgeEvent = TranscriptEvent | ActivityEvent | TerminalEvent | TerminalResizeEvent | BoardEvent | DiffEvent;
80
101
  /**
81
102
  * Narrows an activity event's payload to its typed union. Throws on a
82
103
  * malformed required field so the caller can drop the event cleanly.
@@ -102,6 +102,17 @@ export type TranscriptEventPayload = {
102
102
  revision: number;
103
103
  totalEntries: number;
104
104
  };
105
+ /**
106
+ * PTY grid dimensions, shared by the read-stream snapshot (`ptyDimensions`),
107
+ * the `terminal-resize` event, and the interactive-terminal `resize` action.
108
+ * The bytes on the terminal stream are laid out for exactly this grid; a
109
+ * renderer that uses any other geometry misplaces every cursor-addressed
110
+ * frame the fullscreen TUI draws.
111
+ */
112
+ export interface TerminalDimensionsWire {
113
+ cols: number;
114
+ rows: number;
115
+ }
105
116
  /** Mirrors the desktop's ActivityState. 'permission' means the agent paused for user approval (incl. AskUserQuestion / ExitPlanMode pauses). */
106
117
  export type ActivityStateWire = 'thinking' | 'idle' | 'permission';
107
118
  /** Mirrors the desktop's ActivityReason discriminated union. */
@@ -236,6 +247,12 @@ export interface DiffFileContentWire {
236
247
  export declare function parseTranscriptEntriesWire(payload: JsonValue): TranscriptEntryWire[];
237
248
  /** Narrows a transcript event's delta/reset payload. Throws on a malformed required field so the caller can drop the push. */
238
249
  export declare function parseTranscriptEventPayload(payload: JsonValue): TranscriptEventPayload;
250
+ /**
251
+ * Narrows a PTY dimensions payload. Bounds are sanity caps, not policy:
252
+ * the desktop's own resize clamp (cols >= 2, rows >= 1) is the floor, and
253
+ * 1000 on each axis rejects garbage without constraining any real device.
254
+ */
255
+ export declare function parseTerminalDimensionsWire(payload: JsonValue): TerminalDimensionsWire;
239
256
  /** True for a well-formed ActivityStateWire value. */
240
257
  export declare function isActivityStateWire(value: unknown): value is ActivityStateWire;
241
258
  /** True for a well-formed ActivityReasonWire value. */
package/dist/index.cjs CHANGED
@@ -34,6 +34,9 @@ __export(index_exports, {
34
34
  MAX_FRAME_LENGTH: () => MAX_FRAME_LENGTH,
35
35
  PAIRING_URI_SCHEME: () => PAIRING_URI_SCHEME,
36
36
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
37
+ PUSH_CATEGORIES: () => PUSH_CATEGORIES,
38
+ PUSH_ENVELOPE_MAX_AGE_MS: () => PUSH_ENVELOPE_MAX_AGE_MS,
39
+ PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS: () => PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS,
37
40
  SecretstreamState: () => SecretstreamState,
38
41
  SessionFrameKind: () => SessionFrameKind,
39
42
  X25519_KEY_LENGTH: () => X25519_KEY_LENGTH,
@@ -64,8 +67,10 @@ __export(index_exports, {
64
67
  isBridgeEvent: () => isBridgeEvent,
65
68
  isCapabilityVerb: () => isCapabilityVerb,
66
69
  isJsonValue: () => isJsonValue,
70
+ isPushCategory: () => isPushCategory,
67
71
  isRecord: () => isRecord,
68
72
  isRosterEntryExpired: () => isRosterEntryExpired,
73
+ openPushEnvelope: () => openPushEnvelope,
69
74
  parseActivityEventPayload: () => parseActivityEventPayload,
70
75
  parseBacklogItemWire: () => parseBacklogItemWire,
71
76
  parseBoardColumnWire: () => parseBoardColumnWire,
@@ -73,16 +78,20 @@ __export(index_exports, {
73
78
  parseCapabilityRequestPayload: () => parseCapabilityRequestPayload,
74
79
  parseDiffFileContentWire: () => parseDiffFileContentWire,
75
80
  parseDiffFileListWire: () => parseDiffFileListWire,
81
+ parsePushEnvelopePlaintext: () => parsePushEnvelopePlaintext,
76
82
  parseReadBoardResponsePayload: () => parseReadBoardResponsePayload,
77
83
  parseReadDiffResponsePayload: () => parseReadDiffResponsePayload,
78
84
  parseReadStreamResponsePayload: () => parseReadStreamResponsePayload,
85
+ parseRegisterPushRequestPayload: () => parseRegisterPushRequestPayload,
79
86
  parseSessionEventWire: () => parseSessionEventWire,
80
87
  parseSessionUsageWire: () => parseSessionUsageWire,
88
+ parseTerminalDimensionsWire: () => parseTerminalDimensionsWire,
81
89
  parseTranscriptEntriesWire: () => parseTranscriptEntriesWire,
82
90
  parseTranscriptEventPayload: () => parseTranscriptEventPayload,
83
91
  parseTranscriptWindowResponsePayload: () => parseTranscriptWindowResponsePayload,
84
92
  randomBytes: () => randomBytes4,
85
93
  rosterDeviceCapabilitySet: () => rosterDeviceCapabilitySet,
94
+ sealPushEnvelope: () => sealPushEnvelope,
86
95
  signRosterEntry: () => signRosterEntry,
87
96
  unwrapSessionFrame: () => unwrapSessionFrame,
88
97
  verifyRosterEntry: () => verifyRosterEntry,
@@ -4975,7 +4984,8 @@ var CAPABILITY_VERBS = [
4975
4984
  "answer-permission-prompt",
4976
4985
  "interactive-terminal",
4977
4986
  "board-tool-read",
4978
- "board-tool-write"
4987
+ "board-tool-write",
4988
+ "register-push"
4979
4989
  ];
4980
4990
  function isCapabilityVerb(value) {
4981
4991
  return CAPABILITY_VERBS.includes(value);
@@ -5061,7 +5071,8 @@ function validateEvent(event) {
5061
5071
  switch (event.kind) {
5062
5072
  case "transcript":
5063
5073
  case "activity":
5064
- case "terminal": {
5074
+ case "terminal":
5075
+ case "terminal-resize": {
5065
5076
  if (typeof event.sessionId !== "string") throw new Error(`"${event.kind}" event is missing "sessionId"`);
5066
5077
  if (typeof event.taskId !== "string") throw new Error(`"${event.kind}" event is missing "taskId"`);
5067
5078
  return event;
@@ -5134,6 +5145,47 @@ function unwrapSessionFrame(frame) {
5134
5145
  return { kind, payload: frame.subarray(1) };
5135
5146
  }
5136
5147
 
5148
+ // src/wire/base64url.ts
5149
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5150
+ var DECODE_MAP = {};
5151
+ for (let i = 0; i < ALPHABET.length; i++) DECODE_MAP[ALPHABET[i]] = i;
5152
+ function base64UrlEncode(bytes) {
5153
+ let result = "";
5154
+ let i = 0;
5155
+ for (; i + 2 < bytes.length; i += 3) {
5156
+ const chunk = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
5157
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63] + ALPHABET[chunk & 63];
5158
+ }
5159
+ const remaining = bytes.length - i;
5160
+ if (remaining === 1) {
5161
+ const chunk = bytes[i] << 16;
5162
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63];
5163
+ } else if (remaining === 2) {
5164
+ const chunk = bytes[i] << 16 | bytes[i + 1] << 8;
5165
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63];
5166
+ }
5167
+ return result;
5168
+ }
5169
+ function base64UrlDecode(encoded) {
5170
+ const byteLength = Math.floor(encoded.length * 6 / 8);
5171
+ const bytes = new Uint8Array(byteLength);
5172
+ let bitBuffer = 0;
5173
+ let bitCount = 0;
5174
+ let outputIndex = 0;
5175
+ for (const char of encoded) {
5176
+ const value = DECODE_MAP[char];
5177
+ if (value === void 0) throw new Error(`Invalid base64url character: "${char}"`);
5178
+ bitBuffer = bitBuffer << 6 | value;
5179
+ bitCount += 6;
5180
+ if (bitCount >= 8) {
5181
+ bitCount -= 8;
5182
+ bytes[outputIndex] = bitBuffer >> bitCount & 255;
5183
+ outputIndex += 1;
5184
+ }
5185
+ }
5186
+ return bytes;
5187
+ }
5188
+
5137
5189
  // src/events/payloads.ts
5138
5190
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5139
5191
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
@@ -5199,6 +5251,14 @@ function parseTranscriptEventPayload(payload) {
5199
5251
  });
5200
5252
  return { mode: "delta", revision, totalEntries, upserts };
5201
5253
  }
5254
+ function parseTerminalDimensionsWire(payload) {
5255
+ if (!isRecord(payload)) throw new Error("terminal dimensions must be an object");
5256
+ const cols = requireNumber(payload, "cols", "terminal dimensions");
5257
+ const rows = requireNumber(payload, "rows", "terminal dimensions");
5258
+ if (!Number.isInteger(cols) || cols < 2 || cols > 1e3) throw new Error('terminal dimensions have an invalid "cols"');
5259
+ if (!Number.isInteger(rows) || rows < 1 || rows > 1e3) throw new Error('terminal dimensions have an invalid "rows"');
5260
+ return { cols, rows };
5261
+ }
5202
5262
  function isActivityStateWire(value) {
5203
5263
  return value === "thinking" || value === "idle" || value === "permission";
5204
5264
  }
@@ -5359,6 +5419,7 @@ function parseDiffFileContentWire(payload) {
5359
5419
  }
5360
5420
 
5361
5421
  // src/wire/payloads.ts
5422
+ var READ_STREAM_SESSION_STATUSES = ["running", "queued", "suspended", "exited"];
5362
5423
  function parseReadStreamResponsePayload(payload) {
5363
5424
  if (!isRecord(payload)) throw new Error("read-stream response must be an object");
5364
5425
  if (typeof payload.scrollback !== "string") throw new Error('read-stream response is missing "scrollback"');
@@ -5370,12 +5431,22 @@ function parseReadStreamResponsePayload(payload) {
5370
5431
  if (payload.awaitedPromptId !== null && typeof payload.awaitedPromptId !== "string") {
5371
5432
  throw new Error('read-stream response has an invalid "awaitedPromptId"');
5372
5433
  }
5373
- return {
5434
+ const response = {
5374
5435
  scrollback: payload.scrollback,
5375
5436
  activity: { state: state ?? null, reason: reason ?? null },
5376
5437
  usage: payload.usage === null || payload.usage === void 0 ? null : parseSessionUsageWire(payload.usage),
5377
5438
  awaitedPromptId: payload.awaitedPromptId ?? null
5378
5439
  };
5440
+ if (payload.ptyDimensions !== void 0) {
5441
+ response.ptyDimensions = parseTerminalDimensionsWire(payload.ptyDimensions);
5442
+ }
5443
+ if (payload.sessionStatus !== void 0) {
5444
+ if (typeof payload.sessionStatus !== "string" || !READ_STREAM_SESSION_STATUSES.includes(payload.sessionStatus)) {
5445
+ throw new Error('read-stream response has an invalid "sessionStatus"');
5446
+ }
5447
+ response.sessionStatus = payload.sessionStatus;
5448
+ }
5449
+ return response;
5379
5450
  }
5380
5451
  function parseTranscriptWindowResponsePayload(payload) {
5381
5452
  if (!isRecord(payload)) throw new Error("transcript-window response must be an object");
@@ -5417,6 +5488,13 @@ function parseReadStreamRequestPayload(payload) {
5417
5488
  }
5418
5489
  return request;
5419
5490
  }
5491
+ var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
5492
+ function parseAccentColor(value, context) {
5493
+ if (typeof value !== "string" || !ACCENT_COLOR_PATTERN.test(value)) {
5494
+ throw new Error(`${context} has an invalid accent color (expected "#rrggbb")`);
5495
+ }
5496
+ return value;
5497
+ }
5420
5498
  function parseReadBoardResponsePayload(payload) {
5421
5499
  if (!isRecord(payload)) throw new Error("read-board response must be an object");
5422
5500
  if (Array.isArray(payload.projects)) {
@@ -5424,7 +5502,11 @@ function parseReadBoardResponsePayload(payload) {
5424
5502
  if (!isRecord(project) || typeof project.id !== "string" || typeof project.name !== "string") {
5425
5503
  throw new Error(`read-board project ${index} is malformed`);
5426
5504
  }
5427
- return { id: project.id, name: project.name };
5505
+ return {
5506
+ id: project.id,
5507
+ name: project.name,
5508
+ ...project.color !== void 0 ? { color: parseAccentColor(project.color, `read-board project ${index}`) } : {}
5509
+ };
5428
5510
  });
5429
5511
  return { projects };
5430
5512
  }
@@ -5436,7 +5518,8 @@ function parseReadBoardResponsePayload(payload) {
5436
5518
  projectId: payload.projectId,
5437
5519
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5438
5520
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5439
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item))
5521
+ backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5522
+ ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {}
5440
5523
  };
5441
5524
  }
5442
5525
  function parseReadBoardRequestPayload(payload) {
@@ -5503,8 +5586,21 @@ function parseAnswerPermissionPromptRequestPayload(payload) {
5503
5586
  function parseInteractiveTerminalRequestPayload(payload) {
5504
5587
  if (!isRecord(payload)) throw new Error("interactive-terminal payload must be an object");
5505
5588
  if (typeof payload.sessionId !== "string") throw new Error('interactive-terminal payload missing "sessionId"');
5506
- if (typeof payload.data !== "string") throw new Error('interactive-terminal payload missing "data"');
5507
- return { sessionId: payload.sessionId, data: payload.data };
5589
+ if (payload.action === void 0 || payload.action === "write") {
5590
+ if (typeof payload.data !== "string") throw new Error('interactive-terminal payload missing "data"');
5591
+ return { sessionId: payload.sessionId, action: "write", data: payload.data };
5592
+ }
5593
+ if (payload.action === "resize") {
5594
+ return {
5595
+ sessionId: payload.sessionId,
5596
+ action: "resize",
5597
+ dimensions: parseTerminalDimensionsWire(payload.dimensions)
5598
+ };
5599
+ }
5600
+ if (payload.action === "release-size") {
5601
+ return { sessionId: payload.sessionId, action: "release-size" };
5602
+ }
5603
+ throw new Error('interactive-terminal payload has an invalid "action"');
5508
5604
  }
5509
5605
  function parseBoardToolRequestPayload(payload) {
5510
5606
  if (!isRecord(payload)) throw new Error("board-tool payload must be an object");
@@ -5512,6 +5608,42 @@ function parseBoardToolRequestPayload(payload) {
5512
5608
  if (payload.params === void 0 || !isJsonValue(payload.params)) throw new Error('board-tool payload missing a JSON "params"');
5513
5609
  return { tool: payload.tool, params: payload.params };
5514
5610
  }
5611
+ var PUSH_KEY_LENGTH = 32;
5612
+ function parseRegisterPushRequestPayload(payload) {
5613
+ if (!isRecord(payload)) throw new Error("register-push payload must be an object");
5614
+ if (payload.action !== "register" && payload.action !== "unregister") {
5615
+ throw new Error('register-push payload has an invalid "action"');
5616
+ }
5617
+ const request = { action: payload.action };
5618
+ if (payload.expoPushToken !== void 0) {
5619
+ if (typeof payload.expoPushToken !== "string") throw new Error('register-push payload has a non-string "expoPushToken"');
5620
+ request.expoPushToken = payload.expoPushToken;
5621
+ }
5622
+ if (payload.pushKeyBase64 !== void 0) {
5623
+ if (typeof payload.pushKeyBase64 !== "string") throw new Error('register-push payload has a non-string "pushKeyBase64"');
5624
+ let decodedKey;
5625
+ try {
5626
+ decodedKey = base64UrlDecode(payload.pushKeyBase64);
5627
+ } catch {
5628
+ throw new Error('register-push payload has a malformed "pushKeyBase64"');
5629
+ }
5630
+ if (decodedKey.length !== PUSH_KEY_LENGTH) {
5631
+ throw new Error(`register-push payload "pushKeyBase64" must decode to ${PUSH_KEY_LENGTH} bytes`);
5632
+ }
5633
+ request.pushKeyBase64 = payload.pushKeyBase64;
5634
+ }
5635
+ if (payload.platform !== void 0) {
5636
+ if (payload.platform !== "android" && payload.platform !== "ios") {
5637
+ throw new Error('register-push payload has an invalid "platform"');
5638
+ }
5639
+ request.platform = payload.platform;
5640
+ }
5641
+ if (request.action === "register") {
5642
+ if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
5643
+ if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
5644
+ }
5645
+ return request;
5646
+ }
5515
5647
  function parseCapabilityRequestPayload(verb, payload) {
5516
5648
  switch (verb) {
5517
5649
  case "read-stream":
@@ -5531,6 +5663,8 @@ function parseCapabilityRequestPayload(verb, payload) {
5531
5663
  case "board-tool-read":
5532
5664
  case "board-tool-write":
5533
5665
  return parseBoardToolRequestPayload(payload);
5666
+ case "register-push":
5667
+ return parseRegisterPushRequestPayload(payload);
5534
5668
  default: {
5535
5669
  const exhaustiveCheck = verb;
5536
5670
  throw new Error(`Unknown capability verb: ${String(exhaustiveCheck)}`);
@@ -5538,45 +5672,54 @@ function parseCapabilityRequestPayload(verb, payload) {
5538
5672
  }
5539
5673
  }
5540
5674
 
5541
- // src/wire/base64url.ts
5542
- var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5543
- var DECODE_MAP = {};
5544
- for (let i = 0; i < ALPHABET.length; i++) DECODE_MAP[ALPHABET[i]] = i;
5545
- function base64UrlEncode(bytes) {
5546
- let result = "";
5547
- let i = 0;
5548
- for (; i + 2 < bytes.length; i += 3) {
5549
- const chunk = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
5550
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63] + ALPHABET[chunk & 63];
5551
- }
5552
- const remaining = bytes.length - i;
5553
- if (remaining === 1) {
5554
- const chunk = bytes[i] << 16;
5555
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63];
5556
- } else if (remaining === 2) {
5557
- const chunk = bytes[i] << 16 | bytes[i + 1] << 8;
5558
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63];
5559
- }
5560
- return result;
5675
+ // src/crypto/push-envelope.ts
5676
+ var PUSH_CATEGORIES = ["permission-needed", "agent-question", "turn-complete", "session-failed"];
5677
+ function isPushCategory(value) {
5678
+ return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5679
+ }
5680
+ var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5681
+ var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5682
+ function requireEnvelopeString(record, field) {
5683
+ const value = record[field];
5684
+ if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5685
+ return value;
5561
5686
  }
5562
- function base64UrlDecode(encoded) {
5563
- const byteLength = Math.floor(encoded.length * 6 / 8);
5564
- const bytes = new Uint8Array(byteLength);
5565
- let bitBuffer = 0;
5566
- let bitCount = 0;
5567
- let outputIndex = 0;
5568
- for (const char of encoded) {
5569
- const value = DECODE_MAP[char];
5570
- if (value === void 0) throw new Error(`Invalid base64url character: "${char}"`);
5571
- bitBuffer = bitBuffer << 6 | value;
5572
- bitCount += 6;
5573
- if (bitCount >= 8) {
5574
- bitCount -= 8;
5575
- bytes[outputIndex] = bitBuffer >> bitCount & 255;
5576
- outputIndex += 1;
5577
- }
5687
+ function parsePushEnvelopePlaintext(value) {
5688
+ if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5689
+ if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5690
+ if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5691
+ throw new Error('push envelope plaintext has an invalid "sentAt"');
5578
5692
  }
5579
- return bytes;
5693
+ return {
5694
+ category: value.category,
5695
+ projectId: requireEnvelopeString(value, "projectId"),
5696
+ taskId: requireEnvelopeString(value, "taskId"),
5697
+ sessionId: requireEnvelopeString(value, "sessionId"),
5698
+ taskTitle: requireEnvelopeString(value, "taskTitle"),
5699
+ detail: requireEnvelopeString(value, "detail"),
5700
+ sentAt: value.sentAt
5701
+ };
5702
+ }
5703
+ function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5704
+ const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5705
+ const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5706
+ const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5707
+ return base64UrlEncode(concatBytes4(nonce, ciphertext));
5708
+ }
5709
+ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5710
+ const bytes = base64UrlDecode(blob);
5711
+ if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5712
+ throw new Error("push envelope blob is too short");
5713
+ }
5714
+ const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5715
+ const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5716
+ const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5717
+ const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5718
+ const plaintext = parsePushEnvelopePlaintext(decoded);
5719
+ const now = Date.now();
5720
+ if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5721
+ if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5722
+ return plaintext;
5580
5723
  }
5581
5724
 
5582
5725
  // src/pairing/qr.ts
@@ -5754,6 +5897,10 @@ function parseActivityEventPayload(payload) {
5754
5897
  if (typeof payload.pending !== "boolean") throw new Error('permission payload is missing "pending"');
5755
5898
  return { type: "permission", promptId: payload.promptId, pending: payload.pending };
5756
5899
  }
5900
+ case "session-ended": {
5901
+ if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5902
+ return { type: "session-ended", intentional: payload.intentional };
5903
+ }
5757
5904
  default:
5758
5905
  throw new Error('activity payload has an unknown "type"');
5759
5906
  }
@@ -5783,6 +5930,15 @@ function isBridgeEvent(value) {
5783
5930
  if (typeof value.sessionId !== "string" || typeof value.taskId !== "string") return false;
5784
5931
  return isRecord(value.payload) && typeof value.payload.data === "string";
5785
5932
  }
5933
+ case "terminal-resize": {
5934
+ if (typeof value.sessionId !== "string" || typeof value.taskId !== "string") return false;
5935
+ try {
5936
+ parseTerminalDimensionsWire(value.payload);
5937
+ return true;
5938
+ } catch {
5939
+ return false;
5940
+ }
5941
+ }
5786
5942
  case "board": {
5787
5943
  if (typeof value.projectId !== "string") return false;
5788
5944
  if (value.taskId !== void 0 && typeof value.taskId !== "string") return false;
@@ -5813,6 +5969,9 @@ function isBridgeEvent(value) {
5813
5969
  MAX_FRAME_LENGTH,
5814
5970
  PAIRING_URI_SCHEME,
5815
5971
  PROTOCOL_VERSION,
5972
+ PUSH_CATEGORIES,
5973
+ PUSH_ENVELOPE_MAX_AGE_MS,
5974
+ PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS,
5816
5975
  SecretstreamState,
5817
5976
  SessionFrameKind,
5818
5977
  X25519_KEY_LENGTH,
@@ -5843,8 +6002,10 @@ function isBridgeEvent(value) {
5843
6002
  isBridgeEvent,
5844
6003
  isCapabilityVerb,
5845
6004
  isJsonValue,
6005
+ isPushCategory,
5846
6006
  isRecord,
5847
6007
  isRosterEntryExpired,
6008
+ openPushEnvelope,
5848
6009
  parseActivityEventPayload,
5849
6010
  parseBacklogItemWire,
5850
6011
  parseBoardColumnWire,
@@ -5852,16 +6013,20 @@ function isBridgeEvent(value) {
5852
6013
  parseCapabilityRequestPayload,
5853
6014
  parseDiffFileContentWire,
5854
6015
  parseDiffFileListWire,
6016
+ parsePushEnvelopePlaintext,
5855
6017
  parseReadBoardResponsePayload,
5856
6018
  parseReadDiffResponsePayload,
5857
6019
  parseReadStreamResponsePayload,
6020
+ parseRegisterPushRequestPayload,
5858
6021
  parseSessionEventWire,
5859
6022
  parseSessionUsageWire,
6023
+ parseTerminalDimensionsWire,
5860
6024
  parseTranscriptEntriesWire,
5861
6025
  parseTranscriptEventPayload,
5862
6026
  parseTranscriptWindowResponsePayload,
5863
6027
  randomBytes,
5864
6028
  rosterDeviceCapabilitySet,
6029
+ sealPushEnvelope,
5865
6030
  signRosterEntry,
5866
6031
  unwrapSessionFrame,
5867
6032
  verifyRosterEntry,
package/dist/index.d.ts CHANGED
@@ -12,11 +12,12 @@ export type { JsonValue, BridgeMessage, HeartbeatMessage, CapabilityRequestMessa
12
12
  export { encodeMessage, decodeMessage, MAX_FRAME_LENGTH, MAX_DECODED_LENGTH, COMPRESSION_THRESHOLD } from './wire/framing';
13
13
  export { isJsonValue, isRecord } from './wire/json-value';
14
14
  export { SessionFrameKind, wrapSessionFrame, unwrapSessionFrame } from './wire/session-frame';
15
- export { parseCapabilityRequestPayload, parseReadStreamResponsePayload, parseReadBoardResponsePayload, parseReadDiffResponsePayload, parseTranscriptWindowResponsePayload, type CapabilityRequestPayloadMap, type CapabilityResponsePayloadMap, type ReadStreamRequestPayload, type ReadStreamResponsePayload, type TranscriptWindowResponsePayload, type ReadBoardRequestPayload, type ReadBoardProjectSummary, type ReadBoardProjectListResponsePayload, type ReadBoardSnapshotResponsePayload, type ReadBoardResponsePayload, type ReadDiffScope, type ReadDiffRequestPayload, type ReadDiffResponsePayload, type SendUserMessageRequestPayload, type SendUserMessageResponsePayload, type MoveTaskRequestPayload, type MoveTaskResponsePayload, type AnswerPermissionPromptRequestPayload, type AnswerPermissionPromptResponsePayload, type InteractiveTerminalRequestPayload, type InteractiveTerminalResponsePayload, type BoardToolRequestPayload, type BoardToolResponsePayload, } from './wire/payloads';
15
+ export { parseCapabilityRequestPayload, parseReadStreamResponsePayload, parseReadBoardResponsePayload, parseReadDiffResponsePayload, parseTranscriptWindowResponsePayload, parseRegisterPushRequestPayload, type CapabilityRequestPayloadMap, type CapabilityResponsePayloadMap, type ReadStreamRequestPayload, type ReadStreamResponsePayload, type ReadStreamSessionStatusWire, type TranscriptWindowResponsePayload, type ReadBoardRequestPayload, type ReadBoardProjectSummary, type ReadBoardProjectListResponsePayload, type ReadBoardSnapshotResponsePayload, type ReadBoardResponsePayload, type ReadDiffScope, type ReadDiffRequestPayload, type ReadDiffResponsePayload, type SendUserMessageRequestPayload, type SendUserMessageResponsePayload, type MoveTaskRequestPayload, type MoveTaskResponsePayload, type AnswerPermissionPromptRequestPayload, type AnswerPermissionPromptResponsePayload, type InteractiveTerminalRequestPayload, type InteractiveTerminalResponsePayload, type BoardToolRequestPayload, type BoardToolResponsePayload, type RegisterPushRequestPayload, type RegisterPushResponsePayload, } from './wire/payloads';
16
+ export { PUSH_CATEGORIES, isPushCategory, parsePushEnvelopePlaintext, sealPushEnvelope, openPushEnvelope, PUSH_ENVELOPE_MAX_AGE_MS, PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS, type PushCategory, type PushEnvelopePlaintext, } from './crypto/push-envelope';
16
17
  export { PAIRING_URI_SCHEME, encodePairingQrPayload, decodePairingQrPayload, type PairingQrPayload, } from './pairing/qr';
17
18
  export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
18
19
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
19
20
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
20
- export { isBridgeEvent, parseActivityEventPayload, type BridgeEvent, type TranscriptEvent, type ActivityEvent, type ActivityEventPayload, type TerminalEvent, type BoardEvent, type BoardEventPayload, type DiffEvent, } from './events/event';
21
- export { parseTranscriptEntriesWire, parseTranscriptEventPayload, type TranscriptEventPayload, type TranscriptUpsertWire, isActivityStateWire, isActivityReasonWire, parseSessionUsageWire, parseSessionEventWire, parseBoardColumnWire, parseBoardTaskWire, parseBacklogItemWire, parseDiffFileListWire, parseDiffFileContentWire, type TranscriptBlockWire, type TranscriptEntryWire, type TranscriptTurnUsageWire, type TranscriptSystemSubtypeWire, type ActivityStateWire, type ActivityReasonWire, type SessionUsageWire, type SessionEventWire, type BoardColumnWire, type BoardTaskWire, type BacklogItemWire, type DiffFileStatusWire, type DiffFileWire, type DiffFileListWire, type DiffFileContentWire, } from './events/payloads';
21
+ export { isBridgeEvent, parseActivityEventPayload, type BridgeEvent, type TranscriptEvent, type ActivityEvent, type ActivityEventPayload, type TerminalEvent, type TerminalResizeEvent, type BoardEvent, type BoardEventPayload, type DiffEvent, } from './events/event';
22
+ export { parseTranscriptEntriesWire, parseTranscriptEventPayload, type TranscriptEventPayload, type TranscriptUpsertWire, isActivityStateWire, isActivityReasonWire, parseSessionUsageWire, parseSessionEventWire, parseTerminalDimensionsWire, parseBoardColumnWire, parseBoardTaskWire, parseBacklogItemWire, parseDiffFileListWire, parseDiffFileContentWire, type TranscriptBlockWire, type TranscriptEntryWire, type TranscriptTurnUsageWire, type TranscriptSystemSubtypeWire, type ActivityStateWire, type ActivityReasonWire, type SessionUsageWire, type SessionEventWire, type TerminalDimensionsWire, type BoardColumnWire, type BoardTaskWire, type BacklogItemWire, type DiffFileStatusWire, type DiffFileWire, type DiffFileListWire, type DiffFileContentWire, } from './events/payloads';
22
23
  export type { Transport, TransportState, Unsubscribe } from './transport/transport';
package/dist/index.mjs CHANGED
@@ -4873,7 +4873,8 @@ var CAPABILITY_VERBS = [
4873
4873
  "answer-permission-prompt",
4874
4874
  "interactive-terminal",
4875
4875
  "board-tool-read",
4876
- "board-tool-write"
4876
+ "board-tool-write",
4877
+ "register-push"
4877
4878
  ];
4878
4879
  function isCapabilityVerb(value) {
4879
4880
  return CAPABILITY_VERBS.includes(value);
@@ -4959,7 +4960,8 @@ function validateEvent(event) {
4959
4960
  switch (event.kind) {
4960
4961
  case "transcript":
4961
4962
  case "activity":
4962
- case "terminal": {
4963
+ case "terminal":
4964
+ case "terminal-resize": {
4963
4965
  if (typeof event.sessionId !== "string") throw new Error(`"${event.kind}" event is missing "sessionId"`);
4964
4966
  if (typeof event.taskId !== "string") throw new Error(`"${event.kind}" event is missing "taskId"`);
4965
4967
  return event;
@@ -5032,6 +5034,47 @@ function unwrapSessionFrame(frame) {
5032
5034
  return { kind, payload: frame.subarray(1) };
5033
5035
  }
5034
5036
 
5037
+ // src/wire/base64url.ts
5038
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5039
+ var DECODE_MAP = {};
5040
+ for (let i = 0; i < ALPHABET.length; i++) DECODE_MAP[ALPHABET[i]] = i;
5041
+ function base64UrlEncode(bytes) {
5042
+ let result = "";
5043
+ let i = 0;
5044
+ for (; i + 2 < bytes.length; i += 3) {
5045
+ const chunk = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
5046
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63] + ALPHABET[chunk & 63];
5047
+ }
5048
+ const remaining = bytes.length - i;
5049
+ if (remaining === 1) {
5050
+ const chunk = bytes[i] << 16;
5051
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63];
5052
+ } else if (remaining === 2) {
5053
+ const chunk = bytes[i] << 16 | bytes[i + 1] << 8;
5054
+ result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63];
5055
+ }
5056
+ return result;
5057
+ }
5058
+ function base64UrlDecode(encoded) {
5059
+ const byteLength = Math.floor(encoded.length * 6 / 8);
5060
+ const bytes = new Uint8Array(byteLength);
5061
+ let bitBuffer = 0;
5062
+ let bitCount = 0;
5063
+ let outputIndex = 0;
5064
+ for (const char of encoded) {
5065
+ const value = DECODE_MAP[char];
5066
+ if (value === void 0) throw new Error(`Invalid base64url character: "${char}"`);
5067
+ bitBuffer = bitBuffer << 6 | value;
5068
+ bitCount += 6;
5069
+ if (bitCount >= 8) {
5070
+ bitCount -= 8;
5071
+ bytes[outputIndex] = bitBuffer >> bitCount & 255;
5072
+ outputIndex += 1;
5073
+ }
5074
+ }
5075
+ return bytes;
5076
+ }
5077
+
5035
5078
  // src/events/payloads.ts
5036
5079
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5037
5080
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
@@ -5097,6 +5140,14 @@ function parseTranscriptEventPayload(payload) {
5097
5140
  });
5098
5141
  return { mode: "delta", revision, totalEntries, upserts };
5099
5142
  }
5143
+ function parseTerminalDimensionsWire(payload) {
5144
+ if (!isRecord(payload)) throw new Error("terminal dimensions must be an object");
5145
+ const cols = requireNumber(payload, "cols", "terminal dimensions");
5146
+ const rows = requireNumber(payload, "rows", "terminal dimensions");
5147
+ if (!Number.isInteger(cols) || cols < 2 || cols > 1e3) throw new Error('terminal dimensions have an invalid "cols"');
5148
+ if (!Number.isInteger(rows) || rows < 1 || rows > 1e3) throw new Error('terminal dimensions have an invalid "rows"');
5149
+ return { cols, rows };
5150
+ }
5100
5151
  function isActivityStateWire(value) {
5101
5152
  return value === "thinking" || value === "idle" || value === "permission";
5102
5153
  }
@@ -5257,6 +5308,7 @@ function parseDiffFileContentWire(payload) {
5257
5308
  }
5258
5309
 
5259
5310
  // src/wire/payloads.ts
5311
+ var READ_STREAM_SESSION_STATUSES = ["running", "queued", "suspended", "exited"];
5260
5312
  function parseReadStreamResponsePayload(payload) {
5261
5313
  if (!isRecord(payload)) throw new Error("read-stream response must be an object");
5262
5314
  if (typeof payload.scrollback !== "string") throw new Error('read-stream response is missing "scrollback"');
@@ -5268,12 +5320,22 @@ function parseReadStreamResponsePayload(payload) {
5268
5320
  if (payload.awaitedPromptId !== null && typeof payload.awaitedPromptId !== "string") {
5269
5321
  throw new Error('read-stream response has an invalid "awaitedPromptId"');
5270
5322
  }
5271
- return {
5323
+ const response = {
5272
5324
  scrollback: payload.scrollback,
5273
5325
  activity: { state: state ?? null, reason: reason ?? null },
5274
5326
  usage: payload.usage === null || payload.usage === void 0 ? null : parseSessionUsageWire(payload.usage),
5275
5327
  awaitedPromptId: payload.awaitedPromptId ?? null
5276
5328
  };
5329
+ if (payload.ptyDimensions !== void 0) {
5330
+ response.ptyDimensions = parseTerminalDimensionsWire(payload.ptyDimensions);
5331
+ }
5332
+ if (payload.sessionStatus !== void 0) {
5333
+ if (typeof payload.sessionStatus !== "string" || !READ_STREAM_SESSION_STATUSES.includes(payload.sessionStatus)) {
5334
+ throw new Error('read-stream response has an invalid "sessionStatus"');
5335
+ }
5336
+ response.sessionStatus = payload.sessionStatus;
5337
+ }
5338
+ return response;
5277
5339
  }
5278
5340
  function parseTranscriptWindowResponsePayload(payload) {
5279
5341
  if (!isRecord(payload)) throw new Error("transcript-window response must be an object");
@@ -5315,6 +5377,13 @@ function parseReadStreamRequestPayload(payload) {
5315
5377
  }
5316
5378
  return request;
5317
5379
  }
5380
+ var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
5381
+ function parseAccentColor(value, context) {
5382
+ if (typeof value !== "string" || !ACCENT_COLOR_PATTERN.test(value)) {
5383
+ throw new Error(`${context} has an invalid accent color (expected "#rrggbb")`);
5384
+ }
5385
+ return value;
5386
+ }
5318
5387
  function parseReadBoardResponsePayload(payload) {
5319
5388
  if (!isRecord(payload)) throw new Error("read-board response must be an object");
5320
5389
  if (Array.isArray(payload.projects)) {
@@ -5322,7 +5391,11 @@ function parseReadBoardResponsePayload(payload) {
5322
5391
  if (!isRecord(project) || typeof project.id !== "string" || typeof project.name !== "string") {
5323
5392
  throw new Error(`read-board project ${index} is malformed`);
5324
5393
  }
5325
- return { id: project.id, name: project.name };
5394
+ return {
5395
+ id: project.id,
5396
+ name: project.name,
5397
+ ...project.color !== void 0 ? { color: parseAccentColor(project.color, `read-board project ${index}`) } : {}
5398
+ };
5326
5399
  });
5327
5400
  return { projects };
5328
5401
  }
@@ -5334,7 +5407,8 @@ function parseReadBoardResponsePayload(payload) {
5334
5407
  projectId: payload.projectId,
5335
5408
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5336
5409
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5337
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item))
5410
+ backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5411
+ ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {}
5338
5412
  };
5339
5413
  }
5340
5414
  function parseReadBoardRequestPayload(payload) {
@@ -5401,8 +5475,21 @@ function parseAnswerPermissionPromptRequestPayload(payload) {
5401
5475
  function parseInteractiveTerminalRequestPayload(payload) {
5402
5476
  if (!isRecord(payload)) throw new Error("interactive-terminal payload must be an object");
5403
5477
  if (typeof payload.sessionId !== "string") throw new Error('interactive-terminal payload missing "sessionId"');
5404
- if (typeof payload.data !== "string") throw new Error('interactive-terminal payload missing "data"');
5405
- return { sessionId: payload.sessionId, data: payload.data };
5478
+ if (payload.action === void 0 || payload.action === "write") {
5479
+ if (typeof payload.data !== "string") throw new Error('interactive-terminal payload missing "data"');
5480
+ return { sessionId: payload.sessionId, action: "write", data: payload.data };
5481
+ }
5482
+ if (payload.action === "resize") {
5483
+ return {
5484
+ sessionId: payload.sessionId,
5485
+ action: "resize",
5486
+ dimensions: parseTerminalDimensionsWire(payload.dimensions)
5487
+ };
5488
+ }
5489
+ if (payload.action === "release-size") {
5490
+ return { sessionId: payload.sessionId, action: "release-size" };
5491
+ }
5492
+ throw new Error('interactive-terminal payload has an invalid "action"');
5406
5493
  }
5407
5494
  function parseBoardToolRequestPayload(payload) {
5408
5495
  if (!isRecord(payload)) throw new Error("board-tool payload must be an object");
@@ -5410,6 +5497,42 @@ function parseBoardToolRequestPayload(payload) {
5410
5497
  if (payload.params === void 0 || !isJsonValue(payload.params)) throw new Error('board-tool payload missing a JSON "params"');
5411
5498
  return { tool: payload.tool, params: payload.params };
5412
5499
  }
5500
+ var PUSH_KEY_LENGTH = 32;
5501
+ function parseRegisterPushRequestPayload(payload) {
5502
+ if (!isRecord(payload)) throw new Error("register-push payload must be an object");
5503
+ if (payload.action !== "register" && payload.action !== "unregister") {
5504
+ throw new Error('register-push payload has an invalid "action"');
5505
+ }
5506
+ const request = { action: payload.action };
5507
+ if (payload.expoPushToken !== void 0) {
5508
+ if (typeof payload.expoPushToken !== "string") throw new Error('register-push payload has a non-string "expoPushToken"');
5509
+ request.expoPushToken = payload.expoPushToken;
5510
+ }
5511
+ if (payload.pushKeyBase64 !== void 0) {
5512
+ if (typeof payload.pushKeyBase64 !== "string") throw new Error('register-push payload has a non-string "pushKeyBase64"');
5513
+ let decodedKey;
5514
+ try {
5515
+ decodedKey = base64UrlDecode(payload.pushKeyBase64);
5516
+ } catch {
5517
+ throw new Error('register-push payload has a malformed "pushKeyBase64"');
5518
+ }
5519
+ if (decodedKey.length !== PUSH_KEY_LENGTH) {
5520
+ throw new Error(`register-push payload "pushKeyBase64" must decode to ${PUSH_KEY_LENGTH} bytes`);
5521
+ }
5522
+ request.pushKeyBase64 = payload.pushKeyBase64;
5523
+ }
5524
+ if (payload.platform !== void 0) {
5525
+ if (payload.platform !== "android" && payload.platform !== "ios") {
5526
+ throw new Error('register-push payload has an invalid "platform"');
5527
+ }
5528
+ request.platform = payload.platform;
5529
+ }
5530
+ if (request.action === "register") {
5531
+ if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
5532
+ if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
5533
+ }
5534
+ return request;
5535
+ }
5413
5536
  function parseCapabilityRequestPayload(verb, payload) {
5414
5537
  switch (verb) {
5415
5538
  case "read-stream":
@@ -5429,6 +5552,8 @@ function parseCapabilityRequestPayload(verb, payload) {
5429
5552
  case "board-tool-read":
5430
5553
  case "board-tool-write":
5431
5554
  return parseBoardToolRequestPayload(payload);
5555
+ case "register-push":
5556
+ return parseRegisterPushRequestPayload(payload);
5432
5557
  default: {
5433
5558
  const exhaustiveCheck = verb;
5434
5559
  throw new Error(`Unknown capability verb: ${String(exhaustiveCheck)}`);
@@ -5436,45 +5561,54 @@ function parseCapabilityRequestPayload(verb, payload) {
5436
5561
  }
5437
5562
  }
5438
5563
 
5439
- // src/wire/base64url.ts
5440
- var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5441
- var DECODE_MAP = {};
5442
- for (let i = 0; i < ALPHABET.length; i++) DECODE_MAP[ALPHABET[i]] = i;
5443
- function base64UrlEncode(bytes) {
5444
- let result = "";
5445
- let i = 0;
5446
- for (; i + 2 < bytes.length; i += 3) {
5447
- const chunk = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
5448
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63] + ALPHABET[chunk & 63];
5449
- }
5450
- const remaining = bytes.length - i;
5451
- if (remaining === 1) {
5452
- const chunk = bytes[i] << 16;
5453
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63];
5454
- } else if (remaining === 2) {
5455
- const chunk = bytes[i] << 16 | bytes[i + 1] << 8;
5456
- result += ALPHABET[chunk >> 18 & 63] + ALPHABET[chunk >> 12 & 63] + ALPHABET[chunk >> 6 & 63];
5457
- }
5458
- return result;
5564
+ // src/crypto/push-envelope.ts
5565
+ var PUSH_CATEGORIES = ["permission-needed", "agent-question", "turn-complete", "session-failed"];
5566
+ function isPushCategory(value) {
5567
+ return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5568
+ }
5569
+ var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5570
+ var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5571
+ function requireEnvelopeString(record, field) {
5572
+ const value = record[field];
5573
+ if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5574
+ return value;
5459
5575
  }
5460
- function base64UrlDecode(encoded) {
5461
- const byteLength = Math.floor(encoded.length * 6 / 8);
5462
- const bytes = new Uint8Array(byteLength);
5463
- let bitBuffer = 0;
5464
- let bitCount = 0;
5465
- let outputIndex = 0;
5466
- for (const char of encoded) {
5467
- const value = DECODE_MAP[char];
5468
- if (value === void 0) throw new Error(`Invalid base64url character: "${char}"`);
5469
- bitBuffer = bitBuffer << 6 | value;
5470
- bitCount += 6;
5471
- if (bitCount >= 8) {
5472
- bitCount -= 8;
5473
- bytes[outputIndex] = bitBuffer >> bitCount & 255;
5474
- outputIndex += 1;
5475
- }
5576
+ function parsePushEnvelopePlaintext(value) {
5577
+ if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5578
+ if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5579
+ if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5580
+ throw new Error('push envelope plaintext has an invalid "sentAt"');
5476
5581
  }
5477
- return bytes;
5582
+ return {
5583
+ category: value.category,
5584
+ projectId: requireEnvelopeString(value, "projectId"),
5585
+ taskId: requireEnvelopeString(value, "taskId"),
5586
+ sessionId: requireEnvelopeString(value, "sessionId"),
5587
+ taskTitle: requireEnvelopeString(value, "taskTitle"),
5588
+ detail: requireEnvelopeString(value, "detail"),
5589
+ sentAt: value.sentAt
5590
+ };
5591
+ }
5592
+ function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5593
+ const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5594
+ const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5595
+ const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5596
+ return base64UrlEncode(concatBytes4(nonce, ciphertext));
5597
+ }
5598
+ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5599
+ const bytes = base64UrlDecode(blob);
5600
+ if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5601
+ throw new Error("push envelope blob is too short");
5602
+ }
5603
+ const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5604
+ const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5605
+ const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5606
+ const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5607
+ const plaintext = parsePushEnvelopePlaintext(decoded);
5608
+ const now = Date.now();
5609
+ if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5610
+ if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5611
+ return plaintext;
5478
5612
  }
5479
5613
 
5480
5614
  // src/pairing/qr.ts
@@ -5652,6 +5786,10 @@ function parseActivityEventPayload(payload) {
5652
5786
  if (typeof payload.pending !== "boolean") throw new Error('permission payload is missing "pending"');
5653
5787
  return { type: "permission", promptId: payload.promptId, pending: payload.pending };
5654
5788
  }
5789
+ case "session-ended": {
5790
+ if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5791
+ return { type: "session-ended", intentional: payload.intentional };
5792
+ }
5655
5793
  default:
5656
5794
  throw new Error('activity payload has an unknown "type"');
5657
5795
  }
@@ -5681,6 +5819,15 @@ function isBridgeEvent(value) {
5681
5819
  if (typeof value.sessionId !== "string" || typeof value.taskId !== "string") return false;
5682
5820
  return isRecord(value.payload) && typeof value.payload.data === "string";
5683
5821
  }
5822
+ case "terminal-resize": {
5823
+ if (typeof value.sessionId !== "string" || typeof value.taskId !== "string") return false;
5824
+ try {
5825
+ parseTerminalDimensionsWire(value.payload);
5826
+ return true;
5827
+ } catch {
5828
+ return false;
5829
+ }
5830
+ }
5684
5831
  case "board": {
5685
5832
  if (typeof value.projectId !== "string") return false;
5686
5833
  if (value.taskId !== void 0 && typeof value.taskId !== "string") return false;
@@ -5710,6 +5857,9 @@ export {
5710
5857
  MAX_FRAME_LENGTH,
5711
5858
  PAIRING_URI_SCHEME,
5712
5859
  PROTOCOL_VERSION,
5860
+ PUSH_CATEGORIES,
5861
+ PUSH_ENVELOPE_MAX_AGE_MS,
5862
+ PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS,
5713
5863
  SecretstreamState,
5714
5864
  SessionFrameKind,
5715
5865
  X25519_KEY_LENGTH,
@@ -5740,8 +5890,10 @@ export {
5740
5890
  isBridgeEvent,
5741
5891
  isCapabilityVerb,
5742
5892
  isJsonValue,
5893
+ isPushCategory,
5743
5894
  isRecord,
5744
5895
  isRosterEntryExpired,
5896
+ openPushEnvelope,
5745
5897
  parseActivityEventPayload,
5746
5898
  parseBacklogItemWire,
5747
5899
  parseBoardColumnWire,
@@ -5749,16 +5901,20 @@ export {
5749
5901
  parseCapabilityRequestPayload,
5750
5902
  parseDiffFileContentWire,
5751
5903
  parseDiffFileListWire,
5904
+ parsePushEnvelopePlaintext,
5752
5905
  parseReadBoardResponsePayload,
5753
5906
  parseReadDiffResponsePayload,
5754
5907
  parseReadStreamResponsePayload,
5908
+ parseRegisterPushRequestPayload,
5755
5909
  parseSessionEventWire,
5756
5910
  parseSessionUsageWire,
5911
+ parseTerminalDimensionsWire,
5757
5912
  parseTranscriptEntriesWire,
5758
5913
  parseTranscriptEventPayload,
5759
5914
  parseTranscriptWindowResponsePayload,
5760
5915
  randomBytes4 as randomBytes,
5761
5916
  rosterDeviceCapabilitySet,
5917
+ sealPushEnvelope,
5762
5918
  signRosterEntry,
5763
5919
  unwrapSessionFrame,
5764
5920
  verifyRosterEntry,
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import type { CapabilityVerb } from '../capabilities/verbs';
17
17
  import type { JsonValue } from './messages';
18
- import { type ActivityReasonWire, type ActivityStateWire, type BacklogItemWire, type BoardColumnWire, type BoardTaskWire, type DiffFileContentWire, type DiffFileListWire, type SessionUsageWire, type TranscriptEntryWire } from '../events/payloads';
18
+ import { type ActivityReasonWire, type ActivityStateWire, type BacklogItemWire, type BoardColumnWire, type BoardTaskWire, type DiffFileContentWire, type DiffFileListWire, type SessionUsageWire, type TerminalDimensionsWire, type TranscriptEntryWire } from '../events/payloads';
19
19
  export interface ReadStreamRequestPayload {
20
20
  sessionId: string;
21
21
  /**
@@ -32,6 +32,12 @@ export interface ReadStreamRequestPayload {
32
32
  /** transcript-window only: maximum entries wanted (the desktop may cap this and may return fewer). */
33
33
  limit?: number;
34
34
  }
35
+ /**
36
+ * Mirrors the desktop's SessionStatus. 'suspended' is a registered-but-
37
+ * parked session (resumable placeholder); 'exited' can appear when a
38
+ * snapshot races the session's teardown.
39
+ */
40
+ export type ReadStreamSessionStatusWire = 'running' | 'queued' | 'suspended' | 'exited';
35
41
  /** Initial snapshot returned on subscribe; live updates arrive as TerminalEvent/ActivityEvent/TranscriptEvent. */
36
42
  export interface ReadStreamResponsePayload {
37
43
  scrollback: string;
@@ -42,6 +48,17 @@ export interface ReadStreamResponsePayload {
42
48
  usage: SessionUsageWire | null;
43
49
  /** The live outstanding permission-prompt id (see answer-permission-prompt), or null when none is pending. */
44
50
  awaitedPromptId: string | null;
51
+ /**
52
+ * The PTY grid the scrollback bytes are laid out for. Absent from
53
+ * pre-0.4.0 desktops; the phone then falls back to inferring a width
54
+ * from the scrollback content.
55
+ */
56
+ ptyDimensions?: TerminalDimensionsWire;
57
+ /**
58
+ * The session's lifecycle status at snapshot time. Absent from
59
+ * pre-0.5.0 desktops; the phone then assumes 'running'.
60
+ */
61
+ sessionStatus?: ReadStreamSessionStatusWire;
45
62
  }
46
63
  /** Phone-side narrowing of a read-stream subscribe response. Throws on a malformed required field. */
47
64
  export declare function parseReadStreamResponsePayload(payload: JsonValue): ReadStreamResponsePayload;
@@ -66,6 +83,13 @@ export interface ReadBoardRequestPayload {
66
83
  export interface ReadBoardProjectSummary {
67
84
  id: string;
68
85
  name: string;
86
+ /**
87
+ * Accent color for this project ("#rrggbb"). Today the desktop derives
88
+ * it deterministically from the project id; a user-set project color
89
+ * can later override it through this same field. Absent from pre-0.5.0
90
+ * desktops.
91
+ */
92
+ color?: string;
69
93
  }
70
94
  /** Returned when the request omits projectId - the phone's project-bootstrap listing. */
71
95
  export interface ReadBoardProjectListResponsePayload {
@@ -77,6 +101,8 @@ export interface ReadBoardSnapshotResponsePayload {
77
101
  columns: BoardColumnWire[];
78
102
  tasks: BoardTaskWire[];
79
103
  backlog: BacklogItemWire[];
104
+ /** The project's accent color ("#rrggbb"); same semantics as ReadBoardProjectSummary.color. Absent from pre-0.5.0 desktops. */
105
+ projectColor?: string;
80
106
  }
81
107
  export type ReadBoardResponsePayload = ReadBoardProjectListResponsePayload | ReadBoardSnapshotResponsePayload;
82
108
  /** Phone-side narrowing of a read-board response (project list or board snapshot). Throws on a malformed required field. */
@@ -120,13 +146,38 @@ export interface AnswerPermissionPromptRequestPayload {
120
146
  export interface AnswerPermissionPromptResponsePayload {
121
147
  answered: boolean;
122
148
  }
123
- export interface InteractiveTerminalRequestPayload {
149
+ /**
150
+ * Three actions ride the one interactive-terminal grant: a device trusted
151
+ * to type raw bytes into the PTY is equally trusted to resize it, so
152
+ * resize adds no new capability surface.
153
+ *
154
+ * - write (the default when `action` is omitted, which is what every
155
+ * pre-0.4.0 phone sends): raw bytes into the PTY.
156
+ * - resize: set the PTY grid so the TUI redraws phone-shaped. The desktop
157
+ * remembers its own last dimensions and restores them when the phone
158
+ * releases (explicitly, or implicitly on disconnect/revoke).
159
+ * - release-size: give the grid back to the desktop now.
160
+ */
161
+ export type InteractiveTerminalRequestPayload = {
124
162
  sessionId: string;
163
+ action?: 'write';
125
164
  data: string;
126
- }
127
- export interface InteractiveTerminalResponsePayload {
165
+ } | {
166
+ sessionId: string;
167
+ action: 'resize';
168
+ dimensions: TerminalDimensionsWire;
169
+ } | {
170
+ sessionId: string;
171
+ action: 'release-size';
172
+ };
173
+ export type InteractiveTerminalResponsePayload = {
128
174
  written: boolean;
129
- }
175
+ } | {
176
+ resized: boolean;
177
+ colsChanged: boolean;
178
+ } | {
179
+ released: boolean;
180
+ };
130
181
  export interface BoardToolRequestPayload {
131
182
  tool: string;
132
183
  params: JsonValue;
@@ -134,6 +185,24 @@ export interface BoardToolRequestPayload {
134
185
  export interface BoardToolResponsePayload {
135
186
  result: JsonValue;
136
187
  }
188
+ /**
189
+ * Registers (or unregisters) this device for E2E-encrypted push
190
+ * notifications. 'register' carries the device's Expo push token plus a
191
+ * device-generated 32-byte push key (base64url) the desktop seals every
192
+ * notification envelope with (see crypto/push-envelope.ts); 'unregister'
193
+ * carries neither. The desktop keys the registration by the requesting
194
+ * device's roster identity, never by anything in this payload.
195
+ */
196
+ export interface RegisterPushRequestPayload {
197
+ action: 'register' | 'unregister';
198
+ expoPushToken?: string;
199
+ pushKeyBase64?: string;
200
+ platform?: 'android' | 'ios';
201
+ }
202
+ export interface RegisterPushResponsePayload {
203
+ registered: boolean;
204
+ }
205
+ export declare function parseRegisterPushRequestPayload(payload: JsonValue): RegisterPushRequestPayload;
137
206
  export interface CapabilityRequestPayloadMap {
138
207
  'read-stream': ReadStreamRequestPayload;
139
208
  'read-board': ReadBoardRequestPayload;
@@ -144,6 +213,7 @@ export interface CapabilityRequestPayloadMap {
144
213
  'interactive-terminal': InteractiveTerminalRequestPayload;
145
214
  'board-tool-read': BoardToolRequestPayload;
146
215
  'board-tool-write': BoardToolRequestPayload;
216
+ 'register-push': RegisterPushRequestPayload;
147
217
  }
148
218
  export interface CapabilityResponsePayloadMap {
149
219
  'read-stream': ReadStreamResponsePayload | TranscriptWindowResponsePayload;
@@ -155,6 +225,7 @@ export interface CapabilityResponsePayloadMap {
155
225
  'interactive-terminal': InteractiveTerminalResponsePayload;
156
226
  'board-tool-read': BoardToolResponsePayload;
157
227
  'board-tool-write': BoardToolResponsePayload;
228
+ 'register-push': RegisterPushResponsePayload;
158
229
  }
159
230
  /**
160
231
  * 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.3.0",
3
+ "version": "0.5.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",