@kangentic/protocol 0.6.0 → 0.8.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.
@@ -13,7 +13,7 @@
13
13
  * this protocol surface honest: a tool renamed or removed desktop-side
14
14
  * fails that test instead of silently breaking phones.
15
15
  */
16
- export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events"];
16
+ export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events", "get_activity_intervals"];
17
17
  export declare const BOARD_TOOL_WRITE_NAMES: readonly ["create_task", "update_task", "delete_task", "link_pr", "remove_attachment", "update_column", "create_backlog_task", "promote_backlog", "update_backlog_item", "delete_backlog_item"];
18
18
  export type BoardToolReadName = (typeof BOARD_TOOL_READ_NAMES)[number];
19
19
  export type BoardToolWriteName = (typeof BOARD_TOOL_WRITE_NAMES)[number];
@@ -1,4 +1,4 @@
1
- export declare const PUSH_CATEGORIES: readonly ["permission-needed", "agent-question", "turn-complete", "session-failed"];
1
+ export declare const PUSH_CATEGORIES: readonly ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
2
2
  export type PushCategory = (typeof PUSH_CATEGORIES)[number];
3
3
  export declare function isPushCategory(value: unknown): value is PushCategory;
4
4
  /** Reject an opened envelope whose sentAt is older than this (stale replay). */
