@kangentic/protocol 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,11 @@ model, and the honest relay-metadata statement.
20
20
  - `Noise_KK_25519_ChaChaPoly_BLAKE2s` for ongoing sessions (mutual authentication by construction,
21
21
  no trust-on-first-use).
22
22
  - A token-bound Noise PSK handshake for the one-time pairing ceremony, confirmed by a Matrix-style
23
- short authentication string (SAS) on both screens.
23
+ short authentication string (SAS) compared on both screens - digits only, no emoji.
24
+ - A sealed pairing-confirm frame (`pairing/confirm.ts`), sent once the human taps Confirm on the
25
+ phone: it is a liveness/intent signal, not the security boundary. It opens only if both peers
26
+ completed the same handshake transcript, which is exactly the property the SAS comparison
27
+ already vouches for - the human's comparison remains the actual defense.
24
28
  - No shell, file, or arbitrary-command verb exists in the protocol.
25
29
 
26
30
  This package has not undergone a third-party security audit. Report suspected vulnerabilities
@@ -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];
@@ -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,
@@ -57,6 +58,7 @@ __export(index_exports, {
57
58
  encodeProtocolVersion: () => encodeProtocolVersion,
58
59
  encodeRosterEntryForSigning: () => encodeRosterEntryForSigning,
59
60
  findRosterDevice: () => findRosterDevice,
61
+ formatKeyFingerprint: () => formatKeyFingerprint,
60
62
  generateEd25519KeyPair: () => generateEd25519KeyPair,
61
63
  generateX25519KeyPair: () => generateX25519KeyPair,
62
64
  hexToBytes: () => hexToBytes,
@@ -70,6 +72,8 @@ __export(index_exports, {
70
72
  isPushCategory: () => isPushCategory,
71
73
  isRecord: () => isRecord,
72
74
  isRosterEntryExpired: () => isRosterEntryExpired,
75
+ isSecureRelayAddress: () => isSecureRelayAddress,
76
+ openPairingConfirm: () => openPairingConfirm,
73
77
  openPushEnvelope: () => openPushEnvelope,
74
78
  parseActivityEventPayload: () => parseActivityEventPayload,
75
79
  parseBacklogItemWire: () => parseBacklogItemWire,
@@ -91,6 +95,7 @@ __export(index_exports, {
91
95
  parseTranscriptWindowResponsePayload: () => parseTranscriptWindowResponsePayload,
92
96
  randomBytes: () => randomBytes4,
93
97
  rosterDeviceCapabilitySet: () => rosterDeviceCapabilitySet,
98
+ sealPairingConfirm: () => sealPairingConfirm,
94
99
  sealPushEnvelope: () => sealPushEnvelope,
95
100
  signRosterEntry: () => signRosterEntry,
96
101
  unwrapSessionFrame: () => unwrapSessionFrame,
@@ -5317,6 +5322,7 @@ function isActivityReasonWire(value) {
5317
5322
  switch (value.kind) {
5318
5323
  case "idle":
5319
5324
  case "permission":
5325
+ return value.since === void 0 || typeof value.since === "number";
5320
5326
  case "turn-active":
5321
5327
  return true;
5322
5328
  case "tool":
@@ -5545,6 +5551,10 @@ function parseReadStreamRequestPayload(payload) {
5545
5551
  }
5546
5552
  request.limit = payload.limit;
5547
5553
  }
5554
+ if (payload.terminal !== void 0) {
5555
+ if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
5556
+ request.terminal = payload.terminal;
5557
+ }
5548
5558
  return request;
5549
5559
  }
5550
5560
  var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
@@ -5572,19 +5582,34 @@ function parseReadBoardResponsePayload(payload) {
5572
5582
  if (typeof payload.projectId !== "string") throw new Error('read-board response is missing "projectId"');
5573
5583
  if (!Array.isArray(payload.columns)) throw new Error('read-board response is missing "columns"');
5574
5584
  if (!Array.isArray(payload.tasks)) throw new Error('read-board response is missing "tasks"');
5575
- if (!Array.isArray(payload.backlog)) throw new Error('read-board response is missing "backlog"');
5585
+ if (payload.backlog !== void 0 && !Array.isArray(payload.backlog)) {
5586
+ throw new Error('read-board response has a non-array "backlog"');
5587
+ }
5576
5588
  if (payload.showTicketNumbers !== void 0 && typeof payload.showTicketNumbers !== "boolean") {
5577
5589
  throw new Error('read-board response has a non-boolean "showTicketNumbers"');
5578
5590
  }
5591
+ if (payload.view !== void 0 && payload.view !== "full" && payload.view !== "sessions") {
5592
+ throw new Error('read-board response has an invalid "view"');
5593
+ }
5579
5594
  return {
5580
5595
  projectId: payload.projectId,
5581
5596
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5582
5597
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5583
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5598
+ ...payload.backlog !== void 0 ? { backlog: payload.backlog.map((item) => parseBacklogItemWire(item)) } : {},
5584
5599
  ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {},
5585
- ...payload.showTicketNumbers !== void 0 ? { showTicketNumbers: payload.showTicketNumbers } : {}
5600
+ ...payload.showTicketNumbers !== void 0 ? { showTicketNumbers: payload.showTicketNumbers } : {},
5601
+ ...payload.view !== void 0 ? { view: payload.view } : {},
5602
+ ...payload.taskCountsByColumnId !== void 0 ? { taskCountsByColumnId: parseTaskCountsByColumnId(payload.taskCountsByColumnId) } : {}
5586
5603
  };
5587
5604
  }
5605
+ function parseTaskCountsByColumnId(value) {
5606
+ if (!isRecord(value)) throw new Error('read-board response has a non-object "taskCountsByColumnId"');
5607
+ const counts = {};
5608
+ for (const [columnId, count] of Object.entries(value)) {
5609
+ if (typeof count === "number" && Number.isInteger(count) && count >= 0) counts[columnId] = count;
5610
+ }
5611
+ return counts;
5612
+ }
5588
5613
  function parseReadBoardRequestPayload(payload) {
5589
5614
  if (!isRecord(payload)) throw new Error("read-board payload must be an object");
5590
5615
  if (payload.projectId !== void 0 && typeof payload.projectId !== "string") {
@@ -5593,7 +5618,10 @@ function parseReadBoardRequestPayload(payload) {
5593
5618
  if (payload.action !== void 0 && payload.action !== "subscribe" && payload.action !== "unsubscribe") {
5594
5619
  throw new Error('read-board payload has an invalid "action"');
5595
5620
  }
5596
- return { projectId: payload.projectId, action: payload.action };
5621
+ if (payload.view !== void 0 && payload.view !== "full" && payload.view !== "sessions") {
5622
+ throw new Error('read-board payload has an invalid "view"');
5623
+ }
5624
+ return { projectId: payload.projectId, action: payload.action, view: payload.view };
5597
5625
  }
5598
5626
  function parseReadDiffResponsePayload(payload) {
5599
5627
  if (!isRecord(payload)) throw new Error("read-diff response must be an object");
@@ -5741,11 +5769,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5741
5769
  }
5742
5770
  }
5743
5771
 
5772
+ // src/pairing/relay-address.ts
5773
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5774
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5775
+ function isSecureRelayAddress(relayAddress) {
5776
+ if (relayAddress.startsWith("wss://")) return true;
5777
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5778
+ if (!relayAddress.startsWith(prefix)) return false;
5779
+ const boundaryChar = relayAddress.charAt(prefix.length);
5780
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5781
+ });
5782
+ }
5783
+
5744
5784
  // src/pairing/qr.ts
5745
5785
  var PAIRING_URI_SCHEME = "kangentic-pair";
5746
5786
  var PAYLOAD_VERSION = 1;
5747
5787
  var PAIRING_TOKEN_LENGTH = 32;
5748
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5749
5788
  var MAX_PAYLOAD_LENGTH = 4096;
5750
5789
  function writeUint32(value) {
5751
5790
  const bytes = new Uint8Array(4);
@@ -5823,6 +5862,27 @@ function decodePairingQrPayload(uri) {
5823
5862
  return { desktopStaticPublicKey, pairingToken, relayAddress, expiresAt, protocolVersion };
5824
5863
  }
5825
5864
 
5865
+ // src/pairing/confirm.ts
5866
+ var PAIRING_CONFIRM_PLAINTEXT = new TextEncoder().encode("kangentic-pairing-confirm-v1");
5867
+ var EMPTY_ASSOCIATED_DATA = new Uint8Array(0);
5868
+ function sealPairingConfirm(initiatorToResponder) {
5869
+ return initiatorToResponder.encryptWithAd(EMPTY_ASSOCIATED_DATA, PAIRING_CONFIRM_PLAINTEXT);
5870
+ }
5871
+ function openPairingConfirm(initiatorToResponder, frame) {
5872
+ let plaintext;
5873
+ try {
5874
+ plaintext = initiatorToResponder.decryptWithAd(EMPTY_ASSOCIATED_DATA, frame);
5875
+ } catch {
5876
+ return false;
5877
+ }
5878
+ if (plaintext.length !== PAIRING_CONFIRM_PLAINTEXT.length) return false;
5879
+ let mismatch = 0;
5880
+ for (let index = 0; index < plaintext.length; index += 1) {
5881
+ mismatch |= plaintext[index] ^ PAIRING_CONFIRM_PLAINTEXT[index];
5882
+ }
5883
+ return mismatch === 0;
5884
+ }
5885
+
5826
5886
  // src/roster/roster.ts
5827
5887
  function uint32LengthPrefixed(bytes) {
5828
5888
  const prefix = new Uint8Array(4);
@@ -5862,6 +5922,15 @@ function capabilitySetToRosterCapabilities(capabilities) {
5862
5922
  return capabilitySetToArray(capabilities);
5863
5923
  }
5864
5924
 
5925
+ // src/roster/fingerprint.ts
5926
+ function formatKeyFingerprint(publicKeyHex, groups = 4) {
5927
+ const clusters = [];
5928
+ for (let index = 0; index < groups; index += 1) {
5929
+ clusters.push(publicKeyHex.slice(index * 4, index * 4 + 4));
5930
+ }
5931
+ return clusters.join(" ");
5932
+ }
5933
+
5865
5934
  // src/capabilities/board-tools.ts
5866
5935
  var BOARD_TOOL_READ_NAMES = [
5867
5936
  "search_tasks",
@@ -5876,7 +5945,8 @@ var BOARD_TOOL_READ_NAMES = [
5876
5945
  "get_handoff_context",
5877
5946
  "get_transcript",
5878
5947
  "get_session_files",
5879
- "get_session_events"
5948
+ "get_session_events",
5949
+ "get_activity_intervals"
5880
5950
  ];
5881
5951
  var BOARD_TOOL_WRITE_NAMES = [
5882
5952
  "create_task",
@@ -5926,6 +5996,10 @@ function parseActivityEventPayload(payload) {
5926
5996
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5927
5997
  return { type: "session-ended", intentional: payload.intentional };
5928
5998
  }
5999
+ case "message-preview": {
6000
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
6001
+ return { type: "message-preview", text: payload.text };
6002
+ }
5929
6003
  default:
5930
6004
  throw new Error('activity payload has an unknown "type"');
5931
6005
  }
@@ -5992,6 +6066,7 @@ function isBridgeEvent(value) {
5992
6066
  KK_PATTERN,
5993
6067
  MAX_DECODED_LENGTH,
5994
6068
  MAX_FRAME_LENGTH,
6069
+ MAX_RELAY_ADDRESS_LENGTH,
5995
6070
  PAIRING_URI_SCHEME,
5996
6071
  PROTOCOL_VERSION,
5997
6072
  PUSH_CATEGORIES,
@@ -6017,6 +6092,7 @@ function isBridgeEvent(value) {
6017
6092
  encodeProtocolVersion,
6018
6093
  encodeRosterEntryForSigning,
6019
6094
  findRosterDevice,
6095
+ formatKeyFingerprint,
6020
6096
  generateEd25519KeyPair,
6021
6097
  generateX25519KeyPair,
6022
6098
  hexToBytes,
@@ -6030,6 +6106,8 @@ function isBridgeEvent(value) {
6030
6106
  isPushCategory,
6031
6107
  isRecord,
6032
6108
  isRosterEntryExpired,
6109
+ isSecureRelayAddress,
6110
+ openPairingConfirm,
6033
6111
  openPushEnvelope,
6034
6112
  parseActivityEventPayload,
6035
6113
  parseBacklogItemWire,
@@ -6051,6 +6129,7 @@ function isBridgeEvent(value) {
6051
6129
  parseTranscriptWindowResponsePayload,
6052
6130
  randomBytes,
6053
6131
  rosterDeviceCapabilitySet,
6132
+ sealPairingConfirm,
6054
6133
  sealPushEnvelope,
6055
6134
  signRosterEntry,
6056
6135
  unwrapSessionFrame,
package/dist/index.d.ts CHANGED
@@ -12,10 +12,13 @@ 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, 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';
15
+ export { parseCapabilityRequestPayload, parseReadStreamResponsePayload, parseReadBoardResponsePayload, parseReadDiffResponsePayload, parseTranscriptWindowResponsePayload, parseRegisterPushRequestPayload, type CapabilityRequestPayloadMap, type CapabilityResponsePayloadMap, type ReadStreamRequestPayload, type ReadStreamResponsePayload, type ReadStreamSessionStatusWire, type TranscriptWindowResponsePayload, type ReadBoardRequestPayload, type ReadBoardView, 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 { sealPairingConfirm, openPairingConfirm } from './pairing/confirm';
19
+ export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './pairing/relay-address';
18
20
  export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
21
+ export { formatKeyFingerprint } from './roster/fingerprint';
19
22
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
20
23
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
21
24
  export { isBridgeEvent, parseActivityEventPayload, type BridgeEvent, type TranscriptEvent, type ActivityEvent, type ActivityEventPayload, type TerminalEvent, type TerminalResizeEvent, type BoardEvent, type BoardEventPayload, type DiffEvent, } from './events/event';
package/dist/index.mjs CHANGED
@@ -5206,6 +5206,7 @@ function isActivityReasonWire(value) {
5206
5206
  switch (value.kind) {
5207
5207
  case "idle":
5208
5208
  case "permission":
5209
+ return value.since === void 0 || typeof value.since === "number";
5209
5210
  case "turn-active":
5210
5211
  return true;
5211
5212
  case "tool":
@@ -5434,6 +5435,10 @@ function parseReadStreamRequestPayload(payload) {
5434
5435
  }
5435
5436
  request.limit = payload.limit;
5436
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
+ }
5437
5442
  return request;
5438
5443
  }
5439
5444
  var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
@@ -5461,19 +5466,34 @@ function parseReadBoardResponsePayload(payload) {
5461
5466
  if (typeof payload.projectId !== "string") throw new Error('read-board response is missing "projectId"');
5462
5467
  if (!Array.isArray(payload.columns)) throw new Error('read-board response is missing "columns"');
5463
5468
  if (!Array.isArray(payload.tasks)) throw new Error('read-board response is missing "tasks"');
5464
- if (!Array.isArray(payload.backlog)) throw new Error('read-board response is missing "backlog"');
5469
+ if (payload.backlog !== void 0 && !Array.isArray(payload.backlog)) {
5470
+ throw new Error('read-board response has a non-array "backlog"');
5471
+ }
5465
5472
  if (payload.showTicketNumbers !== void 0 && typeof payload.showTicketNumbers !== "boolean") {
5466
5473
  throw new Error('read-board response has a non-boolean "showTicketNumbers"');
5467
5474
  }
5475
+ if (payload.view !== void 0 && payload.view !== "full" && payload.view !== "sessions") {
5476
+ throw new Error('read-board response has an invalid "view"');
5477
+ }
5468
5478
  return {
5469
5479
  projectId: payload.projectId,
5470
5480
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5471
5481
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5472
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5482
+ ...payload.backlog !== void 0 ? { backlog: payload.backlog.map((item) => parseBacklogItemWire(item)) } : {},
5473
5483
  ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {},
5474
- ...payload.showTicketNumbers !== void 0 ? { showTicketNumbers: payload.showTicketNumbers } : {}
5484
+ ...payload.showTicketNumbers !== void 0 ? { showTicketNumbers: payload.showTicketNumbers } : {},
5485
+ ...payload.view !== void 0 ? { view: payload.view } : {},
5486
+ ...payload.taskCountsByColumnId !== void 0 ? { taskCountsByColumnId: parseTaskCountsByColumnId(payload.taskCountsByColumnId) } : {}
5475
5487
  };
5476
5488
  }
5489
+ function parseTaskCountsByColumnId(value) {
5490
+ if (!isRecord(value)) throw new Error('read-board response has a non-object "taskCountsByColumnId"');
5491
+ const counts = {};
5492
+ for (const [columnId, count] of Object.entries(value)) {
5493
+ if (typeof count === "number" && Number.isInteger(count) && count >= 0) counts[columnId] = count;
5494
+ }
5495
+ return counts;
5496
+ }
5477
5497
  function parseReadBoardRequestPayload(payload) {
5478
5498
  if (!isRecord(payload)) throw new Error("read-board payload must be an object");
5479
5499
  if (payload.projectId !== void 0 && typeof payload.projectId !== "string") {
@@ -5482,7 +5502,10 @@ function parseReadBoardRequestPayload(payload) {
5482
5502
  if (payload.action !== void 0 && payload.action !== "subscribe" && payload.action !== "unsubscribe") {
5483
5503
  throw new Error('read-board payload has an invalid "action"');
5484
5504
  }
5485
- return { projectId: payload.projectId, action: payload.action };
5505
+ if (payload.view !== void 0 && payload.view !== "full" && payload.view !== "sessions") {
5506
+ throw new Error('read-board payload has an invalid "view"');
5507
+ }
5508
+ return { projectId: payload.projectId, action: payload.action, view: payload.view };
5486
5509
  }
5487
5510
  function parseReadDiffResponsePayload(payload) {
5488
5511
  if (!isRecord(payload)) throw new Error("read-diff response must be an object");
@@ -5630,11 +5653,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5630
5653
  }
5631
5654
  }
5632
5655
 
5656
+ // src/pairing/relay-address.ts
5657
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5658
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5659
+ function isSecureRelayAddress(relayAddress) {
5660
+ if (relayAddress.startsWith("wss://")) return true;
5661
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5662
+ if (!relayAddress.startsWith(prefix)) return false;
5663
+ const boundaryChar = relayAddress.charAt(prefix.length);
5664
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5665
+ });
5666
+ }
5667
+
5633
5668
  // src/pairing/qr.ts
5634
5669
  var PAIRING_URI_SCHEME = "kangentic-pair";
5635
5670
  var PAYLOAD_VERSION = 1;
5636
5671
  var PAIRING_TOKEN_LENGTH = 32;
5637
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5638
5672
  var MAX_PAYLOAD_LENGTH = 4096;
5639
5673
  function writeUint32(value) {
5640
5674
  const bytes = new Uint8Array(4);
@@ -5712,6 +5746,27 @@ function decodePairingQrPayload(uri) {
5712
5746
  return { desktopStaticPublicKey, pairingToken, relayAddress, expiresAt, protocolVersion };
5713
5747
  }
5714
5748
 
5749
+ // src/pairing/confirm.ts
5750
+ var PAIRING_CONFIRM_PLAINTEXT = new TextEncoder().encode("kangentic-pairing-confirm-v1");
5751
+ var EMPTY_ASSOCIATED_DATA = new Uint8Array(0);
5752
+ function sealPairingConfirm(initiatorToResponder) {
5753
+ return initiatorToResponder.encryptWithAd(EMPTY_ASSOCIATED_DATA, PAIRING_CONFIRM_PLAINTEXT);
5754
+ }
5755
+ function openPairingConfirm(initiatorToResponder, frame) {
5756
+ let plaintext;
5757
+ try {
5758
+ plaintext = initiatorToResponder.decryptWithAd(EMPTY_ASSOCIATED_DATA, frame);
5759
+ } catch {
5760
+ return false;
5761
+ }
5762
+ if (plaintext.length !== PAIRING_CONFIRM_PLAINTEXT.length) return false;
5763
+ let mismatch = 0;
5764
+ for (let index = 0; index < plaintext.length; index += 1) {
5765
+ mismatch |= plaintext[index] ^ PAIRING_CONFIRM_PLAINTEXT[index];
5766
+ }
5767
+ return mismatch === 0;
5768
+ }
5769
+
5715
5770
  // src/roster/roster.ts
5716
5771
  function uint32LengthPrefixed(bytes) {
5717
5772
  const prefix = new Uint8Array(4);
@@ -5751,6 +5806,15 @@ function capabilitySetToRosterCapabilities(capabilities) {
5751
5806
  return capabilitySetToArray(capabilities);
5752
5807
  }
5753
5808
 
5809
+ // src/roster/fingerprint.ts
5810
+ function formatKeyFingerprint(publicKeyHex, groups = 4) {
5811
+ const clusters = [];
5812
+ for (let index = 0; index < groups; index += 1) {
5813
+ clusters.push(publicKeyHex.slice(index * 4, index * 4 + 4));
5814
+ }
5815
+ return clusters.join(" ");
5816
+ }
5817
+
5754
5818
  // src/capabilities/board-tools.ts
5755
5819
  var BOARD_TOOL_READ_NAMES = [
5756
5820
  "search_tasks",
@@ -5765,7 +5829,8 @@ var BOARD_TOOL_READ_NAMES = [
5765
5829
  "get_handoff_context",
5766
5830
  "get_transcript",
5767
5831
  "get_session_files",
5768
- "get_session_events"
5832
+ "get_session_events",
5833
+ "get_activity_intervals"
5769
5834
  ];
5770
5835
  var BOARD_TOOL_WRITE_NAMES = [
5771
5836
  "create_task",
@@ -5815,6 +5880,10 @@ function parseActivityEventPayload(payload) {
5815
5880
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5816
5881
  return { type: "session-ended", intentional: payload.intentional };
5817
5882
  }
5883
+ case "message-preview": {
5884
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
5885
+ return { type: "message-preview", text: payload.text };
5886
+ }
5818
5887
  default:
5819
5888
  throw new Error('activity payload has an unknown "type"');
5820
5889
  }
@@ -5880,6 +5949,7 @@ export {
5880
5949
  KK_PATTERN,
5881
5950
  MAX_DECODED_LENGTH,
5882
5951
  MAX_FRAME_LENGTH,
5952
+ MAX_RELAY_ADDRESS_LENGTH,
5883
5953
  PAIRING_URI_SCHEME,
5884
5954
  PROTOCOL_VERSION,
5885
5955
  PUSH_CATEGORIES,
@@ -5905,6 +5975,7 @@ export {
5905
5975
  encodeProtocolVersion,
5906
5976
  encodeRosterEntryForSigning,
5907
5977
  findRosterDevice,
5978
+ formatKeyFingerprint,
5908
5979
  generateEd25519KeyPair,
5909
5980
  generateX25519KeyPair,
5910
5981
  hexToBytes,
@@ -5918,6 +5989,8 @@ export {
5918
5989
  isPushCategory,
5919
5990
  isRecord,
5920
5991
  isRosterEntryExpired,
5992
+ isSecureRelayAddress,
5993
+ openPairingConfirm,
5921
5994
  openPushEnvelope,
5922
5995
  parseActivityEventPayload,
5923
5996
  parseBacklogItemWire,
@@ -5939,6 +6012,7 @@ export {
5939
6012
  parseTranscriptWindowResponsePayload,
5940
6013
  randomBytes4 as randomBytes,
5941
6014
  rosterDeviceCapabilitySet,
6015
+ sealPairingConfirm,
5942
6016
  sealPushEnvelope,
5943
6017
  signRosterEntry,
5944
6018
  unwrapSessionFrame,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The pairing-confirm frame: one AEAD-sealed message the phone sends after
3
+ * the human taps Confirm, over the transport key the completed IKpsk0
4
+ * handshake just produced (HandshakeState.writeMessage's `split` result -
5
+ * see pairing-handshake.ts). This is a LIVENESS AND INTENT signal, not the
6
+ * pairing's security boundary: a relay-in-the-middle already holds a valid
7
+ * session with the desktop and could forge any frame it wants. The actual
8
+ * defense is unchanged - the human compares the desktop's SAS digits
9
+ * against the phone's, and only taps Confirm on a match.
10
+ *
11
+ * What this frame buys instead: both peers derive their transport keys
12
+ * from the SAME completed handshake transcript (getHandshakeHash/split are
13
+ * both functions of the transcript), so if the sealed frame opens under the
14
+ * desktop's key, the two transcripts necessarily agree - which means the
15
+ * SAS the human compared necessarily agreed too. The AEAD open IS the
16
+ * desktop's SAS verification; that is why the frame carries no digits of
17
+ * its own, and why a failed open is treated as a mismatch/failure, not a
18
+ * silent no-op.
19
+ *
20
+ * There is no reject frame. Backing out of the ceremony on the phone closes
21
+ * the transport without sending this frame; close-without-confirm is the
22
+ * rejection, observed by the desktop as its pairing timeout (see
23
+ * pairing-service.ts's phase timers).
24
+ */
25
+ import { CipherState } from '../crypto/noise/cipher-state';
26
+ /**
27
+ * Seals the confirm frame under the initiator-to-responder cipher state
28
+ * (index 0 of HandshakeState's `split` pair, by Noise Protocol Framework
29
+ * convention). Called once per ceremony, so nonce reuse under this key is
30
+ * not a concern - the key itself is fresh per handshake transcript.
31
+ */
32
+ export declare function sealPairingConfirm(initiatorToResponder: CipherState): Uint8Array;
33
+ /**
34
+ * Opens a received confirm frame. Returns false (never throws) on a
35
+ * tampered/wrong-key ciphertext or an unexpected plaintext, so the caller
36
+ * can treat "could not verify" uniformly whether the failure was
37
+ * cryptographic or structural.
38
+ */
39
+ export declare function openPairingConfirm(initiatorToResponder: CipherState, frame: Uint8Array): boolean;
@@ -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;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Formats a device's static public key as a short, human-comparable
3
+ * fingerprint - the identity the pairing ceremony actually bound and the
4
+ * SAS actually confirmed, unlike a name (not unique) or a hardware id
5
+ * (unavailable on modern Android, permanent PII). Matches
6
+ * kangentic-mobile's src/screens/usePairedDesktopInfo.ts formatKeyFingerprint
7
+ * exactly, so a user can hold the phone next to the desktop and compare
8
+ * directly: front-only slice, no ellipsis, no case change.
9
+ */
10
+ export declare function formatKeyFingerprint(publicKeyHex: string, groups?: number): string;
@@ -32,6 +32,20 @@ export interface ReadStreamRequestPayload {
32
32
  beforeIndex?: number;
33
33
  /** transcript-window only: maximum entries wanted (the desktop may cap this and may return fewer). */
34
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;
35
49
  }
36
50
  /**
37
51
  * Mirrors the desktop's SessionStatus. 'suspended' is a registered-but-
@@ -85,10 +99,41 @@ export interface TranscriptWindowResponsePayload {
85
99
  }
86
100
  /** Phone-side narrowing of a transcript-window response. Throws on a malformed required field. */
87
101
  export declare function parseTranscriptWindowResponsePayload(payload: JsonValue): TranscriptWindowResponsePayload;
102
+ /**
103
+ * Which projection of a board the phone wants.
104
+ *
105
+ * - 'sessions': columns, per-column task counts, and only the non-archived
106
+ * tasks that carry a `session_id`. This is what an agent feed renders: it
107
+ * watches every project at once but only ever draws the handful of tasks
108
+ * with a live agent on them.
109
+ * - 'full': every non-archived task, for the one project whose board the user
110
+ * actually has open.
111
+ *
112
+ * Neither carries the backlog. Sending the field at all is the signal that
113
+ * this phone tolerates a response without one; a phone that omits it gets the
114
+ * pre-0.9.0 payload unchanged.
115
+ */
116
+ export type ReadBoardView = 'full' | 'sessions';
88
117
  export interface ReadBoardRequestPayload {
89
118
  projectId?: string;
90
119
  /** Defaults to 'subscribe' when omitted. 'unsubscribe' only has an effect when projectId is set - the no-projectId project list is a one-shot read with no live feed to tear down. */
91
120
  action?: 'subscribe' | 'unsubscribe';
121
+ /**
122
+ * subscribe only: the projection wanted (protocol 0.9.0). Omitted means the
123
+ * legacy full board WITH the backlog.
124
+ *
125
+ * Measured on a 15-project desktop, the full boards were 63 kB compressed
126
+ * of a ~96 kB cold start, of which 23 kB was a backlog no phone has ever
127
+ * rendered and another 30 kB was tasks with no session on them. Worse, a
128
+ * board subscription re-snapshots on every board change, so that payload
129
+ * repeats for as long as the phone is connected. The 'sessions' projection
130
+ * is 12 kB for the same 15 projects.
131
+ *
132
+ * A pre-0.9.0 desktop ignores the field and returns a full board with no
133
+ * `view` echo, which is exactly how a 0.9.0 phone detects that it must not
134
+ * treat the snapshot as filtered.
135
+ */
136
+ view?: ReadBoardView;
92
137
  }
93
138
  export interface ReadBoardProjectSummary {
94
139
  id: string;
@@ -109,8 +154,10 @@ export interface ReadBoardProjectListResponsePayload {
109
154
  export interface ReadBoardSnapshotResponsePayload {
110
155
  projectId: string;
111
156
  columns: BoardColumnWire[];
157
+ /** Non-archived tasks; filtered to the ones carrying a `session_id` when `view` is 'sessions'. */
112
158
  tasks: BoardTaskWire[];
113
- backlog: BacklogItemWire[];
159
+ /** Absent whenever the request carried a `view` (protocol 0.9.0) - no phone has ever rendered it. */
160
+ backlog?: BacklogItemWire[];
114
161
  /** The project's accent color ("#rrggbb"); same semantics as ReadBoardProjectSummary.color. Absent from pre-0.5.0 desktops. */
115
162
  projectColor?: string;
116
163
  /**
@@ -119,6 +166,18 @@ export interface ReadBoardSnapshotResponsePayload {
119
166
  * true, the desktop default).
120
167
  */
121
168
  showTicketNumbers?: boolean;
169
+ /**
170
+ * The projection actually applied, echoed back. Absent from a pre-0.9.0
171
+ * desktop, which always sends a full board - so treat an absent `view` as
172
+ * 'full', never as "filtered, unknown how".
173
+ */
174
+ view?: ReadBoardView;
175
+ /**
176
+ * Non-archived task count per column id, sent with a 'sessions' view because
177
+ * `tasks` is filtered and a count taken from it would be wrong. Appending a
178
+ * card to a column needs the real length.
179
+ */
180
+ taskCountsByColumnId?: Record<string, number>;
122
181
  }
123
182
  export type ReadBoardResponsePayload = ReadBoardProjectListResponsePayload | ReadBoardSnapshotResponsePayload;
124
183
  /** Phone-side narrowing of a read-board response (project list or board snapshot). Throws on a malformed required field. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangentic/protocol",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",