@mattstack/rt-client 0.10.0 → 0.11.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/index.js CHANGED
@@ -14,7 +14,7 @@ async function rtCommand(cmd, payload, opts = {}) {
14
14
  const res = await fetch(`http://localhost/${cmd}`, {
15
15
  unix: sockPath,
16
16
  method: "POST",
17
- headers: { "Content-Type": "application/json" },
17
+ headers: { "Content-Type": "application/json", "X-RT-Client": `rt-client/${process.pid}` },
18
18
  body: JSON.stringify(payload),
19
19
  signal: AbortSignal.timeout(opts.timeoutMs ?? 15000)
20
20
  });
@@ -242,6 +242,9 @@ function paneSend(a, o = {}) {
242
242
  payload.callerPane = a.callerPane;
243
243
  return rtCommand("pane:send", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 30000 });
244
244
  }
245
+ function paneFocus(a, o = {}) {
246
+ return rtCommand("pane:focus", { paneId: a.paneId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
247
+ }
245
248
  // src/commands.ts
246
249
  var COMMAND_NAMES = [
247
250
  "project-mrs:read",
@@ -282,7 +285,46 @@ var COMMAND_NAMES = [
282
285
  "pane:accounts",
283
286
  "pane:directories",
284
287
  "pane:spawn",
285
- "pane:send"
288
+ "pane:send",
289
+ "pane:focus",
290
+ "cache:read",
291
+ "branch:enrich",
292
+ "cache:refresh",
293
+ "daemon:log-level",
294
+ "ping",
295
+ "status",
296
+ "tray:status",
297
+ "tcc:check",
298
+ "repos",
299
+ "ports",
300
+ "notifications",
301
+ "discussions:refresh",
302
+ "discussions:resolve",
303
+ "discussions:reply",
304
+ "discussions:diffs",
305
+ "mr:action",
306
+ "mr:fetch-job-detail",
307
+ "mr:fetch-job-trace",
308
+ "endpoint:claim",
309
+ "endpoint:lookup",
310
+ "endpoint:release",
311
+ "endpoint:status",
312
+ "repos:locate",
313
+ "freshness:reconcile",
314
+ "hooks:repair",
315
+ "hooks:watch",
316
+ "sdm:catalog",
317
+ "sdm:snapshot",
318
+ "sdm:recents",
319
+ "sdm:reconnect",
320
+ "system-processes",
321
+ "worktree:provision",
322
+ "worktree:create",
323
+ "worktree:dispose",
324
+ "worktree:list",
325
+ "worktree:restore",
326
+ "worktree:freshen",
327
+ "worktree:adopt"
286
328
  ];
287
329
  // src/relay.ts
288
330
  var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
@@ -409,6 +451,56 @@ function repoNameForPath(repoPath, reposJsonPath) {
409
451
  return fromDb;
410
452
  return repoNameFromJson(repoPath, jsonPath);
411
453
  }
454
+ // src/smart-pane.ts
455
+ var DEFAULT_MIN_COLS = 50;
456
+ var DEFAULT_MIN_ROWS = 14;
457
+ function decidePlacement(rect, opts = {}) {
458
+ const minCols = opts.minCols ?? DEFAULT_MIN_COLS;
459
+ const minRows = opts.minRows ?? DEFAULT_MIN_ROWS;
460
+ if (!rect || rect.width <= 0 || rect.height <= 0)
461
+ return { kind: "split", direction: "right" };
462
+ const canRight = rect.width >= 2 * minCols;
463
+ const canDown = rect.height >= 2 * minRows;
464
+ if (!canRight && !canDown)
465
+ return { kind: "tab" };
466
+ if (canRight && canDown) {
467
+ return rect.width > 2 * rect.height ? { kind: "split", direction: "right" } : { kind: "split", direction: "down" };
468
+ }
469
+ return canRight ? { kind: "split", direction: "right" } : { kind: "split", direction: "down" };
470
+ }
471
+ async function anchorRect(herdr, anchorPaneId) {
472
+ const r = await herdr("pane.layout", { pane_id: anchorPaneId });
473
+ if (!r.ok)
474
+ return null;
475
+ const pane = (r.result?.layout?.panes ?? []).find((p) => p.pane_id === anchorPaneId);
476
+ return pane?.rect ? { width: pane.rect.width, height: pane.rect.height } : null;
477
+ }
478
+ async function openSmartPane(herdr, anchorPaneId, opts = {}) {
479
+ const focus = opts.focus ?? true;
480
+ const placement = decidePlacement(await anchorRect(herdr, anchorPaneId), opts);
481
+ let paneId;
482
+ if (placement.kind === "split") {
483
+ const s = await herdr("pane.split", { pane_id: anchorPaneId, direction: placement.direction, focus });
484
+ if (!s.ok)
485
+ throw new Error(`pane.split failed: ${s.message}`);
486
+ paneId = s.result?.pane?.pane_id;
487
+ if (!paneId)
488
+ throw new Error("pane.split returned no pane_id");
489
+ } else {
490
+ const workspaceId = anchorPaneId.split(":")[0];
491
+ const t = await herdr("tab.create", { workspace_id: workspaceId, focus });
492
+ if (!t.ok)
493
+ throw new Error(`tab.create failed: ${t.message}`);
494
+ paneId = t.result?.root_pane?.pane_id;
495
+ if (!paneId)
496
+ throw new Error("tab.create returned no pane_id");
497
+ }
498
+ if (opts.command) {
499
+ await herdr("pane.send_text", { pane_id: paneId, text: opts.command });
500
+ await herdr("pane.send_keys", { pane_id: paneId, keys: ["enter"] });
501
+ }
502
+ return { paneId, placement };
503
+ }
412
504
  // src/settings/resolve.ts
413
505
  import { homedir as homedir4 } from "os";
414
506
  import { join as join5 } from "path";
@@ -478,6 +570,15 @@ var REGISTRY = [
478
570
  migrated: true,
479
571
  description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader."
480
572
  },
573
+ {
574
+ key: "rt.worktreeReadyApproval",
575
+ type: "string",
576
+ scopes: ALL_SCOPES,
577
+ merge: "replace",
578
+ repoScoped: true,
579
+ migrated: true,
580
+ description: "Per-repo user approval of a team-authored `ready` shell ladder, as its content hash (RT-89). The reader trusts only user/machine scopes so a team store can never approve its own shell; a hash mismatch after a team edit re-holds the ladder until `rt worktree ready-approve` records the new one."
581
+ },
481
582
  {
482
583
  key: "rt.repoIdentityOverrides",
483
584
  type: "object",
@@ -623,6 +724,24 @@ var REGISTRY = [
623
724
  migrated: true,
624
725
  description: "Age floor in days for the log janitor pruning every surface's rotated log files under ~/.mattstack/rt/logs (default 14). A fresh key, not an ownership-latch port, so a default is fine here."
625
726
  },
727
+ {
728
+ key: "rt.logLevel",
729
+ type: "string",
730
+ scopes: ["machine", "user"],
731
+ default: "info",
732
+ merge: "replace",
733
+ migrated: true,
734
+ description: "Daemon log level (trace|debug|info|warn|error). RT_LOG_LEVEL env wins, then this setting, then info (lib/daemon-logger.ts resolveDaemonLogLevel). A fresh key, not an ownership-latch port, so a default is fine here."
735
+ },
736
+ {
737
+ key: "rt.apiPort",
738
+ type: "number",
739
+ scopes: ["machine", "user"],
740
+ default: 9401,
741
+ merge: "replace",
742
+ migrated: true,
743
+ description: "TCP port for the daemon's local HTTP/WS API. Escape hatch when 9401 is held: RT_API_PORT env wins, then this setting, then 9401 (lib/daemon-config.ts resolveApiPort(), read at bind time by lib/daemon/api-server.ts)."
744
+ },
626
745
  {
627
746
  key: "rt.hooks",
628
747
  type: "object",
@@ -632,6 +751,21 @@ var REGISTRY = [
632
751
  migrated: true,
633
752
  description: "Per-repo git hook enable/disable state ({enabled, hooks: {<hookName>: boolean}}); ownership-latch port of repos/<repo>/hooks.json, store wins per field once it owns the key — including per-hook-name entries inside the nested hooks map, each defaulting to enabled when absent. The installed git-hook shim still greps repos/<repo>/hooks.json with zero process spawns (a hook fires on every git operation); that file is now a DERIVED CACHE this key writes through, kept current by commands/hooks.ts's regenerateHooksCache at every write seam."
634
753
  },
754
+ {
755
+ key: "rt.daemonPath",
756
+ type: "string",
757
+ scopes: ["machine"],
758
+ merge: "replace",
759
+ description: "Absolute colon-separated PATH the daemon uses for every child it spawns, instead of probing your login shell. Set this when the daemon can't find node/git/bun/pnpm (e.g. a fish shell, a blocking .zshrc, or PATH exports that live only in .zshrc). Machine-scoped: it never travels to another machine."
760
+ },
761
+ {
762
+ key: "rt.trustedBrowserOrigins",
763
+ type: "array",
764
+ scopes: ["user", "machine"],
765
+ default: [],
766
+ merge: "replace",
767
+ description: "Browser Origins (scheme://host:port, exact string match) trusted to read the :9401 daemon API and subscribe to /ws without presenting the local api-token -- e.g. a locally-hosted console or chat-viewer dev server. Empty by default: every current mattstack consumer (the CLI, the Swift tray, rt-client from Bun/Node processes, the VS Code extension) is a non-browser client (sends no Origin header at all) and is unaffected either way."
768
+ },
635
769
  {
636
770
  key: "mattstack.integrations",
637
771
  type: "object",
@@ -1292,8 +1426,24 @@ function expandCtxFrom(opts) {
1292
1426
  teamsDir: teamsDir()
1293
1427
  };
1294
1428
  }
1429
+ var warnSink = null;
1430
+ var warnedOnce = new Set;
1431
+ function setSettingsWarnSink(sink) {
1432
+ warnSink = sink;
1433
+ warnedOnce.clear();
1434
+ }
1435
+ function emitSettingsWarning(msg) {
1436
+ if (warnSink) {
1437
+ if (warnedOnce.has(msg))
1438
+ return;
1439
+ warnedOnce.add(msg);
1440
+ warnSink(msg);
1441
+ return;
1442
+ }
1443
+ console.warn(msg);
1444
+ }
1295
1445
  function warnInvalid(key, entry) {
1296
- console.warn(`rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`);
1446
+ emitSettingsWarning(`rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`);
1297
1447
  }
1298
1448
  function getSetting(key, opts = {}) {
1299
1449
  const def = getDef(key);
@@ -1328,7 +1478,7 @@ function listSettings(opts = {}) {
1328
1478
  listed.value = expandVariables(resolution.value, ctx);
1329
1479
  } catch (err) {
1330
1480
  listed.expandError = err.message;
1331
- console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
1481
+ emitSettingsWarning(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
1332
1482
  }
1333
1483
  }
1334
1484
  out.push(listed);
@@ -1356,7 +1506,7 @@ function listUnregistered(stores, opts) {
1356
1506
  scan("machine", stores.machine.file, stores.machine.global);
1357
1507
  scan("machine.repo", stores.machine.file, repoSection(stores.machine));
1358
1508
  return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, hit]) => {
1359
- console.warn(`rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`);
1509
+ emitSettingsWarning(`rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`);
1360
1510
  return {
1361
1511
  key,
1362
1512
  value: hit.value,
@@ -1581,21 +1731,42 @@ async function runCapture(argv, opts = {}) {
1581
1731
  } catch {
1582
1732
  return { stdout: "", stderr: "", exitCode: -1 };
1583
1733
  }
1584
- const timer = setTimeout(() => {
1734
+ const timeoutMs = opts.timeoutMs ?? 1e4;
1735
+ let killTimer;
1736
+ const term = setTimeout(() => {
1585
1737
  try {
1586
- proc.kill();
1738
+ proc.kill("SIGTERM");
1587
1739
  } catch {}
1588
- }, opts.timeoutMs ?? 1e4);
1740
+ killTimer = setTimeout(() => {
1741
+ try {
1742
+ proc.kill("SIGKILL");
1743
+ } catch {}
1744
+ }, 2000);
1745
+ killTimer.unref?.();
1746
+ }, timeoutMs);
1747
+ const captured = (async () => {
1748
+ try {
1749
+ const stdoutPromise = new Response(proc.stdout).text();
1750
+ const stderrPromise = captureStderr ? new Response(proc.stderr).text() : Promise.resolve("");
1751
+ const [stdout, stderr, exitCode] = await Promise.all([
1752
+ stdoutPromise,
1753
+ stderrPromise,
1754
+ proc.exited
1755
+ ]);
1756
+ return { stdout, stderr, exitCode };
1757
+ } catch {
1758
+ return { stdout: "", stderr: "", exitCode: -1 };
1759
+ }
1760
+ })();
1761
+ let deadlineTimer;
1762
+ const deadline = new Promise((resolve) => {
1763
+ deadlineTimer = setTimeout(() => resolve({ stdout: "", stderr: "", exitCode: -1, timedOut: true }), timeoutMs);
1764
+ });
1589
1765
  try {
1590
- const stdoutPromise = new Response(proc.stdout).text();
1591
- const stderrPromise = captureStderr ? new Response(proc.stderr).text() : Promise.resolve("");
1592
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
1593
- const exitCode = await proc.exited;
1594
- return { stdout, stderr, exitCode };
1595
- } catch {
1596
- return { stdout: "", stderr: "", exitCode: -1 };
1766
+ return await Promise.race([captured, deadline]);
1597
1767
  } finally {
1598
- clearTimeout(timer);
1768
+ clearTimeout(term);
1769
+ clearTimeout(deadlineTimer);
1599
1770
  }
1600
1771
  }
1601
1772
 
@@ -1718,6 +1889,7 @@ export {
1718
1889
  validateValue,
1719
1890
  unsetSetting,
1720
1891
  subscribe,
1892
+ setSettingsWarnSink,
1721
1893
  setSetting,
1722
1894
  serializeIdentity,
1723
1895
  rtCommand,
@@ -1734,8 +1906,10 @@ export {
1734
1906
  paneSend,
1735
1907
  panePeek,
1736
1908
  paneList,
1909
+ paneFocus,
1737
1910
  paneDirectories,
1738
1911
  paneAccounts,
1912
+ openSmartPane,
1739
1913
  normalizeRemote,
1740
1914
  listTeams,
1741
1915
  listSettings,
@@ -1749,6 +1923,7 @@ export {
1749
1923
  expandVariables,
1750
1924
  eventsHead,
1751
1925
  deriveRepoIdentity,
1926
+ decidePlacement,
1752
1927
  daemonHealth,
1753
1928
  createRelay,
1754
1929
  clearIdentityMemo,
@@ -11,6 +11,8 @@ export interface RunResult {
11
11
  stdout: string;
12
12
  stderr: string;
13
13
  exitCode: number;
14
+ /** Set true only when the deadline fired before the child settled. */
15
+ timedOut?: boolean;
14
16
  }
15
17
  /**
16
18
  * Run argv and capture stdout. Never throws: spawn failures and timeouts
@@ -120,6 +120,11 @@ export interface ExpandCtx {
120
120
  * mutates its input.
121
121
  */
122
122
  export declare function expandVariables(value: unknown, ctx: ExpandCtx): unknown;
123
+ /** The daemon binds a deduped log.warn here so a hot-path getSetting on a
124
+ * disallowed-scope key warns once, not every tick. Default: console.warn
125
+ * (CLI/test behavior unchanged). null restores the default. */
126
+ export declare function setSettingsWarnSink(sink: ((msg: string) => void) | null): void;
127
+ export declare function emitSettingsWarning(msg: string): void;
123
128
  /**
124
129
  * Resolves one key across the whole ladder. Throws for an unregistered key —
125
130
  * an explicit get of something rt has never heard of is a caller bug, not a
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Layout-smart herdr pane placement. `decidePlacement` is pure (geometry ->
3
+ * where a new pane should go); `openSmartPane` reads an anchor pane's real
4
+ * rect, decides, and performs the herdr call through an injected caller so it
5
+ * works over any transport (socket API or a CLI adapter).
6
+ */
7
+ export type Placement = {
8
+ kind: "split";
9
+ direction: "right" | "down";
10
+ } | {
11
+ kind: "tab";
12
+ };
13
+ export interface PlacementOpts {
14
+ minCols?: number;
15
+ minRows?: number;
16
+ }
17
+ export interface HerdrCall {
18
+ (method: string, params: Record<string, unknown>): Promise<{
19
+ ok: true;
20
+ result: any;
21
+ } | {
22
+ ok: false;
23
+ code: string;
24
+ message: string;
25
+ }>;
26
+ }
27
+ /**
28
+ * Where to put a new pane relative to an anchor whose rect (in cells) is given.
29
+ * Cells are ~2:1 (height:width in px), so a pane is visually wider than tall
30
+ * when width > 2*height; split the longer visual axis so both halves stay
31
+ * usable. A null/degenerate rect falls back to a right split (old behavior).
32
+ */
33
+ export declare function decidePlacement(rect: {
34
+ width: number;
35
+ height: number;
36
+ } | null, opts?: PlacementOpts): Placement;
37
+ /**
38
+ * Open a herdr pane placed intelligently next to `anchorPaneId` (split right or
39
+ * down, or a new tab when crowded). Optionally run `command` in it. Returns the
40
+ * new pane id and the placement chosen. Throws on a herdr failure.
41
+ */
42
+ export declare function openSmartPane(herdr: HerdrCall, anchorPaneId: string, opts?: {
43
+ command?: string;
44
+ focus?: boolean;
45
+ } & PlacementOpts): Promise<{
46
+ paneId: string;
47
+ placement: Placement;
48
+ }>;
@@ -2,6 +2,13 @@ export interface RtResponse<T = unknown> {
2
2
  ok: boolean;
3
3
  data?: T;
4
4
  error?: string;
5
+ /** Structured form of `error` on a handler throw (R035): `code` defaults
6
+ * to "handler-threw" when the thrown error carries none. Additive; older
7
+ * daemons and the reject path never set this. */
8
+ failure?: {
9
+ code: string;
10
+ message: string;
11
+ };
5
12
  }
6
13
  export interface RtClientOptions {
7
14
  sockPath?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -51,5 +51,8 @@
51
51
  },
52
52
  "publishConfig": {
53
53
  "access": "public"
54
+ },
55
+ "devDependencies": {
56
+ "typescript": "^5.9.2"
54
57
  }
55
58
  }
package/src/client.ts CHANGED
@@ -388,3 +388,12 @@ export function paneSend(
388
388
  if (a.callerPane !== undefined) payload.callerPane = a.callerPane;
389
389
  return rtCommand<Commands["pane:send"]["data"]>("pane:send", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 30_000 });
390
390
  }
391
+
392
+ /** Brings a herdr pane to the front. The daemon routes this to the tray, which
393
+ owns the herdr focus and the native terminal-window raise. */
394
+ export function paneFocus(
395
+ a: Commands["pane:focus"]["payload"],
396
+ o: RtClientOptions = {},
397
+ ): Promise<RtResponse<Commands["pane:focus"]["data"]>> {
398
+ return rtCommand<Commands["pane:focus"]["data"]>("pane:focus", { paneId: a.paneId }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
399
+ }
package/src/commands.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * its functions against this map so a new command only needs an entry here
5
5
  * plus one function, never a change to the transport itself.
6
6
  */
7
- import type { PullRequest, MRDetail } from "@mattstack/glance";
7
+ import type { PullRequest, MRDetail, Pipeline } from "@mattstack/glance";
8
8
 
9
9
  export type Discussion = MRDetail["discussions"][number];
10
10
 
@@ -166,6 +166,7 @@ export interface InviteResult { paneId: string; delivered: "accepted" | "queued"
166
166
  /** Duplicated shape on purpose: mirrors lib/daemon/inject.ts's InjectResult. */
167
167
  export type PaneDelivery = "accepted" | "queued" | "refused";
168
168
  export interface PaneSendResult { paneId: string; delivered: PaneDelivery; reason?: string }
169
+ export interface PaneFocusResult { paneId: string; focused: boolean }
169
170
 
170
171
  // SKILLS-53: one judgment, computed once in rt, so the console and the tray
171
172
  // never derive two verdicts that can disagree.
@@ -225,6 +226,127 @@ export interface AgentRecord {
225
226
  createdAt: number; lastResumedAt?: number; finishedAt?: number;
226
227
  }
227
228
 
229
+ // ─── The daemon's remaining out-of-process commands (R013/R016) ──
230
+ // rt CLI <-> daemon, tray <-> daemon, and VS Code extension <-> daemon are
231
+ // all separate OS processes, so any command reachable from one counts as
232
+ // "external" here even when the only known caller today is rt's own CLI.
233
+
234
+ /** Duplicated shape on purpose (see EventsBusEvent above): mirrors lib/daemon/health.ts's HealthSnapshot. */
235
+ export type HealthLevel = "ok" | "degraded" | "unhealthy";
236
+ export interface HealthMetrics { rss: number; heapUsed: number; external: number; uptimeMs: number; wsClients: number; watchers: number }
237
+ export interface HealthEventLoop { maxLagMs: number; lastStallAt: number | null; lastStallCmd: string | null; stalls: number }
238
+ export interface DaemonIdentity { flavor: "dev" | "prod"; version: string; sourceRev: string | null; startedAt: number }
239
+
240
+ export interface PingData extends DaemonIdentity {
241
+ uptime: number;
242
+ pid: number;
243
+ health: HealthLevel;
244
+ eventLoop: HealthEventLoop;
245
+ heartbeatSeq: number;
246
+ supervision: { bootAttempts: number; lastReadyAt: number | null; recentFailures: unknown[]; lastExit: unknown };
247
+ }
248
+
249
+ export interface StatusData {
250
+ pid: number; uptime: number; watchedRepos: number; cacheEntries: number;
251
+ portsCached: number; portCacheAge: number | null;
252
+ freshness: unknown; identity: DaemonIdentity;
253
+ health: { level: HealthLevel; reasons: string[] }; metrics: HealthMetrics; eventLoop: HealthEventLoop;
254
+ worktreePool: { dormant: true; repos: string[]; message: string } | { dormant: false };
255
+ }
256
+
257
+ export interface TrayStatusData {
258
+ pid: number; uptime: number; memoryUsage: number; watchedRepos: number; cacheEntries: number;
259
+ portsCached: number; portCacheAge: number | null; lastRefresh: number | null;
260
+ portsByRepo: Record<string, number>; pendingNotifications: number;
261
+ health: { level: HealthLevel; reasons: string[] }; metrics: HealthMetrics; eventLoop: HealthEventLoop;
262
+ }
263
+
264
+ /** Duplicated shape on purpose: mirrors lib/port-scanner.ts's PortEntry. */
265
+ export interface PortEntry {
266
+ port: number; pid: number; command: string; cwd: string;
267
+ repo: string | null; worktree: string | null; branch: string | null;
268
+ relativeDir: string; uptime: string;
269
+ }
270
+
271
+ export interface PortsData {
272
+ ports: PortEntry[];
273
+ grouped: Record<string, Record<string, PortEntry[]>>;
274
+ updatedAt: number;
275
+ age: number | null;
276
+ }
277
+
278
+ /** Duplicated shape on purpose: mirrors lib/state/notifier-store.ts's NotificationEvent. */
279
+ export interface RtNotificationEvent {
280
+ id: string; title: string; message: string; url?: string;
281
+ category: string; timestamp: number; pids?: number[];
282
+ }
283
+
284
+ export interface ReposData {
285
+ repos: Record<string, { path: string; worktrees: Array<{ path: string; branch: string }> }>;
286
+ watched: string[];
287
+ }
288
+
289
+ export interface TccCheckData {
290
+ blocked: Array<{ name: string; path: string; error: string }>;
291
+ accessible: string[];
292
+ totalRepos: number;
293
+ daemonPid: number;
294
+ }
295
+
296
+ export interface WorktreeTreeRow {
297
+ name: string; kind: string; state: string; path: string; branch: string | null;
298
+ repoName: string; mr: { iid: number; state: string; title: string } | null;
299
+ duplicateBranch?: true;
300
+ [extra: string]: unknown;
301
+ }
302
+ export interface WorktreeListData {
303
+ trees: WorktreeTreeRow[];
304
+ dormant?: true; dormantRepos?: string[]; message?: string;
305
+ readyHeld?: true; readyHeldRepos?: string[];
306
+ }
307
+ export interface WorktreeProvisionData {
308
+ tree: string; path: string; branch: string; wasOnDeck: boolean;
309
+ readyAt: string | null; branchState: "new" | "tracking-remote" | "existing-clean" | "diverged" | "behind";
310
+ readyFailed?: true; failedStep?: string;
311
+ }
312
+ export interface WorktreeCreateData { tree: string; path: string }
313
+ export interface WorktreeDisposeData {
314
+ disposed: string[];
315
+ refused: Array<{ tree: string; reason: string }>;
316
+ recoverable: Array<{ tree: string; path: string; until: string }>;
317
+ }
318
+ export interface WorktreeRestoreData {
319
+ restored: true; path: string; tree: string; readyFailed?: true; failedStep?: string;
320
+ }
321
+ export interface WorktreeFreshenData { ran: string[] }
322
+ export interface WorktreeAdoptData {
323
+ main: string; claimed: string[]; unmanaged: string[]; disposed: string[];
324
+ refused: Array<{ tree: string; reason: string }>;
325
+ }
326
+
327
+ /** Duplicated shape on purpose: mirrors lib/endpoint/store.ts's EndpointClaim. */
328
+ export interface EndpointClaim { worktree: string; role: string; port: number; ts: number }
329
+ export interface EndpointRoleRef { port: number; url: string; running: boolean }
330
+ export interface EndpointClaimData { role: string; port: number; url: string; refs: Record<string, EndpointRoleRef> }
331
+ export interface EndpointLookupData { claimed: boolean; port: number | null; url: string | null; running: boolean }
332
+ export interface EndpointReleaseData { released: number }
333
+ export interface EndpointStatusData { repos: Record<string, Array<EndpointClaim & { running: boolean }>> }
334
+
335
+ /**
336
+ * Duplicated shape on purpose: mirrors @mattstack/glance's `JobDetail`
337
+ * (types.ts), which the package does not re-export from its index.
338
+ */
339
+ export type MrJobDetail = { type: "trace"; content: string } | { type: "bridge"; downstreamPipeline: Pipeline };
340
+
341
+ export interface DiscussionsWriteData { discussions: Discussion[]; fetchedAt: number }
342
+ export interface DiscussionsDiffsData { diffs: Array<{ newPath: string; diff: string }>; truncated: boolean }
343
+
344
+ export type MRActionName =
345
+ | "merge" | "rebase" | "approve" | "unapprove"
346
+ | "setAutoMerge" | "cancelAutoMerge"
347
+ | "retryJob" | "retryPipeline"
348
+ | "toggleDraft" | "requestReReview";
349
+
228
350
  export interface Commands {
229
351
  "project-mrs:read": { payload: { repoName: string; maxAgeMs?: number; demand?: DemandDecl }; data: ProjectMRsData };
230
352
  "discussions:read": { payload: { repoName: string; iid: number }; data: DiscussionsData };
@@ -353,6 +475,61 @@ export interface Commands {
353
475
  data: { pane: ChatPane; ready: boolean };
354
476
  };
355
477
  "pane:send": { payload: { paneId: string; text: string; callerPane?: string }; data: PaneSendResult };
478
+ "pane:focus": { payload: { paneId: string }; data: PaneFocusResult };
479
+
480
+ // ─── R013/R016 ────────────────────────────────────────────────
481
+ "cache:read": { payload: { branches?: string[]; maxAgeMs?: number; repoIdentity?: string }; data: Record<string, BranchEnrichment> };
482
+ /** `source` ("cache"|"fresh"|"empty") rides alongside `data` on the wire, not nested under it. */
483
+ "branch:enrich": { payload: { branch: string; repoPath?: string; remoteUrl?: string; repoIdentity?: string }; data: BranchEnrichment | null };
484
+ /** Fire-and-forget kickoff; wire reply is `{ok, message}`, not `{ok,data}`. */
485
+ "cache:refresh": { payload: Record<string, never>; data: { message: string } };
486
+ "daemon:log-level": { payload: { level?: "trace" | "debug" | "info" | "warn" | "error" }; data: { level: string } };
487
+ "ping": { payload: Record<string, never>; data: PingData };
488
+ "status": { payload: Record<string, never>; data: StatusData };
489
+ "tray:status": { payload: Record<string, never>; data: TrayStatusData };
490
+ "tcc:check": { payload: Record<string, never>; data: TccCheckData };
491
+ "repos": { payload: Record<string, never>; data: ReposData };
492
+ "ports": { payload: { repo?: string; refresh?: boolean }; data: PortsData };
493
+ "notifications": { payload: Record<string, never>; data: RtNotificationEvent[] };
494
+
495
+ "discussions:refresh": { payload: { repoName: string; iid: number }; data: DiscussionsWriteData };
496
+ "discussions:resolve": { payload: { repoName: string; iid: number; discussionId: string; resolved?: boolean }; data: DiscussionsWriteData };
497
+ "discussions:reply": { payload: { repoName: string; iid: number; discussionId: string; body: string }; data: DiscussionsWriteData };
498
+ "discussions:diffs": { payload: { repoName: string; iid: number }; data: DiscussionsDiffsData };
499
+
500
+ /** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
501
+ "mr:action": { payload: { repoName: string; iid: number; action: MRActionName; args?: unknown[] }; data: Record<string, never> };
502
+ "mr:fetch-job-detail": { payload: { repoName: string; iid: number; jobId: number; pipelineId?: number }; data: MrJobDetail };
503
+ "mr:fetch-job-trace": { payload: { repoName: string; iid: number; jobId: number }; data: string };
504
+
505
+ "endpoint:claim": { payload: { repo: string; worktree: string; role: string; pid?: number }; data: EndpointClaimData };
506
+ "endpoint:lookup": { payload: { repo: string; worktree: string; role: string }; data: EndpointLookupData };
507
+ "endpoint:release": { payload: { repo: string; worktree: string; role?: string }; data: EndpointReleaseData };
508
+ "endpoint:status": { payload: { repo?: string }; data: EndpointStatusData };
509
+
510
+ "repos:locate": { payload: { newPath: string; repo?: string; dryRun?: boolean }; data: unknown };
511
+ "freshness:reconcile": { payload: Record<string, never>; data: unknown };
512
+
513
+ /** Wire reply on success is always `{ok:true, repaired}` (no `data`
514
+ * wrapper) — `data` here documents the extra field the same way PingData
515
+ * does for `ping`, not the literal wire nesting (R3). */
516
+ "hooks:repair": { payload: { repo: string }; data: { repaired: boolean } };
517
+ "hooks:watch": { payload: { repo: string }; data: Record<string, never> };
518
+
519
+ "sdm:catalog": { payload: { refresh?: boolean }; data: unknown };
520
+ "sdm:snapshot": { payload: { force?: boolean }; data: unknown };
521
+ "sdm:recents": { payload: Record<string, never>; data: unknown };
522
+ "sdm:reconnect": { payload: { key: string }; data: unknown };
523
+
524
+ "system-processes": { payload: Record<string, never>; data: unknown };
525
+
526
+ "worktree:provision": { payload: { repoName: string; branch?: string; ticket?: string; ticketTitle?: string; disposal?: "job" | "merge"; owner?: string }; data: WorktreeProvisionData };
527
+ "worktree:create": { payload: { repoName: string; onDeck?: boolean }; data: WorktreeCreateData };
528
+ "worktree:dispose": { payload: { repoName?: string; owner?: string; tree?: string; force?: boolean; callerPid?: number }; data: WorktreeDisposeData };
529
+ "worktree:list": { payload: { repoName?: string }; data: WorktreeListData };
530
+ "worktree:restore": { payload: { repoName: string; tree: string }; data: WorktreeRestoreData };
531
+ "worktree:freshen": { payload: { repoName?: string; tree?: string }; data: WorktreeFreshenData };
532
+ "worktree:adopt": { payload: { repoName: string; claim?: boolean }; data: WorktreeAdoptData };
356
533
  }
357
534
 
358
535
  export type CommandName = keyof Commands;
@@ -397,4 +574,45 @@ export const COMMAND_NAMES: readonly CommandName[] = [
397
574
  "pane:directories",
398
575
  "pane:spawn",
399
576
  "pane:send",
577
+ "pane:focus",
578
+
579
+ // ─── R013/R016 ────────────────────────────────────────────────
580
+ "cache:read",
581
+ "branch:enrich",
582
+ "cache:refresh",
583
+ "daemon:log-level",
584
+ "ping",
585
+ "status",
586
+ "tray:status",
587
+ "tcc:check",
588
+ "repos",
589
+ "ports",
590
+ "notifications",
591
+ "discussions:refresh",
592
+ "discussions:resolve",
593
+ "discussions:reply",
594
+ "discussions:diffs",
595
+ "mr:action",
596
+ "mr:fetch-job-detail",
597
+ "mr:fetch-job-trace",
598
+ "endpoint:claim",
599
+ "endpoint:lookup",
600
+ "endpoint:release",
601
+ "endpoint:status",
602
+ "repos:locate",
603
+ "freshness:reconcile",
604
+ "hooks:repair",
605
+ "hooks:watch",
606
+ "sdm:catalog",
607
+ "sdm:snapshot",
608
+ "sdm:recents",
609
+ "sdm:reconnect",
610
+ "system-processes",
611
+ "worktree:provision",
612
+ "worktree:create",
613
+ "worktree:dispose",
614
+ "worktree:list",
615
+ "worktree:restore",
616
+ "worktree:freshen",
617
+ "worktree:adopt",
400
618
  ];