@@ -69,6 +69,24 @@ export type ActivityEventPayload = {
69
69
  | {
70
70
  type: 'session-ended';
71
71
  intentional: boolean;
72
+ }
73
+ /**
74
+ * The agent's most recent assistant message, already collapsed to a short
75
+ * plain-text preview, pushed whenever it changes.
76
+ *
77
+ * A phone's session list renders exactly one line per session. Deriving it
78
+ * client-side cost a transcript-window request per session (measured 2.3 to
79
+ * 34.6 KB each, to keep a string under 200 characters) plus 0.7 to 3.8
80
+ * seconds of desktop work per request. The desktop already resolves the
81
+ * transcript to compute its delta pushes, so it can carry the line for
82
+ * free on a feed the phone is receiving anyway.
83
+ *
84
+ * Absent from pre-0.8.0 desktops; a phone that sees none should fall back
85
+ * to whatever it can derive locally rather than showing nothing.
86
+ */
87
+ | {
88
+ type: 'message-preview';
89
+ text: string;
72
90
  };
73
91
  export interface ActivityEvent {
74
92
  kind: 'activity';
@@ -115,11 +115,19 @@ export interface TerminalDimensionsWire {
115
115
  }
116
116
  /** Mirrors the desktop's ActivityState. 'permission' means the agent paused for user approval (incl. AskUserQuestion / ExitPlanMode pauses). */
117
117
  export type ActivityStateWire = 'thinking' | 'idle' | 'permission';
118
- /** Mirrors the desktop's ActivityReason discriminated union. */
118
+ /**
119
+ * Mirrors the desktop's ActivityReason discriminated union. `since` (epoch ms
120
+ * the session first needed the user) is optional, additive-field style like
121
+ * `toolCallCount`/`effort` above: an older phone parsing a payload with the
122
+ * field missing still validates, and a desktop that has not yet learned a
123
+ * `since` for a phantom-state edge case can omit it.
124
+ */
119
125
  export type ActivityReasonWire = {
120
126
  kind: 'idle';
127
+ since?: number;
121
128
  } | {
122
129
  kind: 'permission';
130
+ since?: number;
123
131
  } | {
124
132
  kind: 'tool';
125
133
  pendingCount: number;
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  KK_PATTERN: () => KK_PATTERN,
33
33
  MAX_DECODED_LENGTH: () => MAX_DECODED_LENGTH,
34
34
  MAX_FRAME_LENGTH: () => MAX_FRAME_LENGTH,
35
+ MAX_RELAY_ADDRESS_LENGTH: () => MAX_RELAY_ADDRESS_LENGTH,
35
36
  PAIRING_URI_SCHEME: () => PAIRING_URI_SCHEME,
36
37
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
37
38
  PUSH_CATEGORIES: () => PUSH_CATEGORIES,
@@ -70,6 +71,7 @@ __export(index_exports, {
70
71
  isPushCategory: () => isPushCategory,
71
72
  isRecord: () => isRecord,
72
73
  isRosterEntryExpired: () => isRosterEntryExpired,
74
+ isSecureRelayAddress: () => isSecureRelayAddress,
73
75
  openPushEnvelope: () => openPushEnvelope,
74
76
  parseActivityEventPayload: () => parseActivityEventPayload,
75
77
  parseBacklogItemWire: () => parseBacklogItemWire,
@@ -5186,6 +5188,56 @@ function base64UrlDecode(encoded) {
5186
5188
  return bytes;
5187
5189
  }
5188
5190
 
5191
+ // src/crypto/push-envelope.ts
5192
+ var PUSH_CATEGORIES = ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
5193
+ function isPushCategory(value) {
5194
+ return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5195
+ }
5196
+ var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5197
+ var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5198
+ function requireEnvelopeString(record, field) {
5199
+ const value = record[field];
5200
+ if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5201
+ return value;
5202
+ }
5203
+ function parsePushEnvelopePlaintext(value) {
5204
+ if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5205
+ if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5206
+ if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5207
+ throw new Error('push envelope plaintext has an invalid "sentAt"');
5208
+ }
5209
+ return {
5210
+ category: value.category,
5211
+ projectId: requireEnvelopeString(value, "projectId"),
5212
+ taskId: requireEnvelopeString(value, "taskId"),
5213
+ sessionId: requireEnvelopeString(value, "sessionId"),
5214
+ taskTitle: requireEnvelopeString(value, "taskTitle"),
5215
+ detail: requireEnvelopeString(value, "detail"),
5216
+ sentAt: value.sentAt
5217
+ };
5218
+ }
5219
+ function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5220
+ const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5221
+ const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5222
+ const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5223
+ return base64UrlEncode(concatBytes4(nonce, ciphertext));
5224
+ }
5225
+ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5226
+ const bytes = base64UrlDecode(blob);
5227
+ if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5228
+ throw new Error("push envelope blob is too short");
5229
+ }
5230
+ const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5231
+ const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5232
+ const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5233
+ const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5234
+ const plaintext = parsePushEnvelopePlaintext(decoded);
5235
+ const now = Date.now();
5236
+ if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5237
+ if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5238
+ return plaintext;
5239
+ }
5240
+
5189
5241
  // src/events/payloads.ts
5190
5242
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5191
5243
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
@@ -5267,6 +5319,7 @@ function isActivityReasonWire(value) {
5267
5319
  switch (value.kind) {
5268
5320
  case "idle":
5269
5321
  case "permission":
5322
+ return value.since === void 0 || typeof value.since === "number";
5270
5323
  case "turn-active":
5271
5324
  return true;
5272
5325
  case "tool":
@@ -5495,6 +5548,10 @@ function parseReadStreamRequestPayload(payload) {
5495
5548
  }
5496
5549
  request.limit = payload.limit;
5497
5550
  }
5551
+ if (payload.terminal !== void 0) {
5552
+ if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
5553
+ request.terminal = payload.terminal;
5554
+ }
5498
5555
  return request;
5499
5556
  }
5500
5557
  var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
@@ -5651,6 +5708,12 @@ function parseRegisterPushRequestPayload(payload) {
5651
5708
  }
5652
5709
  request.platform = payload.platform;
5653
5710
  }
5711
+ if (payload.categories !== void 0) {
5712
+ if (!Array.isArray(payload.categories) || !payload.categories.every((entry) => typeof entry === "string")) {
5713
+ throw new Error('register-push payload has a malformed "categories"');
5714
+ }
5715
+ request.categories = payload.categories.filter(isPushCategory);
5716
+ }
5654
5717
  if (request.action === "register") {
5655
5718
  if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
5656
5719
  if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
@@ -5685,61 +5748,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5685
5748
  }
5686
5749
  }
5687
5750
 
