@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 +5 -1
- package/dist/capabilities/board-tools.d.ts +1 -1
- package/dist/events/event.d.ts +18 -0
- package/dist/events/payloads.d.ts +9 -1
- package/dist/index.cjs +85 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.mjs +80 -6
- package/dist/pairing/confirm.d.ts +39 -0
- package/dist/pairing/qr.d.ts +1 -0
- package/dist/pairing/relay-address.d.ts +18 -0
- package/dist/roster/fingerprint.d.ts +10 -0
- package/dist/wire/payloads.d.ts +60 -1
- package/package.json +1 -1
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];
|
package/dist/events/event.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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))
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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;
|
package/dist/pairing/qr.d.ts
CHANGED
|
@@ -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;
|
package/dist/wire/payloads.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|