@kangentic/protocol 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  * this protocol surface honest: a tool renamed or removed desktop-side
14
14
  * fails that test instead of silently breaking phones.
15
15
  */
16
- export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events"];
16
+ export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events", "get_activity_intervals"];
17
17
  export declare const BOARD_TOOL_WRITE_NAMES: readonly ["create_task", "update_task", "delete_task", "link_pr", "remove_attachment", "update_column", "create_backlog_task", "promote_backlog", "update_backlog_item", "delete_backlog_item"];
18
18
  export type BoardToolReadName = (typeof BOARD_TOOL_READ_NAMES)[number];
19
19
  export type BoardToolWriteName = (typeof BOARD_TOOL_WRITE_NAMES)[number];
@@ -69,6 +69,24 @@ export type ActivityEventPayload = {
69
69
  | {
70
70
  type: 'session-ended';
71
71
  intentional: boolean;
72
+ }
73
+ /**
74
+ * The agent's most recent assistant message, already collapsed to a short
75
+ * plain-text preview, pushed whenever it changes.
76
+ *
77
+ * A phone's session list renders exactly one line per session. Deriving it
78
+ * client-side cost a transcript-window request per session (measured 2.3 to
79
+ * 34.6 KB each, to keep a string under 200 characters) plus 0.7 to 3.8
80
+ * seconds of desktop work per request. The desktop already resolves the
81
+ * transcript to compute its delta pushes, so it can carry the line for
82
+ * free on a feed the phone is receiving anyway.
83
+ *
84
+ * Absent from pre-0.8.0 desktops; a phone that sees none should fall back
85
+ * to whatever it can derive locally rather than showing nothing.
86
+ */
87
+ | {
88
+ type: 'message-preview';
89
+ text: string;
72
90
  };
73
91
  export interface ActivityEvent {
74
92
  kind: 'activity';
@@ -115,11 +115,19 @@ export interface TerminalDimensionsWire {
115
115
  }
116
116
  /** Mirrors the desktop's ActivityState. 'permission' means the agent paused for user approval (incl. AskUserQuestion / ExitPlanMode pauses). */
117
117
  export type ActivityStateWire = 'thinking' | 'idle' | 'permission';
118
- /** Mirrors the desktop's ActivityReason discriminated union. */
118
+ /**
119
+ * Mirrors the desktop's ActivityReason discriminated union. `since` (epoch ms
120
+ * the session first needed the user) is optional, additive-field style like
121
+ * `toolCallCount`/`effort` above: an older phone parsing a payload with the
122
+ * field missing still validates, and a desktop that has not yet learned a
123
+ * `since` for a phantom-state edge case can omit it.
124
+ */
119
125
  export type ActivityReasonWire = {
120
126
  kind: 'idle';
127
+ since?: number;
121
128
  } | {
122
129
  kind: 'permission';
130
+ since?: number;
123
131
  } | {
124
132
  kind: 'tool';
125
133
  pendingCount: number;
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  KK_PATTERN: () => KK_PATTERN,
33
33
  MAX_DECODED_LENGTH: () => MAX_DECODED_LENGTH,
34
34
  MAX_FRAME_LENGTH: () => MAX_FRAME_LENGTH,
35
+ MAX_RELAY_ADDRESS_LENGTH: () => MAX_RELAY_ADDRESS_LENGTH,
35
36
  PAIRING_URI_SCHEME: () => PAIRING_URI_SCHEME,
36
37
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
37
38
  PUSH_CATEGORIES: () => PUSH_CATEGORIES,
@@ -70,6 +71,7 @@ __export(index_exports, {
70
71
  isPushCategory: () => isPushCategory,
71
72
  isRecord: () => isRecord,
72
73
  isRosterEntryExpired: () => isRosterEntryExpired,
74
+ isSecureRelayAddress: () => isSecureRelayAddress,
73
75
  openPushEnvelope: () => openPushEnvelope,
74
76
  parseActivityEventPayload: () => parseActivityEventPayload,
75
77
  parseBacklogItemWire: () => parseBacklogItemWire,
@@ -5317,6 +5319,7 @@ function isActivityReasonWire(value) {
5317
5319
  switch (value.kind) {
5318
5320
  case "idle":
5319
5321
  case "permission":
5322
+ return value.since === void 0 || typeof value.since === "number";
5320
5323
  case "turn-active":
5321
5324
  return true;
5322
5325
  case "tool":
@@ -5545,6 +5548,10 @@ function parseReadStreamRequestPayload(payload) {
5545
5548
  }
5546
5549
  request.limit = payload.limit;
5547
5550
  }
5551
+ if (payload.terminal !== void 0) {
5552
+ if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
5553
+ request.terminal = payload.terminal;
5554
+ }
5548
5555
  return request;
5549
5556
  }
5550
5557
  var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
@@ -5741,11 +5748,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5741
5748
  }
5742
5749
  }
5743
5750
 
5751
+ // src/pairing/relay-address.ts
5752
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5753
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5754
+ function isSecureRelayAddress(relayAddress) {
5755
+ if (relayAddress.startsWith("wss://")) return true;
5756
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5757
+ if (!relayAddress.startsWith(prefix)) return false;
5758
+ const boundaryChar = relayAddress.charAt(prefix.length);
5759
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5760
+ });
5761
+ }
5762
+
5744
5763
  // src/pairing/qr.ts
5745
5764
  var PAIRING_URI_SCHEME = "kangentic-pair";
5746
5765
  var PAYLOAD_VERSION = 1;
5747
5766
  var PAIRING_TOKEN_LENGTH = 32;
5748
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5749
5767
  var MAX_PAYLOAD_LENGTH = 4096;
5750
5768
  function writeUint32(value) {
5751
5769
  const bytes = new Uint8Array(4);
@@ -5876,7 +5894,8 @@ var BOARD_TOOL_READ_NAMES = [
5876
5894
  "get_handoff_context",
5877
5895
  "get_transcript",
5878
5896
  "get_session_files",
5879
- "get_session_events"
5897
+ "get_session_events",
5898
+ "get_activity_intervals"
5880
5899
  ];
5881
5900
  var BOARD_TOOL_WRITE_NAMES = [
5882
5901
  "create_task",
@@ -5926,6 +5945,10 @@ function parseActivityEventPayload(payload) {
5926
5945
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5927
5946
  return { type: "session-ended", intentional: payload.intentional };
5928
5947
  }
5948
+ case "message-preview": {
5949
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
5950
+ return { type: "message-preview", text: payload.text };
5951
+ }
5929
5952
  default:
5930
5953
  throw new Error('activity payload has an unknown "type"');
5931
5954
  }
@@ -5992,6 +6015,7 @@ function isBridgeEvent(value) {
5992
6015
  KK_PATTERN,
5993
6016
  MAX_DECODED_LENGTH,
5994
6017
  MAX_FRAME_LENGTH,
6018
+ MAX_RELAY_ADDRESS_LENGTH,
5995
6019
  PAIRING_URI_SCHEME,
5996
6020
  PROTOCOL_VERSION,
5997
6021
  PUSH_CATEGORIES,
@@ -6030,6 +6054,7 @@ function isBridgeEvent(value) {
6030
6054
  isPushCategory,
6031
6055
  isRecord,
6032
6056
  isRosterEntryExpired,
6057
+ isSecureRelayAddress,
6033
6058
  openPushEnvelope,
6034
6059
  parseActivityEventPayload,
6035
6060
  parseBacklogItemWire,
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { SessionFrameKind, wrapSessionFrame, unwrapSessionFrame } from './wire/s
15
15
  export { parseCapabilityRequestPayload, parseReadStreamResponsePayload, parseReadBoardResponsePayload, parseReadDiffResponsePayload, parseTranscriptWindowResponsePayload, parseRegisterPushRequestPayload, type CapabilityRequestPayloadMap, type CapabilityResponsePayloadMap, type ReadStreamRequestPayload, type ReadStreamResponsePayload, type ReadStreamSessionStatusWire, type TranscriptWindowResponsePayload, type ReadBoardRequestPayload, type ReadBoardProjectSummary, type ReadBoardProjectListResponsePayload, type ReadBoardSnapshotResponsePayload, type ReadBoardResponsePayload, type ReadDiffScope, type ReadDiffRequestPayload, type ReadDiffResponsePayload, type SendUserMessageRequestPayload, type SendUserMessageResponsePayload, type MoveTaskRequestPayload, type MoveTaskResponsePayload, type AnswerPermissionPromptRequestPayload, type AnswerPermissionPromptResponsePayload, type InteractiveTerminalRequestPayload, type InteractiveTerminalResponsePayload, type BoardToolRequestPayload, type BoardToolResponsePayload, type RegisterPushRequestPayload, type RegisterPushResponsePayload, } from './wire/payloads';
16
16
  export { PUSH_CATEGORIES, isPushCategory, parsePushEnvelopePlaintext, sealPushEnvelope, openPushEnvelope, PUSH_ENVELOPE_MAX_AGE_MS, PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS, type PushCategory, type PushEnvelopePlaintext, } from './crypto/push-envelope';
17
17
  export { PAIRING_URI_SCHEME, encodePairingQrPayload, decodePairingQrPayload, type PairingQrPayload, } from './pairing/qr';
18
+ export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './pairing/relay-address';
18
19
  export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
19
20
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
20
21
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
package/dist/index.mjs CHANGED
@@ -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}$/;
@@ -5630,11 +5635,22 @@ function parseCapabilityRequestPayload(verb, payload) {
5630
5635
  }
5631
5636
  }
5632
5637
 
5638
+ // src/pairing/relay-address.ts
5639
+ var MAX_RELAY_ADDRESS_LENGTH = 512;
5640
+ var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
5641
+ function isSecureRelayAddress(relayAddress) {
5642
+ if (relayAddress.startsWith("wss://")) return true;
5643
+ return LOOPBACK_WS_PREFIXES.some((prefix) => {
5644
+ if (!relayAddress.startsWith(prefix)) return false;
5645
+ const boundaryChar = relayAddress.charAt(prefix.length);
5646
+ return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
5647
+ });
5648
+ }
5649
+
5633
5650
  // src/pairing/qr.ts
5634
5651
  var PAIRING_URI_SCHEME = "kangentic-pair";
5635
5652
  var PAYLOAD_VERSION = 1;
5636
5653
  var PAIRING_TOKEN_LENGTH = 32;
5637
- var MAX_RELAY_ADDRESS_LENGTH = 512;
5638
5654
  var MAX_PAYLOAD_LENGTH = 4096;
5639
5655
  function writeUint32(value) {
5640
5656
  const bytes = new Uint8Array(4);
@@ -5765,7 +5781,8 @@ var BOARD_TOOL_READ_NAMES = [
5765
5781
  "get_handoff_context",
5766
5782
  "get_transcript",
5767
5783
  "get_session_files",
5768
- "get_session_events"
5784
+ "get_session_events",
5785
+ "get_activity_intervals"
5769
5786
  ];
5770
5787
  var BOARD_TOOL_WRITE_NAMES = [
5771
5788
  "create_task",
@@ -5815,6 +5832,10 @@ function parseActivityEventPayload(payload) {
5815
5832
  if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
5816
5833
  return { type: "session-ended", intentional: payload.intentional };
5817
5834
  }
5835
+ case "message-preview": {
5836
+ if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
5837
+ return { type: "message-preview", text: payload.text };
5838
+ }
5818
5839
  default:
5819
5840
  throw new Error('activity payload has an unknown "type"');
5820
5841
  }
@@ -5880,6 +5901,7 @@ export {
5880
5901
  KK_PATTERN,
5881
5902
  MAX_DECODED_LENGTH,
5882
5903
  MAX_FRAME_LENGTH,
5904
+ MAX_RELAY_ADDRESS_LENGTH,
5883
5905
  PAIRING_URI_SCHEME,
5884
5906
  PROTOCOL_VERSION,
5885
5907
  PUSH_CATEGORIES,
@@ -5918,6 +5940,7 @@ export {
5918
5940
  isPushCategory,
5919
5941
  isRecord,
5920
5942
  isRosterEntryExpired,
5943
+ isSecureRelayAddress,
5921
5944
  openPushEnvelope,
5922
5945
  parseActivityEventPayload,
5923
5946
  parseBacklogItemWire,
@@ -1,3 +1,4 @@
1
+ export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './relay-address';
1
2
  export declare const PAIRING_URI_SCHEME = "kangentic-pair";
2
3
  export interface PairingQrPayload {
3
4
  desktopStaticPublicKey: Uint8Array;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Relay address rules shared, byte-for-byte, with the mobile app's
3
+ * src/pairing/qr.ts. This module imports nothing on purpose: it is
4
+ * deep-imported by the desktop's src/shared/relay.ts, which the RENDERER
5
+ * bundles, and pulling in the rest of this package (crypto/primitives ->
6
+ * @noble/*) would drag Noise crypto into that bundle for no reason. Keep it
7
+ * dependency-free.
8
+ */
9
+ export declare const MAX_RELAY_ADDRESS_LENGTH = 512;
10
+ /**
11
+ * True if the phone's pairing QR scanner will accept this relay address.
12
+ * wss:// is always secure; ws:// is accepted only for loopback, since the
13
+ * pairing token doubles as the Noise PSK and is dialed verbatim as
14
+ * `?slot=`. Prefix-based (not hostname-based) with an explicit boundary
15
+ * check, so `ws://localhost.evil.com` - a hostname that merely starts with
16
+ * the string "localhost" - is rejected rather than treated as loopback.
17
+ */
18
+ export declare function isSecureRelayAddress(relayAddress: string): boolean;
@@ -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-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangentic/protocol",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Kangentic mobile bridge wire protocol: framing, Noise-based pairing and session handshakes, signed device roster, and capability verbs shared between desktop and mobile.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",