5688
- // src/crypto/push-envelope.ts
5689
- var PUSH_CATEGORIES = ["permission-needed", "agent-question", "turn-complete", "session-failed"];
5690
- function isPushCategory(value) {
5691
- return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5692
- }
5693
- var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5694
- var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5695
- function requireEnvelopeString(record, field) {
5696
- const value = record[field];
5697
- if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5698
- return value;
5699
- }
5700
- function parsePushEnvelopePlaintext(value) {
5701
- if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5702
- if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5703
- if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5704
- throw new Error('push envelope plaintext has an invalid "sentAt"');
5705
- }
5706
- return {
5707
- category: value.category,
5708
- projectId: requireEnvelopeString(value, "projectId"),
5709
- taskId: requireEnvelopeString(value, "taskId"),
5710
- sessionId: requireEnvelopeString(value, "sessionId"),
5711
- taskTitle: requireEnvelopeString(value, "taskTitle"),
5712
- detail: requireEnvelopeString(value, "detail"),
5713
- sentAt: value.sentAt
5714
- };
5715
- }
5716
- function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5717
- const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5718
- const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5719
- const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5720
- return base64UrlEncode(concatBytes4(nonce, ciphertext));
5721
- }
5722
- function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5723
- const bytes = base64UrlDecode(blob);
5724
- if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5725
- throw new Error("push envelope blob is too short");
5726
- }
5727
- const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5728
- const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5729
- const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5730
- const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5731
- const plaintext = parsePushEnvelopePlaintext(decoded);
5732
- const now = Date.now();
5733
- if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5734
- if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5735
- return plaintext;
5751
+ // src/pairing/relay-address.ts
5752
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5753
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5754
+ function isSecureRelayAddress(relayAddress) {
5755
+ if (relayAddress.startsWith("wss://")) return true;
5756
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5757
+ if (!relayAddress.startsWith(prefix)) return false;
5758
+ const boundaryChar = relayAddress.charAt(prefix.length);
5759
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5760
+ });
5736
5761
  }
5737
5762
 
5738
5763
  // src/pairing/qr.ts
5739
5764
  var PAIRING_URI_SCHEME = "kangentic-pair";
5740
5765
  var PAYLOAD_VERSION = 1;
5741
5766
  var PAIRING_TOKEN_LENGTH = 32;
5742
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5743
5767
  var MAX_PAYLOAD_LENGTH = 4096;
