@mattstack/rt-client 0.4.1 → 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.
@@ -25,26 +25,9 @@
25
25
  * through `identityFromRemote`, memoized per path so repeated callers in
26
26
  * one process don't re-spawn git.
27
27
  */
28
- export type RepoIdentity = {
29
- kind: "remote";
30
- id: string;
31
- } | {
32
- kind: "path";
33
- id: string;
34
- };
35
- /**
36
- * The wire form crosses the daemon socket, sits in board config, and lands in
37
- * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
38
- * `encodeURIComponent` guarantees that and is exactly reversible.
39
- */
40
- export declare function serializeIdentity(id: RepoIdentity): string;
41
- export declare function parseIdentity(wire: string): RepoIdentity | null;
42
- /**
43
- * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
44
- * embedded credentials stripped) or null when the remote doesn't match a
45
- * recognized host form (local paths, garbage input).
46
- */
47
- export declare function normalizeRemote(remote: string): string | null;
28
+ import { type RepoIdentity } from "./identity-codec.ts";
29
+ export { serializeIdentity, parseIdentity, normalizeRemote } from "./identity-codec.ts";
30
+ export type { RepoIdentity } from "./identity-codec.ts";
48
31
  /**
49
32
  * The sync helper every non-derivation call site uses: machine-store
50
33
  * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
@@ -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.4.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -9,6 +9,12 @@
9
9
  "import": "./dist/index.js",
10
10
  "default": "./dist/index.js"
11
11
  },
12
+ "./identity": {
13
+ "types": "./dist/settings/identity-codec.d.ts",
14
+ "bun": "./src/settings/identity-codec.ts",
15
+ "import": "./dist/settings/identity-codec.js",
16
+ "default": "./dist/settings/identity-codec.js"
17
+ },
12
18
  "./test/fake-daemon.ts": "./test/fake-daemon.ts"
13
19
  },
14
20
  "peerDependencies": {
@@ -39,7 +45,7 @@
39
45
  "bun": ">=1.0.0"
40
46
  },
41
47
  "scripts": {
42
- "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
48
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && tsc -p tsconfig.json",
43
49
  "check-types": "tsc --noEmit -p tsconfig.json",
44
50
  "prepack": "bun run build"
45
51
  },
package/src/client.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectMRsData,
11
11
  DiscussionsData,
12
12
  MrByBranchData,
13
+ BranchEnrichment,
13
14
  ForgeSlug,
14
15
  ForgeTokenData,
15
16
  RunSummary,
@@ -18,6 +19,8 @@ import type {
18
19
  ChatMember,
19
20
  ChatMessage,
20
21
  RoomSummary,
22
+ BuddyStatus,
23
+ PresenceRow,
21
24
  } from "./commands.ts";
22
25
 
23
26
  /**
@@ -66,6 +69,22 @@ export function readMrsByBranch(
66
69
  );
67
70
  }
68
71
 
72
+ /**
73
+ * Cached ticket/MR enrichment for a set of branches, keyed by branch name
74
+ * (the cache's own primary key -- see lib/state/branch-cache.ts). Serves
75
+ * whatever the daemon already has; it does not trigger a fetch.
76
+ */
77
+ export function readBranchCache(
78
+ branches: string[],
79
+ opts: RtClientOptions = {},
80
+ ): Promise<RtResponse<Record<string, BranchEnrichment>>> {
81
+ return rtCommand<Record<string, BranchEnrichment>>(
82
+ "cache:read",
83
+ { branches },
84
+ { sockPath: opts.sockPath, timeoutMs: 10_000 },
85
+ );
86
+ }
87
+
69
88
  /**
70
89
  * The forge token for one tracked repo (MAT-33). Grant-gated on the daemon
71
90
  * side: an untracked repo comes back `ok: false` with the `rt daemon track`
@@ -134,21 +153,23 @@ export function chatJoin(
134
153
  if (a.wakeOn !== undefined) payload.wakeOn = a.wakeOn;
135
154
  if (a.cwd !== undefined) payload.cwd = a.cwd;
136
155
  if (a.pane !== undefined) payload.pane = a.pane;
137
- 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 });
138
157
  }
139
158
 
140
159
  export function chatLeave(
141
160
  a: { room: string; handle: string },
142
161
  o: RtClientOptions = {},
143
162
  ): Promise<RtResponse<Record<string, never>>> {
144
- 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 });
145
164
  }
146
165
 
147
166
  export function chatPost(
148
- a: { room: string; handle: string; body: string },
167
+ a: { room: string; handle: string; body: string; mentions?: string[] },
149
168
  o: RtClientOptions = {},
150
169
  ): Promise<RtResponse<{ id: number; recipients: string[] }>> {
151
- 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 });
152
173
  }
153
174
 
154
175
  export function chatRead(
@@ -159,21 +180,21 @@ export function chatRead(
159
180
  if (a.room !== undefined) payload.room = a.room;
160
181
  if (a.limit !== undefined) payload.limit = a.limit;
161
182
  if (a.sinceMs !== undefined) payload.sinceMs = a.sinceMs;
162
- 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 });
163
184
  }
164
185
 
165
186
  export function chatRooms(
166
187
  a: { handle: string },
167
188
  o: RtClientOptions = {},
168
189
  ): Promise<RtResponse<{ rooms: RoomSummary[] }>> {
169
- 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 });
170
191
  }
171
192
 
172
193
  export function chatWho(
173
194
  a: { room: string },
174
195
  o: RtClientOptions = {},
175
196
  ): Promise<RtResponse<{ members: ChatMember[] }>> {
176
- 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 });
177
198
  }
178
199
 
179
200
  export function chatMark(
@@ -182,7 +203,7 @@ export function chatMark(
182
203
  ): Promise<RtResponse<Record<string, never>>> {
183
204
  const payload: Record<string, unknown> = { handle: a.handle };
184
205
  if (a.room !== undefined) payload.room = a.room;
185
- 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 });
186
207
  }
187
208
 
188
209
  export function chatMessages(
@@ -192,30 +213,35 @@ export function chatMessages(
192
213
  const payload: Record<string, unknown> = { room: a.room };
193
214
  if (a.before !== undefined) payload.before = a.before;
194
215
  if (a.limit !== undefined) payload.limit = a.limit;
195
- 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 });
196
217
  }
197
218
 
198
219
  export function chatArm(
199
- a: { handle: string; room?: string },
220
+ a: { handle: string; room?: string; sessionId?: string },
200
221
  o: RtClientOptions = {},
201
222
  ): Promise<RtResponse<Record<string, never>>> {
202
223
  const payload: Record<string, unknown> = { handle: a.handle };
203
224
  if (a.room !== undefined) payload.room = a.room;
204
- 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 });
205
227
  }
206
228
 
207
229
  export function chatTouch(
208
- a: { handle: string },
230
+ a: { handle: string; sessionId?: string },
209
231
  o: RtClientOptions = {},
210
232
  ): Promise<RtResponse<Record<string, never>>> {
211
- 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 });
212
236
  }
213
237
 
214
238
  export function chatDisarm(
215
- a: { handle: string },
239
+ a: { handle: string; sessionId?: string },
216
240
  o: RtClientOptions = {},
217
241
  ): Promise<RtResponse<Record<string, never>>> {
218
- 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 });
219
245
  }
220
246
 
221
247
  export function chatUnreadWaking(
@@ -224,9 +250,76 @@ export function chatUnreadWaking(
224
250
  ): Promise<RtResponse<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>> {
225
251
  const payload: Record<string, unknown> = { handle: a.handle };
226
252
  if (a.room !== undefined) payload.room = a.room;
227
- 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 });
228
321
  }
229
322
 
230
323
  export function eventsHead(o: RtClientOptions = {}): Promise<RtResponse<{ cursor: number }>> {
231
- 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 });
232
325
  }
package/src/commands.ts CHANGED
@@ -45,6 +45,21 @@ export interface MrByBranchData {
45
45
  syncedAt: number;
46
46
  }
47
47
 
48
+ /**
49
+ * Trimmed, structural view of the daemon's `CacheEntry` (lib/state/branch-cache.ts) --
50
+ * rt-client cannot import daemon/lib internals, so this names only the fields
51
+ * console's run-view rows read, spelled exactly as they land on the wire
52
+ * (`mr` is `toMRInfo(pr)`, i.e. `getMRDashboardProps` -- camelCase `webUrl`,
53
+ * nested `pipeline.status`, no `ciStatus`). Extra wire fields (including the
54
+ * rest of `pipeline`) are fine; anything this shape doesn't name is simply
55
+ * not surfaced.
56
+ */
57
+ export interface BranchEnrichment {
58
+ ticket: { identifier: string; title: string; url: string } | null;
59
+ mr: { iid: number; webUrl: string | null; state: string; pipeline: { status: string } | null } | null;
60
+ fetchedAt: number;
61
+ }
62
+
48
63
  /** Forges the daemon can hold a token for. */
