@mattstack/rt-client 0.5.0 → 0.6.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/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RtResponse, RtClientOptions } from "./transport.ts";
2
- import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, BranchEnrichment, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary } from "./commands.ts";
2
+ import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, BranchEnrichment, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, BuddyStatus, PresenceRow } from "./commands.ts";
3
3
  /**
4
4
  * One repo's project open-MR store. A cold repo forces a full paginated sync
5
5
  * on the daemon side when maxAgeMs demands it, which can run tens of seconds
@@ -58,6 +58,7 @@ export declare function chatPost(a: {
58
58
  room: string;
59
59
  handle: string;
60
60
  body: string;
61
+ mentions?: string[];
61
62
  }, o?: RtClientOptions): Promise<RtResponse<{
62
63
  id: number;
63
64
  recipients: string[];
@@ -97,12 +98,15 @@ export declare function chatMessages(a: {
97
98
  export declare function chatArm(a: {
98
99
  handle: string;
99
100
  room?: string;
101
+ sessionId?: string;
100
102
  }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
101
103
  export declare function chatTouch(a: {
102
104
  handle: string;
105
+ sessionId?: string;
103
106
  }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
104
107
  export declare function chatDisarm(a: {
105
108
  handle: string;
109
+ sessionId?: string;
106
110
  }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
107
111
  export declare function chatUnreadWaking(a: {
108
112
  handle: string;
@@ -115,6 +119,57 @@ export declare function chatUnreadWaking(a: {
115
119
  maxId: number;
116
120
  }[];
117
121
  }>>;
122
+ export declare function chatSignIn(a: {
123
+ sessionId: string;
124
+ baseHandle: string;
125
+ cwd?: string;
126
+ repo?: string;
127
+ branch?: string;
128
+ pane?: string;
129
+ statusText?: string;
130
+ }, o?: RtClientOptions): Promise<RtResponse<{
131
+ handle: string;
132
+ reclaimed: boolean;
133
+ }>>;
134
+ export declare function chatSignOut(a: {
135
+ sessionId: string;
136
+ }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
137
+ export declare function chatAway(a: {
138
+ sessionId: string;
139
+ text: string;
140
+ }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
141
+ export declare function chatBack(a: {
142
+ sessionId: string;
143
+ }, o?: RtClientOptions): Promise<RtResponse<Record<string, never>>>;
144
+ export declare function chatBuddies(o?: RtClientOptions): Promise<RtResponse<{
145
+ buddies: Array<PresenceRow & {
146
+ status: BuddyStatus;
147
+ }>;
148
+ }>>;
149
+ export declare function chatPulse(a: {
150
+ sessionId: string;
151
+ cwd?: string;
152
+ repo?: string;
153
+ branch?: string;
154
+ pane?: string;
155
+ }, o?: RtClientOptions): Promise<RtResponse<{
156
+ unread: {
157
+ dms: number;
158
+ mentions: number;
159
+ rooms: number;
160
+ };
161
+ status: BuddyStatus;
162
+ }>>;
163
+ export declare function chatDm(a: {
164
+ from: string;
165
+ to: string;
166
+ body: string;
167
+ sessionId?: string;
168
+ }, o?: RtClientOptions): Promise<RtResponse<{
169
+ room: string;
170
+ id: number;
171
+ recipients: string[];
172
+ }>>;
118
173
  export declare function eventsHead(o?: RtClientOptions): Promise<RtResponse<{
119
174
  cursor: number;
120
175
  }>>;
@@ -10,24 +10,17 @@ export type Discussion = MRDetail["discussions"][number];
10
10
  export interface DemandDecl {
11
11
  client: string;
12
12
  authors: string[];
13
- /** Codeowner sections this client needs covered (spec: second demand axis). */
14
- codeownerSections?: string[];
15
13
  declaredAt: number;
16
14
  }
17
15
  export interface ProjectMRsScope {
18
16
  authors: string[];
19
17
  windowDays: number;
20
18
  uncovered: string[];
21
- /** Effective synced section union; absent from a pre-sections daemon. */
22
- sections?: string[];
23
- /** Demanded sections not yet swept for this client. */
24
- uncoveredSections?: string[];
25
19
  }