5744
5768
  function writeUint32(value) {
5745
5769
  const bytes = new Uint8Array(4);
@@ -5870,7 +5894,8 @@ var BOARD_TOOL_READ_NAMES = [
5870
5894
  "get_handoff_context",
5871
5895
  "get_transcript",
5872
5896
  "get_session_files",
5873
- "get_session_events"
5897
+ "get_session_events",
5898
+ "get_activity_intervals"
5874
5899
  ];
5875
5900
  var BOARD_TOOL_WRITE_NAMES = [
5876
5901
  "create_task",
@@ -5920,6 +5945,10 @@ function parseActivityEventPayload(payload) {
5920
5945
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5921
5946
  return { type: "session-ended", intentional: payload.intentional };
5922
5947
  }
5948
+ case "message-preview": {
5949
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
5950
+ return { type: "message-preview", text: payload.text };
5951
+ }
5923
5952
  default:
5924
5953
  throw new Error('activity payload has an unknown "type"');
5925
5954
  }
@@ -5986,6 +6015,7 @@ function isBridgeEvent(value) {
5986
6015
  KK_PATTERN,
5987
6016
  MAX_DECODED_LENGTH,
5988
6017
  MAX_FRAME_LENGTH,
6018
+ MAX_RELAY_ADDRESS_LENGTH,
5989
6019
  PAIRING_URI_SCHEME,
5990
6020
  PROTOCOL_VERSION,
5991
6021
  PUSH_CATEGORIES,
@@ -6024,6 +6054,7 @@ function isBridgeEvent(value) {
6024
6054
  isPushCategory,
6025
6055
  isRecord,
6026
6056
  isRosterEntryExpired,
6057
+ isSecureRelayAddress,
6027
6058
  openPushEnvelope,
6028
6059
  parseActivityEventPayload,
6029
6060
  parseBacklogItemWire,
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { SessionFrameKind, wrapSessionFrame, unwrapSessionFrame } from './wire/s
15
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
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';
17
17
  export { PAIRING_URI_SCHEME, encodePairingQrPayload, decodePairingQrPayload, type PairingQrPayload, } from './pairing/qr';
18
+ export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './pairing/relay-address';
18
19
  export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
19
20
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
20
21
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
package/dist/index.mjs CHANGED
@@ -5075,6 +5075,56 @@ function base64UrlDecode(encoded) {
5075
5075
  return bytes;
5076
5076
  }
5077
5077
 
5078
+ // src/crypto/push-envelope.ts
5079
+ var PUSH_CATEGORIES = ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
5080
+ function isPushCategory(value) {
5081
+ return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5082
+ }
5083
+ var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5084
+ var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5085
+ function requireEnvelopeString(record, field) {
5086
+ const value = record[field];
5087
+ if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5088
+ return value;
5089
+ }
5090
+ function parsePushEnvelopePlaintext(value) {
5091
+ if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5092
+ if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5093
+ if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5094
+ throw new Error('push envelope plaintext has an invalid "sentAt"');
5095
+ }
5096
+ return {
5097
+ category: value.category,
5098
+ projectId: requireEnvelopeString(value, "projectId"),
5099
+ taskId: requireEnvelopeString(value, "taskId"),
5100
+ sessionId: requireEnvelopeString(value, "sessionId"),
5101
+ taskTitle: requireEnvelopeString(value, "taskTitle"),
5102
+ detail: requireEnvelopeString(value, "detail"),
5103
+ sentAt: value.sentAt
5104
+ };
5105
+ }
5106
+ function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5107
+ const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5108
+ const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5109
+ const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5110
+ return base64UrlEncode(concatBytes4(nonce, ciphertext));
5111
+ }
5112
+ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5113
+ const bytes = base64UrlDecode(blob);
5114
+ if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5115
+ throw new Error("push envelope blob is too short");
5116
+ }
5117
+ const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5118
+ const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5119
+ const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5120
+ const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5121
+ const plaintext = parsePushEnvelopePlaintext(decoded);
5122
+ const now = Date.now();
5123
+ if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5124
+ if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5125
+ return plaintext;
5126
+ }
5127
+
5078
5128
  // src/events/payloads.ts
5079
5129
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5080
5130
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
@@ -5156,6 +5206,7 @@ function isActivityReasonWire(value) {
5156
5206
  switch (value.kind) {
5157
5207
  case "idle":
5158
5208
  case "permission":
5209
+ return value.since === void 0 || typeof value.since === "number";
5159
5210
  case "turn-active":
5160
5211
  return true;
5161
5212
  case "tool":
@@ -5384,6 +5435,10 @@ function parseReadStreamRequestPayload(payload) {
5384
5435
  }
5385
5436
  request.limit = payload.limit;
5386
5437
  }
5438
+ if (payload.terminal !== void 0) {
5439
+ if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
5440
+ request.terminal = payload.terminal;
5441
+ }
5387
5442
  return request;
5388
5443
  }
5389
5444
  var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
@@ -5540,6 +5595,12 @@ function parseRegisterPushRequestPayload(payload) {
5540
5595
  }
5541
5596
  request.platform = payload.platform;
5542
5597
  }
5598
+ if (payload.categories !== void 0) {
5599
+ if (!Array.isArray(payload.categories) || !payload.categories.every((entry) => typeof entry === "string")) {
5600
+ throw new Error('register-push payload has a malformed "categories"');
5601
+ }
5602
+ request.categories = payload.categories.filter(isPushCategory);
5603
+ }
5543
5604
  if (request.action === "register") {
5544
5605
  if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
5545
5606
  if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
@@ -5574,61 +5635,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5574
5635
  }
5575
5636
  }
5576
5637
 