49
64
  export type ForgeSlug = "gitlab" | "github";
50
65
 
@@ -75,6 +90,8 @@ export interface ChatMember {
75
90
  armedAt?: number;
76
91
  cwd?: string;
77
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;
78
95
  }
79
96
 
80
97
  export interface ChatMessage {
@@ -93,13 +110,41 @@ export interface RoomSummary {
93
110
  unread: number;
94
111
  mentions: number;
95
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;
96
141
  }
97
142
 
98
143
  // SKILLS-53: one judgment, computed once in rt, so the console and the tray
99
144
  // never derive two verdicts that can disagree.
100
145
  export type Attention = {
101
146
  needs: boolean;
102
- reason: "failed" | "stale" | "stranded" | null;
147
+ reason: "failed" | "stale" | "stranded" | "blocked" | null;
103
148
  evidence: string;
104
149
  };
105
150
 
@@ -120,6 +165,17 @@ export interface RunSummary {
120
165
  the run has not produced that field yet. */
121
166
  ticket: string | null;
122
167
  branch: string | null;
168
+ /** The herdr agent attributed to this run (matched by recorded claude
169
+ session, else by worktree), mirrored live from `herdr agent list`.
170
+ Null when no agent matches or herdr is unavailable; absent on
171
+ pre-mirror daemons. */
172
+ agent?: RunAgent | null;
173
+ /** Executed stages only, in run order — the pipeline may define more that have not started. */
174
+ stages?: { name: string; status: string; started_at: number | null }[];
175
+ }
176
+ export interface RunAgent {
177
+ status: "working" | "idle" | "blocked" | "done" | "unknown";
178
+ pane: string;
123
179
  }
124
180
  export interface RunStageRow {
125
181
  name: string; status: string; attempt: number;
@@ -188,16 +244,33 @@ export interface Commands {
188
244
  "runs:abandon": { payload: { runId: string; repo?: string; reason?: string }; data: { ok: boolean } };
189
245
  "chat:join": { payload: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string }; data: { handle: string; memberCount: number; unread: number } };
190
246
  "chat:leave": { payload: { room: string; handle: string }; data: Record<string, never> };
191
- "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[] } };
192
248
  "chat:read": { payload: { handle: string; room?: string; limit?: number; sinceMs?: number }; data: { rooms: { room: string; messages: ChatMessage[] }[] } };
193
249
  "chat:rooms": { payload: { handle: string }; data: { rooms: RoomSummary[] } };
194
250
  "chat:who": { payload: { room: string }; data: { members: ChatMember[] } };
195
251
  "chat:mark": { payload: { handle: string; room?: string }; data: Record<string, never> };
196
252
  "chat:messages": { payload: { room: string; before?: number; limit?: number }; data: { messages: ChatMessage[] } };
197
- "chat:arm": { payload: { handle: string; room?: string }; data: Record<string, never> };
198
- "chat:touch": { payload: { handle: string }; data: Record<string, never> };
199
- "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> };
200
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[] } };
201
274
  }
