@kangentic/protocol 0.8.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
package/dist/index.cjs CHANGED
@@ -58,6 +58,7 @@ __export(index_exports, {
58
58
  encodeProtocolVersion: () => encodeProtocolVersion,
59
59
  encodeRosterEntryForSigning: () => encodeRosterEntryForSigning,
60
60
  findRosterDevice: () => findRosterDevice,
61
+ formatKeyFingerprint: () => formatKeyFingerprint,
61
62
  generateEd25519KeyPair: () => generateEd25519KeyPair,
62
63
  generateX25519KeyPair: () => generateX25519KeyPair,
63
64
  hexToBytes: () => hexToBytes,
@@ -72,6 +73,7 @@ __export(index_exports, {
72
73
  isRecord: () => isRecord,
73
74
  isRosterEntryExpired: () => isRosterEntryExpired,
74
75
  isSecureRelayAddress: () => isSecureRelayAddress,
76
+ openPairingConfirm: () => openPairingConfirm,
75
77
  openPushEnvelope: () => openPushEnvelope,
76
78
  parseActivityEventPayload: () => parseActivityEventPayload,
77
79
  parseBacklogItemWire: () => parseBacklogItemWire,
@@ -93,6 +95,7 @@ __export(index_exports, {
93
95
  parseTranscriptWindowResponsePayload: () => parseTranscriptWindowResponsePayload,
94
96
  randomBytes: () => randomBytes4,
95
97
  rosterDeviceCapabilitySet: () => rosterDeviceCapabilitySet,
98
+ sealPairingConfirm: () => sealPairingConfirm,
96
99
  sealPushEnvelope: () => sealPushEnvelope,
97
100
  signRosterEntry: () => signRosterEntry,
98
101
  unwrapSessionFrame: () => unwrapSessionFrame,
@@ -5579,19 +5582,34 @@ function parseReadBoardResponsePayload(payload) {
5579
5582
  if (typeof payload.projectId !== "string") throw new Error('read-board response is missing "projectId"');
5580
5583
  if (!Array.isArray(payload.columns)) throw new Error('read-board response is missing "columns"');
5581
5584
  if (!Array.isArray(payload.tasks)) throw new Error('read-board response is missing "tasks"');
5582
- 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
+ }
5583
5588
  if (payload.showTicketNumbers !== void 0 && typeof payload.showTicketNumbers !== "boolean") {
5584
5589
  throw new Error('read-board response has a non-boolean "showTicketNumbers"');
5585
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
+ }
5586
5594
  return {
5587
5595
  projectId: payload.projectId,
5588
5596
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5589
5597
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5590
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5598
+ ...payload.backlog !== void 0 ? { backlog: payload.backlog.map((item) => parseBacklogItemWire(item)) } : {},
5591
5599
  ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {},
5592
- ...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) } : {}
5593
5603
  };
5594
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
+ }
5595
5613
  function parseReadBoardRequestPayload(payload) {
5596
5614
  if (!isRecord(payload)) throw new Error("read-board payload must be an object");
5597
5615
  if (payload.projectId !== void 0 && typeof payload.projectId !== "string") {
@@ -5600,7 +5618,10 @@ function parseReadBoardRequestPayload(payload) {
5600
5618
  if (payload.action !== void 0 && payload.action !== "subscribe" && payload.action !== "unsubscribe") {
5601
5619
  throw new Error('read-board payload has an invalid "action"');
5602
5620
  }
5603
- 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 };
5604
5625
  }
5605
5626
  function parseReadDiffResponsePayload(payload) {
5606
5627
  if (!isRecord(payload)) throw new Error("read-diff response must be an object");
@@ -5841,6 +5862,27 @@ function decodePairingQrPayload(uri) {
5841
5862
  return { desktopStaticPublicKey, pairingToken, relayAddress, expiresAt, protocolVersion };
5842
5863
  }
5843
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
+
5844
5886
  // src/roster/roster.ts
5845
5887
  function uint32LengthPrefixed(bytes) {
5846
5888
  const prefix = new Uint8Array(4);
@@ -5880,6 +5922,15 @@ function capabilitySetToRosterCapabilities(capabilities) {
5880
5922
  return capabilitySetToArray(capabilities);
5881
5923
  }
5882
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
+
5883
5934
  // src/capabilities/board-tools.ts
5884
5935
  var BOARD_TOOL_READ_NAMES = [
5885
5936
  "search_tasks",
@@ -6041,6 +6092,7 @@ function isBridgeEvent(value) {
6041
6092
  encodeProtocolVersion,
6042
6093
  encodeRosterEntryForSigning,
6043
6094
  findRosterDevice,
6095
+ formatKeyFingerprint,
6044
6096
  generateEd25519KeyPair,
6045
6097
  generateX25519KeyPair,
6046
6098
  hexToBytes,
@@ -6055,6 +6107,7 @@ function isBridgeEvent(value) {
6055
6107
  isRecord,
6056
6108
  isRosterEntryExpired,
6057
6109
  isSecureRelayAddress,
6110
+ openPairingConfirm,
6058
6111
  openPushEnvelope,
6059
6112
  parseActivityEventPayload,
6060
6113
  parseBacklogItemWire,
@@ -6076,6 +6129,7 @@ function isBridgeEvent(value) {
6076
6129
  parseTranscriptWindowResponsePayload,
6077
6130
  randomBytes,
6078
6131
  rosterDeviceCapabilitySet,
6132
+ sealPairingConfirm,
6079
6133
  sealPushEnvelope,
6080
6134
  signRosterEntry,
6081
6135
  unwrapSessionFrame,
package/dist/index.d.ts CHANGED
@@ -12,11 +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';
18
19
  export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './pairing/relay-address';
19
20
  export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
21
+ export { formatKeyFingerprint } from './roster/fingerprint';
20
22
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
21
23
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
22
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
@@ -5466,19 +5466,34 @@ function parseReadBoardResponsePayload(payload) {
5466
5466
  if (typeof payload.projectId !== "string") throw new Error('read-board response is missing "projectId"');
5467
5467
  if (!Array.isArray(payload.columns)) throw new Error('read-board response is missing "columns"');
5468
5468
  if (!Array.isArray(payload.tasks)) throw new Error('read-board response is missing "tasks"');
5469
- 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
+ }
5470
5472
  if (payload.showTicketNumbers !== void 0 && typeof payload.showTicketNumbers !== "boolean") {
5471
5473
  throw new Error('read-board response has a non-boolean "showTicketNumbers"');
5472
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
+ }
5473
5478
  return {
5474
5479
  projectId: payload.projectId,
5475
5480
  columns: payload.columns.map((column) => parseBoardColumnWire(column)),
5476
5481
  tasks: payload.tasks.map((task) => parseBoardTaskWire(task)),
5477
- backlog: payload.backlog.map((item) => parseBacklogItemWire(item)),
5482
+ ...payload.backlog !== void 0 ? { backlog: payload.backlog.map((item) => parseBacklogItemWire(item)) } : {},
5478
5483
  ...payload.projectColor !== void 0 ? { projectColor: parseAccentColor(payload.projectColor, "read-board snapshot") } : {},
5479
- ...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) } : {}
5480
5487
  };
5481
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
+ }
5482
5497
  function parseReadBoardRequestPayload(payload) {
5483
5498
  if (!isRecord(payload)) throw new Error("read-board payload must be an object");
5484
5499
  if (payload.projectId !== void 0 && typeof payload.projectId !== "string") {
@@ -5487,7 +5502,10 @@ function parseReadBoardRequestPayload(payload) {
5487
5502
  if (payload.action !== void 0 && payload.action !== "subscribe" && payload.action !== "unsubscribe") {
5488
5503
  throw new Error('read-board payload has an invalid "action"');
5489
5504
  }
5490
- 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 };
5491
5509
  }
5492
5510
  function parseReadDiffResponsePayload(payload) {
5493
5511
  if (!isRecord(payload)) throw new Error("read-diff response must be an object");
@@ -5728,6 +5746,27 @@ function decodePairingQrPayload(uri) {
5728
5746
  return { desktopStaticPublicKey, pairingToken, relayAddress, expiresAt, protocolVersion };
5729
5747
  }
5730
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
+
5731
5770
  // src/roster/roster.ts
5732
5771
  function uint32LengthPrefixed(bytes) {
5733
5772
  const prefix = new Uint8Array(4);
@@ -5767,6 +5806,15 @@ function capabilitySetToRosterCapabilities(capabilities) {
5767
5806
  return capabilitySetToArray(capabilities);
5768
5807
  }
5769
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
+
5770
5818
  // src/capabilities/board-tools.ts
5771
5819
  var BOARD_TOOL_READ_NAMES = [
5772
5820
  "search_tasks",
@@ -5927,6 +5975,7 @@ export {
5927
5975
  encodeProtocolVersion,
5928
5976
  encodeRosterEntryForSigning,
5929
5977
  findRosterDevice,
5978
+ formatKeyFingerprint,
5930
5979
  generateEd25519KeyPair,
5931
5980
  generateX25519KeyPair,
5932
5981
  hexToBytes,
@@ -5941,6 +5990,7 @@ export {
5941
5990
  isRecord,
5942
5991
  isRosterEntryExpired,
5943
5992
  isSecureRelayAddress,
5993
+ openPairingConfirm,
5944
5994
  openPushEnvelope,
5945
5995
  parseActivityEventPayload,
5946
5996
  parseBacklogItemWire,
@@ -5962,6 +6012,7 @@ export {
5962
6012
  parseTranscriptWindowResponsePayload,
5963
6013
  randomBytes4 as randomBytes,
5964
6014
  rosterDeviceCapabilitySet,
6015
+ sealPairingConfirm,
5965
6016
  sealPushEnvelope,
5966
6017
  signRosterEntry,
5967
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;
@@ -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;
@@ -99,10 +99,41 @@ export interface TranscriptWindowResponsePayload {
99
99
  }
100
100
  /** Phone-side narrowing of a transcript-window response. Throws on a malformed required field. */
101
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';
102
117
  export interface ReadBoardRequestPayload {
103
118
  projectId?: string;
104
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. */
105
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;
106
137
  }
107
138
  export interface ReadBoardProjectSummary {
108
139
  id: string;
@@ -123,8 +154,10 @@ export interface ReadBoardProjectListResponsePayload {
123
154
  export interface ReadBoardSnapshotResponsePayload {
124
155
  projectId: string;
125
156
  columns: BoardColumnWire[];
157
+ /** Non-archived tasks; filtered to the ones carrying a `session_id` when `view` is 'sessions'. */
126
158
  tasks: BoardTaskWire[];
127
- backlog: BacklogItemWire[];
159
+ /** Absent whenever the request carried a `view` (protocol 0.9.0) - no phone has ever rendered it. */
160
+ backlog?: BacklogItemWire[];
128
161
  /** The project's accent color ("#rrggbb"); same semantics as ReadBoardProjectSummary.color. Absent from pre-0.5.0 desktops. */
129
162
  projectColor?: string;
130
163
  /**
@@ -133,6 +166,18 @@ export interface ReadBoardSnapshotResponsePayload {
133
166
  * true, the desktop default).
134
167
  */
135
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>;
136
181
  }
137
182
  export type ReadBoardResponsePayload = ReadBoardProjectListResponsePayload | ReadBoardSnapshotResponsePayload;
138
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.8.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",