26
20
  export interface ProjectMRsData {
27
21
  mrs: Record<string, {
28
22
  pr: PullRequest;
29
23
  fetchedAt: number;
30
- codeownerSections?: string[];
31
24
  }>;
32
25
  listSyncedAt: number;
33
26
  source: "poll" | "events" | "mutation";
@@ -103,6 +96,8 @@ export interface ChatMember {
103
96
  armedAt?: number;
104
97
  cwd?: string;
105
98
  pane?: string;
99
+ /** Presence-joined by chat:who's handler — the only place this type is ever returned, and it always attaches one. */
100
+ status: BuddyStatus;
106
101
  }
107
102
  export interface ChatMessage {
108
103
  id: number;
@@ -119,6 +114,35 @@ export interface RoomSummary {
119
114
  unread: number;
120
115
  mentions: number;
121
116
  lastPostedAt?: number;
117
+ /** Set only by chat:rooms's left join against chat_dms. */
118
+ kind?: "dm";
119
+ participants?: {
120
+ a: string;
121
+ b: string;
122
+ };
123
+ /** Set only by chat:rooms's left join against chat_room_defaults; undefined for a room never stamped a default (every DM room included). */
124
+ defaultWake?: WakeMode;
125
+ }
126
+ /**
127
+ * Duplicated shape on purpose, same reasoning as ChatMember/ChatMessage
128
+ * above: mirrors lib/state/presence-store.ts's types, which rt-client
129
+ * cannot import.
130
+ */
131
+ export type BuddyStatus = "live" | "idle" | "deaf" | "offline";
132
+ export interface PresenceRow {
133
+ sessionId: string;
134
+ handle: string;
135
+ baseHandle: string;
136
+ cwd?: string;
137
+ repo?: string;
138
+ branch?: string;
139
+ pane?: string;
140
+ statusText?: string;
141
+ signedInAt: number;
142
+ lastSeenAt: number;
143
+ tailSeenAt?: number;
144
+ armedAt?: number;
145
+ signedOutAt?: number;
122
146
  }
123
147
  export type Attention = {
124
148
  needs: boolean;
@@ -156,6 +180,7 @@ export interface RunSummary {
156
180
  stages?: {
157
181
  name: string;
158
182
  status: string;
183
+ started_at: number | null;
159
184
  }[];
160
185
  }
161
186
  export interface RunAgent {
@@ -359,6 +384,7 @@ export interface Commands {
359
384
  room: string;
360
385
  handle: string;
361
386
  body: string;
387
+ mentions?: string[];
362
388
  };
363
389
  data: {
364
390
  id: number;
@@ -416,18 +442,21 @@ export interface Commands {
416
442
  payload: {
417
443
  handle: string;
418
444
  room?: string;
445
+ sessionId?: string;
419
446
  };
420
447
  data: Record<string, never>;
421
448
  };
422
449
  "chat:touch": {
423
450
  payload: {
424
451
  handle: string;
452
+ sessionId?: string;
425
453
  };
426
454
  data: Record<string, never>;
427
455
  };
428
456
  "chat:disarm": {
429
457
  payload: {
430
458
  handle: string;
459
+ sessionId?: string;
431
460
  };
432
461
  data: Record<string, never>;
433
462
  };
@@ -445,6 +474,79 @@ export interface Commands {
445
474
  }[];
446
475
  };
447
476
  };
477
+ "chat:sign-in": {
478
+ payload: {
479
+ sessionId: string;
480
+ baseHandle: string;
481
+ cwd?: string;
482
+ repo?: string;
483
+ branch?: string;
484
+ pane?: string;
485
+ statusText?: string;
486
+ };
487
+ data: {
488
+ handle: string;
489
+ reclaimed: boolean;
490
+ };
491
+ };
492
+ "chat:sign-out": {
493
+ payload: {
494
+ sessionId: string;
495
+ };
496
+ data: Record<string, never>;
497
+ };
498
+ "chat:away": {
499
+ payload: {
500
+ sessionId: string;
501
+ text: string;
502
+ };
503
+ data: Record<string, never>;
504
+ };
505
+ "chat:back": {
506
+ payload: {
507
+ sessionId: string;
508
+ };
509
+ data: Record<string, never>;
510
+ };
511
+ "chat:buddies": {
512
+ payload: Record<string, never>;
513
+ data: {
514
+ buddies: Array<PresenceRow & {
515
+ status: BuddyStatus;
516
+ }>;
517
+ };
518
+ };
519
+ /** `unread`'s three fields are disjoint and sum to the true total: `dms` is DM-room waking count; `mentions` is non-DM waking mentions; `rooms` is non-DM waking count minus those mentions (never negative). */
520
+ "chat:pulse": {
521
+ payload: {
522
+ sessionId: string;
523
+ cwd?: string;
524
+ repo?: string;
525
+ branch?: string;
526
+ pane?: string;
527
+ };
528
+ data: {
529
+ unread: {
530
+ dms: number;
531
+ mentions: number;
532
+ rooms: number;
533
+ };
534
+ status: BuddyStatus;
535
+ };
536
+ };
537
+ "chat:dm": {
538
+ payload: {
539
+ from: string;
540
+ to: string;
541
+ body: string;
542
+ sessionId?: string;
543
+ };
544
+ data: {
545
+ room: string;
546
+ id: number;
547
+ recipients: string[];
548
+ };
549
+ };
448
550
  }
449
551
  export type CommandName = keyof Commands;
450
552
  export declare const COMMAND_NAMES: readonly CommandName[];
@@ -0,0 +1,11 @@
1
+ import type { RtClientOptions } from "./transport.ts";
2
+ /**
3
+ * A daemon-down result is a successful probe, not a failure of this call —
4
+ * eventsHead already never throws (transport.ts degrades every fetch to
5
+ * `{ ok: false, error }`), so this only reshapes that envelope for callers
6
+ * who want a boolean, not `{ ok, data, error }`.
7
+ */
8
+ export declare function daemonHealth(opts?: RtClientOptions): Promise<{
9
+ reachable: boolean;
10
+ error?: string;
11
+ }>;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
- export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, eventsHead, } from "./client.ts";
3
+ export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatPulse, chatDm, eventsHead, } from "./client.ts";
4
4
  export { COMMAND_NAMES } from "./commands.ts";
5
- export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, } from "./commands.ts";
6
- export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
5
+ export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, BuddyStatus, PresenceRow, } from "./commands.ts";
6
+ export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
7
7
  export type { RelayEventType } from "./relay.ts";
8
+ export { daemonHealth } from "./health.ts";
8
9
  export { repoNameForPath } from "./repos.ts";
9
10
  export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
10
11
  export type { Scope, Provenance, ResolveOpts, Resolved, InvalidScope, ListedSetting, ExplainRow, ExpandCtx, } from "./settings/resolve.ts";