5577
- // src/crypto/push-envelope.ts
5578
- var PUSH_CATEGORIES = ["permission-needed", "agent-question", "turn-complete", "session-failed"];
5579
- function isPushCategory(value) {
5580
- return typeof value === "string" && PUSH_CATEGORIES.includes(value);
5581
- }
5582
- var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5583
- var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
5584
- function requireEnvelopeString(record, field) {
5585
- const value = record[field];
5586
- if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
5587
- return value;
5588
- }
5589
- function parsePushEnvelopePlaintext(value) {
5590
- if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
5591
- if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
5592
- if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
5593
- throw new Error('push envelope plaintext has an invalid "sentAt"');
5594
- }
5595
- return {
5596
- category: value.category,
5597
- projectId: requireEnvelopeString(value, "projectId"),
5598
- taskId: requireEnvelopeString(value, "taskId"),
5599
- sessionId: requireEnvelopeString(value, "sessionId"),
5600
- taskTitle: requireEnvelopeString(value, "taskTitle"),
5601
- detail: requireEnvelopeString(value, "detail"),
5602
- sentAt: value.sentAt
5603
- };
5604
- }
5605
- function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
5606
- const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
5607
- const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
5608
- const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
5609
- return base64UrlEncode(concatBytes4(nonce, ciphertext));
5610
- }
5611
- function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5612
- const bytes = base64UrlDecode(blob);
5613
- if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
5614
- throw new Error("push envelope blob is too short");
5615
- }
5616
- const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
5617
- const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
5618
- const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
5619
- const decoded = JSON.parse(new TextDecoder().decode(decrypted));
5620
- const plaintext = parsePushEnvelopePlaintext(decoded);
5621
- const now = Date.now();
5622
- if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
5623
- if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
5624
- return plaintext;
5638
+ // src/pairing/relay-address.ts
5639
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5640
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5641
+ function isSecureRelayAddress(relayAddress) {
5642
+ if (relayAddress.startsWith("wss://")) return true;
5643
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5644
+ if (!relayAddress.startsWith(prefix)) return false;
5645
+ const boundaryChar = relayAddress.charAt(prefix.length);
5646
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5647
+ });
5625
5648
  }
5626
5649
 
5627
5650
  // src/pairing/qr.ts
5628
5651
  var PAIRING_URI_SCHEME = "kangentic-pair";
5629
5652
  var PAYLOAD_VERSION = 1;
5630
5653
  var PAIRING_TOKEN_LENGTH = 32;
5631
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5632
5654
  var MAX_PAYLOAD_LENGTH = 4096;