202
275
 
203
276
  export type CommandName = keyof Commands;
@@ -227,4 +300,11 @@ export const COMMAND_NAMES: readonly CommandName[] = [
227
300
  "chat:touch",
228
301
  "chat:disarm",
229
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",
230
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
@@ -5,6 +5,7 @@ export {
5
5
  readProjectMRs,
6
6
  readDiscussions,
7
7
  readMrsByBranch,
8
+ readBranchCache,
8
9
  resolveForgeToken,
9
10
  listRuns,
10
11
  getRun,
@@ -21,6 +22,13 @@ export {
21
22
  chatTouch,
22
23
  chatDisarm,
23
24
  chatUnreadWaking,
25
+ chatSignIn,
26
+ chatSignOut,
27
+ chatAway,
28
+ chatBack,
29
+ chatBuddies,
30
+ chatPulse,
31
+ chatDm,
24
32
  eventsHead,
25
33
  } from "./client.ts";
26
34
 
@@ -33,6 +41,7 @@ export type {
33
41
  DiscussionsData,
34
42
  MrByBranchEntry,
35
43
  MrByBranchData,
44
+ BranchEnrichment,
36
45
  Commands,
37
46
  CommandName,
38
47
  ForgeSlug,
@@ -47,11 +56,15 @@ export type {
47
56
  ChatMember,
48
57
  ChatMessage,
49
58
  RoomSummary,
59
+ BuddyStatus,
60
+ PresenceRow,
50
61
  } from "./commands.ts";
51
62
 
52
- export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
63
+ export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
53
64
  export type { RelayEventType } from "./relay.ts";
54
65
 
66
+ export { daemonHealth } from "./health.ts";
67
+
55
68
  export { repoNameForPath } from "./repos.ts";
56
69
 
57
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
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The pure half of the repo-identity contract: the wire codec and the
3
+ * remote-URL normalizer. Split from identity.ts so browser bundles can key
4
+ * and label repos without dragging in fs/child_process — this module must
5
+ * never import node builtins or anything that does (the `./identity`
6
+ * subpath export points here, and a browser consumer evaluates it at module
7
+ * scope). Override-aware and derivation entry points stay in identity.ts.
8
+ */
9
+
10
+ // Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ...
11
+ const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
12
+
13
+ // scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
14
+ // Deliberately excludes anything starting with "/" (absolute local paths)
15
+ // so a Windows-drive-letter-free local remote never falsely matches.
16
+ const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
17
+
18
+ export type RepoIdentity =
19
+ | { kind: "remote"; id: string }
20
+ | { kind: "path"; id: string };
21
+
22
+ /**
23
+ * The wire form crosses the daemon socket, sits in board config, and lands in
24
+ * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
25
+ * `encodeURIComponent` guarantees that and is exactly reversible.
26
+ */
27
+ export function serializeIdentity(id: RepoIdentity): string {
28
+ return `${id.kind}:${encodeURIComponent(id.id)}`;
29
+ }
30
+
31
+ export function parseIdentity(wire: string): RepoIdentity | null {
32
+ const colon = wire.indexOf(":");
33
+ if (colon === -1) return null;
34
+ const kind = wire.slice(0, colon);
35
+ if (kind !== "remote" && kind !== "path") return null;
36
+ const encoded = wire.slice(colon + 1);
37
+ let id: string;
38
+ try {
39
+ id = decodeURIComponent(encoded);
40
+ } catch {
41
+ return null;
42
+ }
43
+ // Canonical wires only: the id segment must be byte-for-byte what
44
+ // serializeIdentity emits. Guard sites validate with parseIdentity and then
45
+ // use the WIRE as a single path component (repoDataDir et al.) — a
46
+ // hand-built wire with a literal "/" ("path:../..") would otherwise parse
47
+ // and escape the state directory.
48
+ if (encodeURIComponent(id) !== encoded) return null;
49
+ return { kind, id };
50
+ }
51
+
52
+ /**
53
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
54
+ * embedded credentials stripped) or null when the remote doesn't match a
55
+ * recognized host form (local paths, garbage input).
56
+ */
57
+ export function normalizeRemote(remote: string): string | null {
58
+ const trimmed = remote.trim();
59
+ if (!trimmed) return null;
60
+
61
+ let host: string | undefined;
62
+ let path: string | undefined;
63
+
64
+ const urlMatch = URL_RE.exec(trimmed);
65
+ if (urlMatch) {
66
+ host = urlMatch[1];
67
+ path = urlMatch[2];
68
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
69
+ const scpMatch = SCP_RE.exec(trimmed);
70
+ if (scpMatch) {
71
+ host = scpMatch[1];
72
+ path = scpMatch[2];
73
+ }
74
+ }
75
+
76
+ if (!host || !path) return null;
77
+
78
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
79
+ if (!normalizedPath) return null;
80
+
81
+ return `${host.toLowerCase()}/${normalizedPath}`;
82
+ }