package/dist/index.js CHANGED
@@ -73,13 +73,16 @@ function chatJoin(a, o = {}) {
73
73
  payload.cwd = a.cwd;
74
74
  if (a.pane !== undefined)
75
75
  payload.pane = a.pane;
76
- return rtCommand("chat:join", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
76
+ return rtCommand("chat:join", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
77
77
  }
78
78
  function chatLeave(a, o = {}) {
79
- return rtCommand("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
79
+ return rtCommand("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
80
80
  }
81
81
  function chatPost(a, o = {}) {
82
- return rtCommand("chat:post", { room: a.room, handle: a.handle, body: a.body }, { sockPath: o.sockPath, timeoutMs: 1e4 });
82
+ const payload = { room: a.room, handle: a.handle, body: a.body };
83
+ if (a.mentions !== undefined)
84
+ payload.mentions = a.mentions;
85
+ return rtCommand("chat:post", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
83
86
  }
84
87
  function chatRead(a, o = {}) {
85
88
  const payload = { handle: a.handle };
@@ -89,19 +92,19 @@ function chatRead(a, o = {}) {
89
92
  payload.limit = a.limit;
90
93
  if (a.sinceMs !== undefined)
91
94
  payload.sinceMs = a.sinceMs;
92
- return rtCommand("chat:read", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
95
+ return rtCommand("chat:read", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
93
96
  }
94
97
  function chatRooms(a, o = {}) {
95
- return rtCommand("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
98
+ return rtCommand("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
96
99
  }
97
100
  function chatWho(a, o = {}) {
98
- return rtCommand("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: 1e4 });
101
+ return rtCommand("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
99
102
  }
100
103
  function chatMark(a, o = {}) {
101
104
  const payload = { handle: a.handle };
102
105
  if (a.room !== undefined)
103
106
  payload.room = a.room;
104
- return rtCommand("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
107
+ return rtCommand("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
105
108
  }
106
109
  function chatMessages(a, o = {}) {
107
110
  const payload = { room: a.room };
@@ -109,28 +112,80 @@ function chatMessages(a, o = {}) {
109
112
  payload.before = a.before;
110
113
  if (a.limit !== undefined)
111
114
  payload.limit = a.limit;
112
- return rtCommand("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
115
+ return rtCommand("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
113
116
  }
114
117
  function chatArm(a, o = {}) {
115
118
  const payload = { handle: a.handle };
116
119
  if (a.room !== undefined)
117
120
  payload.room = a.room;
118
- return rtCommand("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
121
+ if (a.sessionId !== undefined)
122
+ payload.sessionId = a.sessionId;
123
+ return rtCommand("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
119
124
  }
120
125
  function chatTouch(a, o = {}) {
121
- return rtCommand("chat:touch", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
126
+ const payload = { handle: a.handle };
127
+ if (a.sessionId !== undefined)
128
+ payload.sessionId = a.sessionId;
129
+ return rtCommand("chat:touch", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
122
130
  }
123
131
  function chatDisarm(a, o = {}) {
124
- return rtCommand("chat:disarm", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
132
+ const payload = { handle: a.handle };
133
+ if (a.sessionId !== undefined)
134
+ payload.sessionId = a.sessionId;
135
+ return rtCommand("chat:disarm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
125
136
  }
126
137
  function chatUnreadWaking(a, o = {}) {
127
138
  const payload = { handle: a.handle };
128
139
  if (a.room !== undefined)
129
140
  payload.room = a.room;
130
- return rtCommand("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
141
+ return rtCommand("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
142
+ }
143
+ function chatSignIn(a, o = {}) {
144
+ const payload = { sessionId: a.sessionId, baseHandle: a.baseHandle };
145
+ if (a.cwd !== undefined)
146
+ payload.cwd = a.cwd;
147
+ if (a.repo !== undefined)
148
+ payload.repo = a.repo;
149
+ if (a.branch !== undefined)
150
+ payload.branch = a.branch;
151
+ if (a.pane !== undefined)
152
+ payload.pane = a.pane;
153
+ if (a.statusText !== undefined)
154
+ payload.statusText = a.statusText;
155
+ return rtCommand("chat:sign-in", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
156
+ }
157
+ function chatSignOut(a, o = {}) {
158
+ return rtCommand("chat:sign-out", { sessionId: a.sessionId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
159
+ }
160
+ function chatAway(a, o = {}) {
161
+ return rtCommand("chat:away", { sessionId: a.sessionId, text: a.text }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
162
+ }
163
+ function chatBack(a, o = {}) {
164
+ return rtCommand("chat:back", { sessionId: a.sessionId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
165
+ }
166
+ function chatBuddies(o = {}) {
167
+ return rtCommand("chat:buddies", {}, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
168
+ }
169
+ function chatPulse(a, o = {}) {
170
+ const payload = { sessionId: a.sessionId };
171
+ if (a.cwd !== undefined)
172
+ payload.cwd = a.cwd;
173
+ if (a.repo !== undefined)
174
+ payload.repo = a.repo;
175
+ if (a.branch !== undefined)
176
+ payload.branch = a.branch;
177
+ if (a.pane !== undefined)
178
+ payload.pane = a.pane;
179
+ return rtCommand("chat:pulse", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
180
+ }
181
+ function chatDm(a, o = {}) {
182
+ const payload = { from: a.from, to: a.to, body: a.body };
183
+ if (a.sessionId !== undefined)
184
+ payload.sessionId = a.sessionId;
185
+ return rtCommand("chat:dm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
131
186
  }
132
187
  function eventsHead(o = {}) {
133
- return rtCommand("events:head", {}, { sockPath: o.sockPath, timeoutMs: 1e4 });
188
+ return rtCommand("events:head", {}, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
134
189
  }
135
190
  // src/commands.ts
136
191
  var COMMAND_NAMES = [
@@ -157,7 +212,14 @@ var COMMAND_NAMES = [
157
212
  "chat:arm",
158
213
  "chat:touch",
159
214
  "chat:disarm",
160
- "chat:unread-waking"
215
+ "chat:unread-waking",
216
+ "chat:sign-in",
217
+ "chat:sign-out",
218
+ "chat:away",
219
+ "chat:back",
220
+ "chat:buddies",
221
+ "chat:pulse",
222
+ "chat:dm"
161
223
  ];
162
224
  // src/relay.ts
163
225
  var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
@@ -202,6 +264,25 @@ function subscribe(onEvent, opts = {}) {
202
264
  } catch {}
203
265
  };
204
266
  }
267
+ function createRelay(cfg, opts = {}) {
268
+ const doSubscribe = opts.subscribeImpl ?? subscribe;
269
+ return doSubscribe((type, data) => {
270
+ if (type !== "event")
271
+ return;
272
+ const frame = data;
273
+ if (typeof frame?.topic !== "string" || !cfg.match(frame.topic))
274
+ return;
275
+ const payload = JSON.stringify(data);
276
+ try {
277
+ cfg.publish(cfg.topic, payload);
278
+ } catch {}
279
+ }, opts);
280
+ }
281
+ // src/health.ts
282
+ async function daemonHealth(opts = {}) {
283
+ const res = await eventsHead(opts);
284
+ return { reachable: res.ok, error: res.ok ? undefined : res.error };
285
+ }
205
286
  // src/repos.ts
206
287
  import { existsSync, readFileSync } from "fs";
207
288
  import { homedir as homedir2 } from "os";
@@ -509,6 +590,13 @@ var REGISTRY = [
509
590
  merge: "replace",
510
591
  description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications."
511
592
  },
593
+ {
594
+ key: "mattstack.mode",
595
+ type: "string",
596
+ scopes: ["machine"],
597
+ merge: "replace",
598
+ description: 'The machine\'s intended flavor, "dev" or "prod". Normally written by `rt settings dev-mode` after a successful handoff; a manual `rt settings set` is the blessed repair escape hatch — the daemon park loop converges on whatever this says. Unset ⇒ derived from the dev wrapper\'s presence.'
599
+ },
512
600
  {
513
601
  key: "rt.integrations",
514
602
  type: "object",
@@ -615,13 +703,6 @@ var REGISTRY = [
615
703
  merge: "replace",
616
704
  description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently."
617
705
  },
618
- {
619
- key: "board.tabs",
620
- type: "array",
621
- scopes: ["team"],
622
- merge: "replace",
623
- description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here)."
624
- },
625
706
  {
626
707
  key: "board.staleAfterDays",
627
708
  type: "number",
@@ -1549,18 +1630,27 @@ export {
1549
1630
  expandVariables,
1550
1631
  eventsHead,
1551
1632
  deriveRepoIdentity,
1633
+ daemonHealth,
1634
+ createRelay,
1552
1635
  clearIdentityMemo,
1553
1636
  chatWho,
1554
1637
  chatUnreadWaking,
1555
1638
  chatTouch,
1639
+ chatSignOut,
1640
+ chatSignIn,
1556
1641
  chatRooms,
1557
1642
  chatRead,
1643
+ chatPulse,
1558
1644
  chatPost,
1559
1645
  chatMessages,
1560
1646
  chatMark,
1561
1647
  chatLeave,
1562
1648
  chatJoin,
1649
+ chatDm,
1563
1650
  chatDisarm,
1651
+ chatBuddies,
1652
+ chatBack,
1653
+ chatAway,
1564
1654
  chatArm,
1565
1655
  allDefs,
1566
1656
  abandonRun,
package/dist/relay.d.ts CHANGED
@@ -13,3 +13,18 @@ export declare const DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
13
13
  * the daemon being down just means silence, never a crash.
14
14
  */
15
15
  export declare function subscribe(onEvent: (type: RelayEventType, data: unknown) => void, opts?: RtClientOptions): () => void;
16
+ /**
17
+ * One daemon subscription for the whole process, republished onto a
18
+ * caller-chosen pub/sub topic. Every subscriber to that topic then shares
19
+ * one relay connection instead of each opening its own — filtering here
20
+ * (rather than at each subscriber) is what keeps an unrelated event from
21
+ * making every subscriber re-render.
22
+ *
23
+ * Ported from console's `startRelay` (src/server/ws.ts) with the match
24
+ * predicate and target topic lifted to arguments.
25
+ */
26
+ export declare function createRelay(cfg: {
27
+ match: (topic: string) => boolean;
28
+ topic: string;
29
+ publish: (topic: string, data: string) => void;
30
+ }, opts?: RtClientOptions): () => void;
@@ -6,6 +6,15 @@ export interface RtResponse<T = unknown> {
6
6
  export interface RtClientOptions {
7
7
  sockPath?: string;
8
8
  wsUrl?: string;
9
+ /** Per-call override of rtCommand's own default (15s); chat's pulse wrapper needs an 800ms hook budget. */
10
+ timeoutMs?: number;
11
+ /**
12
+ * Test seam for createRelay (relay.ts): swaps the daemon subscription for
13
+ * a fake without a live WebSocket server. Typed structurally against
14
+ * relay.ts's `subscribe` rather than importing its RelayEventType, which
15
+ * would make this module depend on the one that already depends on it.
16
+ */
17
+ subscribeImpl?: (onEvent: (type: string, data: unknown) => void, opts?: RtClientOptions) => () => void;
9
18
  }
10
19
  /**
11
20
  * Display-only: a module-load snapshot for callers that just want to show
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/client.ts CHANGED
@@ -19,6 +19,8 @@ import type {
19
19
  ChatMember,
20
20
  ChatMessage,
21
21
  RoomSummary,
22
+ BuddyStatus,
23
+ PresenceRow,
22
24
  } from "./commands.ts";
23
25
 
24
26
  /**
@@ -151,21 +153,23 @@ export function chatJoin(
151
153
  if (a.wakeOn !== undefined) payload.wakeOn = a.wakeOn;
152
154
  if (a.cwd !== undefined) payload.cwd = a.cwd;
153
155
  if (a.pane !== undefined) payload.pane = a.pane;
154
- return rtCommand<{ handle: string; memberCount: number; unread: number }>("chat:join", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
156
+ return rtCommand<{ handle: string; memberCount: number; unread: number }>("chat:join", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
155
157
  }
156
158
 
157
159
  export function chatLeave(
158
160
  a: { room: string; handle: string },
159
161
  o: RtClientOptions = {},
160
162
  ): Promise<RtResponse<Record<string, never>>> {
161
- return rtCommand<Record<string, never>>("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
163
+ return rtCommand<Record<string, never>>("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
162
164
  }
163
165
 
164
166
  export function chatPost(
165
- a: { room: string; handle: string; body: string },
167
+ a: { room: string; handle: string; body: string; mentions?: string[] },
166
168
  o: RtClientOptions = {},
167
169
  ): Promise<RtResponse<{ id: number; recipients: string[] }>> {
168
- return rtCommand<{ id: number; recipients: string[] }>("chat:post", { room: a.room, handle: a.handle, body: a.body }, { sockPath: o.sockPath, timeoutMs: 10_000 });
170
+ const payload: Record<string, unknown> = { room: a.room, handle: a.handle, body: a.body };
171
+ if (a.mentions !== undefined) payload.mentions = a.mentions;
172
+ return rtCommand<{ id: number; recipients: string[] }>("chat:post", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
169
173
  }
170
174
 
171
175
  export function chatRead(
@@ -176,21 +180,21 @@ export function chatRead(
176
180
  if (a.room !== undefined) payload.room = a.room;
177
181
  if (a.limit !== undefined) payload.limit = a.limit;
178
182
  if (a.sinceMs !== undefined) payload.sinceMs = a.sinceMs;
179
- return rtCommand<{ rooms: { room: string; messages: ChatMessage[] }[] }>("chat:read", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
183
+ return rtCommand<{ rooms: { room: string; messages: ChatMessage[] }[] }>("chat:read", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
180
184
  }
181
185
 
182
186
  export function chatRooms(
183
187
  a: { handle: string },
184
188
  o: RtClientOptions = {},
185
189
  ): Promise<RtResponse<{ rooms: RoomSummary[] }>> {
186
- return rtCommand<{ rooms: RoomSummary[] }>("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
190
+ return rtCommand<{ rooms: RoomSummary[] }>("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
187
191
  }
188
192
 
189
193
  export function chatWho(
190
194
  a: { room: string },
191
195
  o: RtClientOptions = {},
192
196
  ): Promise<RtResponse<{ members: ChatMember[] }>> {
193
- return rtCommand<{ members: ChatMember[] }>("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: 10_000 });
197
+ return rtCommand<{ members: ChatMember[] }>("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
194
198
  }
195
199
 
196
200
  export function chatMark(
@@ -199,7 +203,7 @@ export function chatMark(
199
203
  ): Promise<RtResponse<Record<string, never>>> {
200
204
  const payload: Record<string, unknown> = { handle: a.handle };
201
205
  if (a.room !== undefined) payload.room = a.room;
202
- return rtCommand<Record<string, never>>("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
206
+ return rtCommand<Record<string, never>>("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
203
207
  }
204
208
 
205
209
  export function chatMessages(
@@ -209,30 +213,35 @@ export function chatMessages(
209
213
  const payload: Record<string, unknown> = { room: a.room };
210
214
  if (a.before !== undefined) payload.before = a.before;
211
215
  if (a.limit !== undefined) payload.limit = a.limit;
212
- return rtCommand<{ messages: ChatMessage[] }>("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
216
+ return rtCommand<{ messages: ChatMessage[] }>("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
213
217
  }
214
218
 
215
219
  export function chatArm(
216
- a: { handle: string; room?: string },
220
+ a: { handle: string; room?: string; sessionId?: string },
217
221
  o: RtClientOptions = {},
218
222
  ): Promise<RtResponse<Record<string, never>>> {
219
223
  const payload: Record<string, unknown> = { handle: a.handle };
220
224
  if (a.room !== undefined) payload.room = a.room;
221
- return rtCommand<Record<string, never>>("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
225
+ if (a.sessionId !== undefined) payload.sessionId = a.sessionId;
226
+ return rtCommand<Record<string, never>>("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
222
227
  }
223
228
 
224
229
  export function chatTouch(
225
- a: { handle: string },
230
+ a: { handle: string; sessionId?: string },
226
231
  o: RtClientOptions = {},
227
232
  ): Promise<RtResponse<Record<string, never>>> {
228
- return rtCommand<Record<string, never>>("chat:touch", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
233
+ const payload: Record<string, unknown> = { handle: a.handle };
234
+ if (a.sessionId !== undefined) payload.sessionId = a.sessionId;
235
+ return rtCommand<Record<string, never>>("chat:touch", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
229
236
  }
230
237
 
231
238
  export function chatDisarm(
232
- a: { handle: string },
239
+ a: { handle: string; sessionId?: string },
233
240
  o: RtClientOptions = {},
234
241
  ): Promise<RtResponse<Record<string, never>>> {
235
- return rtCommand<Record<string, never>>("chat:disarm", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
242
+ const payload: Record<string, unknown> = { handle: a.handle };
243
+ if (a.sessionId !== undefined) payload.sessionId = a.sessionId;
244
+ return rtCommand<Record<string, never>>("chat:disarm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
236
245
  }
237
246
 
238
247
  export function chatUnreadWaking(
@@ -241,9 +250,76 @@ export function chatUnreadWaking(
241
250
  ): Promise<RtResponse<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>> {
242
251
  const payload: Record<string, unknown> = { handle: a.handle };
243
252
  if (a.room !== undefined) payload.room = a.room;
244
- return rtCommand<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
253
+ return rtCommand<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
254
+ }
255
+
256
+ // ─── Presence ──────────────────────────────────────────────────────────
257
+
258
+ export function chatSignIn(
259
+ a: { sessionId: string; baseHandle: string; cwd?: string; repo?: string; branch?: string; pane?: string; statusText?: string },
260
+ o: RtClientOptions = {},
261
+ ): Promise<RtResponse<{ handle: string; reclaimed: boolean }>> {
262
+ const payload: Record<string, unknown> = { sessionId: a.sessionId, baseHandle: a.baseHandle };
263
+ if (a.cwd !== undefined) payload.cwd = a.cwd;
264
+ if (a.repo !== undefined) payload.repo = a.repo;
265
+ if (a.branch !== undefined) payload.branch = a.branch;
266
+ if (a.pane !== undefined) payload.pane = a.pane;
267
+ if (a.statusText !== undefined) payload.statusText = a.statusText;
268
+ return rtCommand<{ handle: string; reclaimed: boolean }>("chat:sign-in", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
269
+ }
270
+
271
+ export function chatSignOut(
272
+ a: { sessionId: string },
273
+ o: RtClientOptions = {},
274
+ ): Promise<RtResponse<Record<string, never>>> {
275
+ return rtCommand<Record<string, never>>("chat:sign-out", { sessionId: a.sessionId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
276
+ }
277
+
278
+ export function chatAway(
279
+ a: { sessionId: string; text: string },
280
+ o: RtClientOptions = {},
281
+ ): Promise<RtResponse<Record<string, never>>> {
282
+ return rtCommand<Record<string, never>>("chat:away", { sessionId: a.sessionId, text: a.text }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
283
+ }
284
+
285
+ export function chatBack(
286
+ a: { sessionId: string },
287
+ o: RtClientOptions = {},
288
+ ): Promise<RtResponse<Record<string, never>>> {
289
+ return rtCommand<Record<string, never>>("chat:back", { sessionId: a.sessionId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
290
+ }
291
+
292
+ export function chatBuddies(
293
+ o: RtClientOptions = {},
294
+ ): Promise<RtResponse<{ buddies: Array<PresenceRow & { status: BuddyStatus }> }>> {
295
+ return rtCommand<{ buddies: Array<PresenceRow & { status: BuddyStatus }> }>("chat:buddies", {}, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
296
+ }
297
+
298
+ export function chatPulse(
299
+ a: { sessionId: string; cwd?: string; repo?: string; branch?: string; pane?: string },
300
+ o: RtClientOptions = {},
301
+ ): Promise<RtResponse<{ unread: { dms: number; mentions: number; rooms: number }; status: BuddyStatus }>> {
302
+ const payload: Record<string, unknown> = { sessionId: a.sessionId };
303
+ if (a.cwd !== undefined) payload.cwd = a.cwd;
304
+ if (a.repo !== undefined) payload.repo = a.repo;
305
+ if (a.branch !== undefined) payload.branch = a.branch;
306
+ if (a.pane !== undefined) payload.pane = a.pane;
307
+ return rtCommand<{ unread: { dms: number; mentions: number; rooms: number }; status: BuddyStatus }>(
308
+ "chat:pulse",
309
+ payload,
310
+ { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 },
311
+ );
312
+ }
313
+
314
+ export function chatDm(
315
+ a: { from: string; to: string; body: string; sessionId?: string },
316
+ o: RtClientOptions = {},
317
+ ): Promise<RtResponse<{ room: string; id: number; recipients: string[] }>> {
318
+ const payload: Record<string, unknown> = { from: a.from, to: a.to, body: a.body };
319
+ if (a.sessionId !== undefined) payload.sessionId = a.sessionId;
320
+ return rtCommand<{ room: string; id: number; recipients: string[] }>("chat:dm", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
245
321
  }
246
322
 
247
323
  export function eventsHead(o: RtClientOptions = {}): Promise<RtResponse<{ cursor: number }>> {
248
- return rtCommand<{ cursor: number }>("events:head", {}, { sockPath: o.sockPath, timeoutMs: 10_000 });
324
+ return rtCommand<{ cursor: number }>("events:head", {}, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
249
325
  }
package/src/commands.ts CHANGED
@@ -12,8 +12,6 @@ export type Discussion = MRDetail["discussions"][number];
12
12
  export interface DemandDecl {
13
13
  client: string;
14
14
  authors: string[];
15
- /** Codeowner sections this client needs covered (spec: second demand axis). */
16
- codeownerSections?: string[];
17
15
  declaredAt: number;
18
16
  }
19
17
 
@@ -21,14 +19,10 @@ export interface ProjectMRsScope {
21
19
  authors: string[];
22
20
  windowDays: number;
23
21
  uncovered: string[];
24
- /** Effective synced section union; absent from a pre-sections daemon. */
25
- sections?: string[];
26
- /** Demanded sections not yet swept for this client. */
27
- uncoveredSections?: string[];
28
22
  }
29
23
 
30
24
  export interface ProjectMRsData {
31
- mrs: Record<string, { pr: PullRequest; fetchedAt: number; codeownerSections?: string[] }>;
25
+ mrs: Record<string, { pr: PullRequest; fetchedAt: number }>;
32
26
  listSyncedAt: number;
33
27
  source: "poll" | "events" | "mutation";
34
28
  syncedAt: number;
@@ -96,6 +90,8 @@ export interface ChatMember {
96
90
  armedAt?: number;
97
91
  cwd?: string;
98
92
  pane?: string;
93
+ /** Presence-joined by chat:who's handler — the only place this type is ever returned, and it always attaches one. */
94
+ status: BuddyStatus;
99
95
  }
100
96
 
101
97
  export interface ChatMessage {
@@ -114,6 +110,34 @@ export interface RoomSummary {
114
110
  unread: number;
115
111
  mentions: number;
116
112
  lastPostedAt?: number;
113
+ /** Set only by chat:rooms's left join against chat_dms. */
114
+ kind?: "dm";
115
+ participants?: { a: string; b: string };
116
+ /** Set only by chat:rooms's left join against chat_room_defaults; undefined for a room never stamped a default (every DM room included). */
117
+ defaultWake?: WakeMode;
118
+ }
119
+
120
+ /**
121
+ * Duplicated shape on purpose, same reasoning as ChatMember/ChatMessage
122
+ * above: mirrors lib/state/presence-store.ts's types, which rt-client
123
+ * cannot import.
124
+ */
125
+ export type BuddyStatus = "live" | "idle" | "deaf" | "offline";
126
+
127
+ export interface PresenceRow {
128
+ sessionId: string;
129
+ handle: string;
130
+ baseHandle: string;
131
+ cwd?: string;
132
+ repo?: string;
133
+ branch?: string;
134
+ pane?: string;
135
+ statusText?: string;
136
+ signedInAt: number;
137
+ lastSeenAt: number;
138
+ tailSeenAt?: number;
139
+ armedAt?: number;
140
+ signedOutAt?: number;
117
141
  }
118
142
 
119
143
  // SKILLS-53: one judgment, computed once in rt, so the console and the tray
@@ -147,7 +171,7 @@ export interface RunSummary {
147
171
  pre-mirror daemons. */
148
172
  agent?: RunAgent | null;
149
173
  /** Executed stages only, in run order — the pipeline may define more that have not started. */
150
- stages?: { name: string; status: string }[];
174
+ stages?: { name: string; status: string; started_at: number | null }[];
151
175
  }
152
176
  export interface RunAgent {
153
177
  status: "working" | "idle" | "blocked" | "done" | "unknown";
@@ -220,16 +244,33 @@ export interface Commands {
220
244
  "runs:abandon": { payload: { runId: string; repo?: string; reason?: string }; data: { ok: boolean } };
221
245
  "chat:join": { payload: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string }; data: { handle: string; memberCount: number; unread: number } };
222
246
  "chat:leave": { payload: { room: string; handle: string }; data: Record<string, never> };
223
- "chat:post": { payload: { room: string; handle: string; body: string }; data: { id: number; recipients: string[] } };
247
+ "chat:post": { payload: { room: string; handle: string; body: string; mentions?: string[] }; data: { id: number; recipients: string[] } };
224
248
  "chat:read": { payload: { handle: string; room?: string; limit?: number; sinceMs?: number }; data: { rooms: { room: string; messages: ChatMessage[] }[] } };
225
249
  "chat:rooms": { payload: { handle: string }; data: { rooms: RoomSummary[] } };
226
250
  "chat:who": { payload: { room: string }; data: { members: ChatMember[] } };
227
251
  "chat:mark": { payload: { handle: string; room?: string }; data: Record<string, never> };
228
252
  "chat:messages": { payload: { room: string; before?: number; limit?: number }; data: { messages: ChatMessage[] } };
229
- "chat:arm": { payload: { handle: string; room?: string }; data: Record<string, never> };
230
- "chat:touch": { payload: { handle: string }; data: Record<string, never> };
231
- "chat:disarm": { payload: { handle: string }; data: Record<string, never> };
253
+ "chat:arm": { payload: { handle: string; room?: string; sessionId?: string }; data: Record<string, never> };
254
+ "chat:touch": { payload: { handle: string; sessionId?: string }; data: Record<string, never> };
255
+ "chat:disarm": { payload: { handle: string; sessionId?: string }; data: Record<string, never> };
232
256
  "chat:unread-waking": { payload: { handle: string; room?: string }; data: { rooms: { room: string; count: number; mentions: number; maxId: number }[] } };
257
+
258
+ // A session id keys these to one signed-in handle, not a room-membership
259
+ // handle string.
260
+ "chat:sign-in": {
261
+ payload: { sessionId: string; baseHandle: string; cwd?: string; repo?: string; branch?: string; pane?: string; statusText?: string };
262
+ data: { handle: string; reclaimed: boolean };
263
+ };
264
+ "chat:sign-out": { payload: { sessionId: string }; data: Record<string, never> };
265
+ "chat:away": { payload: { sessionId: string; text: string }; data: Record<string, never> };
266
+ "chat:back": { payload: { sessionId: string }; data: Record<string, never> };
267
+ "chat:buddies": { payload: Record<string, never>; data: { buddies: Array<PresenceRow & { status: BuddyStatus }> } };
268
+ /** `unread`'s three fields are disjoint and sum to the true total: `dms` is DM-room waking count; `mentions` is non-DM waking mentions; `rooms` is non-DM waking count minus those mentions (never negative). */
269
+ "chat:pulse": {
270
+ payload: { sessionId: string; cwd?: string; repo?: string; branch?: string; pane?: string };
271
+ data: { unread: { dms: number; mentions: number; rooms: number }; status: BuddyStatus };
272
+ };
273
+ "chat:dm": { payload: { from: string; to: string; body: string; sessionId?: string }; data: { room: string; id: number; recipients: string[] } };
233
274
  }
234
275
 
235
276
  export type CommandName = keyof Commands;
@@ -259,4 +300,11 @@ export const COMMAND_NAMES: readonly CommandName[] = [
259
300
  "chat:touch",
260
301
  "chat:disarm",
261
302
  "chat:unread-waking",
303
+ "chat:sign-in",
304
+ "chat:sign-out",
305
+ "chat:away",
306
+ "chat:back",
307
+ "chat:buddies",
308
+ "chat:pulse",
309
+ "chat:dm",
262
310
  ];
package/src/health.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { eventsHead } from "./client.ts";
2
+ import type { RtClientOptions } from "./transport.ts";
3
+
4
+ /**
5
+ * A daemon-down result is a successful probe, not a failure of this call —
6
+ * eventsHead already never throws (transport.ts degrades every fetch to
7
+ * `{ ok: false, error }`), so this only reshapes that envelope for callers
8
+ * who want a boolean, not `{ ok, data, error }`.
9
+ */
10
+ export async function daemonHealth(
11
+ opts: RtClientOptions = {},
12
+ ): Promise<{ reachable: boolean; error?: string }> {
13
+ const res = await eventsHead(opts);
14
+ return { reachable: res.ok, error: res.ok ? undefined : res.error };
15
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,13 @@ export {
22
22
  chatTouch,
23
23
  chatDisarm,
24
24
  chatUnreadWaking,
25
+ chatSignIn,
26
+ chatSignOut,
27
+ chatAway,
28
+ chatBack,
29
+ chatBuddies,
30
+ chatPulse,
31
+ chatDm,
25
32
  eventsHead,
26
33
  } from "./client.ts";
27
34
 
@@ -49,11 +56,15 @@ export type {
49
56
  ChatMember,
50
57
  ChatMessage,
51
58
  RoomSummary,
59
+ BuddyStatus,
60
+ PresenceRow,
52
61
  } from "./commands.ts";
53
62
 
54
- export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
63
+ export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
55
64
  export type { RelayEventType } from "./relay.ts";
56
65
 
66
+ export { daemonHealth } from "./health.ts";
67
+
57
68
  export { repoNameForPath } from "./repos.ts";
58
69
 
59
70
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
package/src/relay.ts CHANGED
@@ -59,3 +59,34 @@ export function subscribe(
59
59
  try { ws?.close(); } catch { /* already closed */ }
60
60
  };
61
61
  }
62
+
63
+ /**
64
+ * One daemon subscription for the whole process, republished onto a
65
+ * caller-chosen pub/sub topic. Every subscriber to that topic then shares
66
+ * one relay connection instead of each opening its own — filtering here
67
+ * (rather than at each subscriber) is what keeps an unrelated event from
68
+ * making every subscriber re-render.
69
+ *
70
+ * Ported from console's `startRelay` (src/server/ws.ts) with the match
71
+ * predicate and target topic lifted to arguments.
72
+ */
73
+ export function createRelay(
74
+ cfg: { match: (topic: string) => boolean; topic: string; publish: (topic: string, data: string) => void },
75
+ opts: RtClientOptions = {},
76
+ ): () => void {
77
+ const doSubscribe = opts.subscribeImpl ?? subscribe;
78
+ return doSubscribe((type, data) => {
79
+ if (type !== "event") return;
80
+ const frame = data as { topic?: unknown };
81
+ if (typeof frame?.topic !== "string" || !cfg.match(frame.topic)) return;
82
+ // Serializing outside the catch keeps the suppression narrow: only a
83
+ // publish that rejects its own frame is expected here, and one
84
+ // subscriber's broken publish must not tear down the shared relay.
85
+ const payload = JSON.stringify(data);
86
+ try {
87
+ cfg.publish(cfg.topic, payload);
88
+ } catch {
89
+ /* the subscriber went away */
90
+ }
91
+ }, opts);
92
+ }
@@ -228,6 +228,13 @@ export const REGISTRY: readonly SettingDef[] = [
228
228
  merge: "replace",
229
229
  description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications.",
230
230
  },
231
+ {
232
+ key: "mattstack.mode",
233
+ type: "string",
234
+ scopes: ["machine"],
235
+ merge: "replace",
236
+ description: "The machine's intended flavor, \"dev\" or \"prod\". Normally written by `rt settings dev-mode` after a successful handoff; a manual `rt settings set` is the blessed repair escape hatch — the daemon park loop converges on whatever this says. Unset ⇒ derived from the dev wrapper's presence.",
237
+ },
231
238
  {
232
239
  key: "rt.integrations",
233
240
  type: "object",
@@ -345,13 +352,6 @@ export const REGISTRY: readonly SettingDef[] = [
345
352
  merge: "replace",
346
353
  description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently.",
347
354
  },
348
- {
349
- key: "board.tabs",
350
- type: "array",
351
- scopes: ["team"],
352
- merge: "replace",
353
- description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here).",
354
- },
355
355
 
356
356
  // --- board (user) ----------------------------------------------------------
357
357
  {
package/src/transport.ts CHANGED
@@ -19,6 +19,15 @@ export interface RtResponse<T = unknown> {
19
19
  export interface RtClientOptions {
20
20
  sockPath?: string;
21
21
  wsUrl?: string;
22
+ /** Per-call override of rtCommand's own default (15s); chat's pulse wrapper needs an 800ms hook budget. */
23
+ timeoutMs?: number;
24
+ /**
25
+ * Test seam for createRelay (relay.ts): swaps the daemon subscription for
26
+ * a fake without a live WebSocket server. Typed structurally against
27
+ * relay.ts's `subscribe` rather than importing its RelayEventType, which
28
+ * would make this module depend on the one that already depends on it.
29
+ */
30
+ subscribeImpl?: (onEvent: (type: string, data: unknown) => void, opts?: RtClientOptions) => () => void;
22
31
  }
23
32
 
24
33
  // Duplicates the ~/.mattstack/rt layout: rt-client has no dependency on rt's