5633
5655
  function writeUint32(value) {
5634
5656
  const bytes = new Uint8Array(4);
@@ -5759,7 +5781,8 @@ var BOARD_TOOL_READ_NAMES = [
5759
5781
  "get_handoff_context",
5760
5782
  "get_transcript",
5761
5783
  "get_session_files",
5762
- "get_session_events"
5784
+ "get_session_events",
5785
+ "get_activity_intervals"
5763
5786
  ];
5764
5787
  var BOARD_TOOL_WRITE_NAMES = [
5765
5788
  "create_task",
@@ -5809,6 +5832,10 @@ function parseActivityEventPayload(payload) {
5809
5832
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5810
5833
  return { type: "session-ended", intentional: payload.intentional };
5811
5834
  }
5835
+ case "message-preview": {
5836
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
5837
+ return { type: "message-preview", text: payload.text };
5838
+ }
5812
5839
  default:
5813
5840
  throw new Error('activity payload has an unknown "type"');
5814
5841
  }
@@ -5874,6 +5901,7 @@ export {
5874
5901
  KK_PATTERN,
5875
5902
  MAX_DECODED_LENGTH,
5876
5903
  MAX_FRAME_LENGTH,
5904
+ MAX_RELAY_ADDRESS_LENGTH,
5877
5905
  PAIRING_URI_SCHEME,
5878
5906
  PROTOCOL_VERSION,
5879
5907
  PUSH_CATEGORIES,
@@ -5912,6 +5940,7 @@ export {
5912
5940
  isPushCategory,
5913
5941
  isRecord,
5914
5942
  isRosterEntryExpired,
5943
+ isSecureRelayAddress,
5915
5944
  openPushEnvelope,
5916
5945
  parseActivityEventPayload,
5917
5946
  parseBacklogItemWire,
@@ -1,3 +1,4 @@
1
+ export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './relay-address';
1
2
  export declare const PAIRING_URI_SCHEME = "kangentic-pair";
2
3
  export interface PairingQrPayload {
3
4
  desktopStaticPublicKey: Uint8Array;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Relay address rules shared, byte-for-byte, with the mobile app's
3
+ * src/pairing/qr.ts. This module imports nothing on purpose: it is
4
+ * deep-imported by the desktop's src/shared/relay.ts, which the RENDERER
5
+ * bundles, and pulling in the rest of this package (crypto/primitives ->
6
+ * @noble/*) would drag Noise crypto into that bundle for no reason. Keep it
7
+ * dependency-free.
8
+ */
9
+ export declare const MAX_RELAY_ADDRESS_LENGTH = 512;
10
+ /**
11
+ * True if the phone's pairing QR scanner will accept this relay address.
12
+ * wss:// is always secure; ws:// is accepted only for loopback, since the
13
+ * pairing token doubles as the Noise PSK and is dialed verbatim as
14
+ * `?slot=`. Prefix-based (not hostname-based) with an explicit boundary
15
+ * check, so `ws://localhost.evil.com` - a hostname that merely starts with
16
+ * the string "localhost" - is rejected rather than treated as loopback.
17
+ */
18
+ export declare function isSecureRelayAddress(relayAddress: string): boolean;
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { CapabilityVerb } from '../capabilities/verbs';
17
17
  import type { JsonValue } from './messages';
18
+ import { type PushCategory } from '../crypto/push-envelope';
18
19
  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
20
  export interface ReadStreamRequestPayload {
20
21
  sessionId: string;
@@ -31,6 +32,20 @@ export interface ReadStreamRequestPayload {
31
32
  beforeIndex?: number;
32
33
  /** transcript-window only: maximum entries wanted (the desktop may cap this and may return fewer). */
33
34
  limit?: number;
35
+ /**
36
+ * subscribe only: whether this subscription wants live PTY bytes.
37
+ *
38
+ * A phone watching its session list needs activity, permission and
39
+ * transcript pushes, but NOT the terminal - it discards those bytes on
40
+ * arrival. Measured on a live board, that discard cost roughly 13MB an hour
41
+ * of relay traffic and mobile data for a feed showing no terminal at all.
42
+ *
43
+ * Omitted means true, so an older phone keeps the previous behaviour and an
44
+ * older desktop that ignores the field simply keeps sending (wasteful, not
45
+ * broken). Set false for a list-only subscription and re-subscribe with it
46
+ * true when a terminal actually opens.
47
+ */
48
+ terminal?: boolean;
34
49
  }
35
50
  /**
36
51
  * Mirrors the desktop's SessionStatus. 'suspended' is a registered-but-
@@ -207,12 +222,20 @@ export interface BoardToolResponsePayload {
207
222
  * notification envelope with (see crypto/push-envelope.ts); 'unregister'
208
223
  * carries neither. The desktop keys the registration by the requesting
209
224
  * device's roster identity, never by anything in this payload.
225
+ *
226
+ * `categories`, when present, is the device's push preferences: the desktop
227
+ * filters outgoing notifications to this set before sending, so preference
228
+ * enforcement never depends on the receiving device (a future iOS
229
+ * Notification Service Extension stays preference-free). Absent means every
230
+ * category (the default, and what an older device that predates this field
231
+ * implicitly requests); an explicit empty array means none.
210
232
  */
211
233
  export interface RegisterPushRequestPayload {
212
234
  action: 'register' | 'unregister';
213
235
  expoPushToken?: string;
214
236
  pushKeyBase64?: string;
215
237
  platform?: 'android' | 'ios';
238
+ categories?: PushCategory[];
216
239
  }
217
240
  export interface RegisterPushResponsePayload {
218
241
  registered: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangentic/protocol",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",