@mattstack/rt-client 0.10.1 → 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
  });
@@ -286,7 +286,45 @@ var COMMAND_NAMES = [
286
286
  "pane:directories",
287
287
  "pane:spawn",
288
288
  "pane:send",
289
- "pane:focus"
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"
290
328
  ];
291
329
  // src/relay.ts
292
330
  var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
@@ -413,6 +451,56 @@ function repoNameForPath(repoPath, reposJsonPath) {
413
451
  return fromDb;
414
452
  return repoNameFromJson(repoPath, jsonPath);
415
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
+ }
416
504
  // src/settings/resolve.ts
417
505
  import { homedir as homedir4 } from "os";
418
506
  import { join as join5 } from "path";
@@ -482,6 +570,15 @@ var REGISTRY = [
482
570
  migrated: true,
483
571
  description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader."
484
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
+ },
485
582
  {
486
583
  key: "rt.repoIdentityOverrides",
487
584
  type: "object",
@@ -627,6 +724,24 @@ var REGISTRY = [
627
724
  migrated: true,
628
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."
629
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
+ },
630
745
  {
631
746
  key: "rt.hooks",
632
747
  type: "object",
@@ -636,6 +751,21 @@ var REGISTRY = [
636
751
  migrated: true,
637
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."
638
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
+ },
639
769
  {
640
770
  key: "mattstack.integrations",
641
771
  type: "object",
@@ -1296,8 +1426,24 @@ function expandCtxFrom(opts) {
1296
1426
  teamsDir: teamsDir()
1297
1427
  };
1298
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
+ }
1299
1445
  function warnInvalid(key, entry) {
1300
- 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}`);
1301
1447
  }
1302
1448
  function getSetting(key, opts = {}) {
1303
1449
  const def = getDef(key);
@@ -1332,7 +1478,7 @@ function listSettings(opts = {}) {
1332
1478
  listed.value = expandVariables(resolution.value, ctx);
1333
1479
  } catch (err) {
1334
1480
  listed.expandError = err.message;
1335
- console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
1481
+ emitSettingsWarning(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
1336
1482
  }
1337
1483
  }
1338
1484
  out.push(listed);
@@ -1360,7 +1506,7 @@ function listUnregistered(stores, opts) {
1360
1506
  scan("machine", stores.machine.file, stores.machine.global);
1361
1507
  scan("machine.repo", stores.machine.file, repoSection(stores.machine));
1362
1508
  return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, hit]) => {
1363
- 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)`);
1364
1510
  return {
1365
1511
  key,
1366
1512
  value: hit.value,
@@ -1585,21 +1731,42 @@ async function runCapture(argv, opts = {}) {
1585
1731
  } catch {
1586
1732
  return { stdout: "", stderr: "", exitCode: -1 };
1587
1733
  }
1588
- const timer = setTimeout(() => {
1734
+ const timeoutMs = opts.timeoutMs ?? 1e4;
1735
+ let killTimer;
1736
+ const term = setTimeout(() => {
1589
1737
  try {
1590
- proc.kill();
1738
+ proc.kill("SIGTERM");
1591
1739
  } catch {}
1592
- }, 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
+ });
1593
1765
  try {
1594
- const stdoutPromise = new Response(proc.stdout).text();
1595
- const stderrPromise = captureStderr ? new Response(proc.stderr).text() : Promise.resolve("");
1596
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
1597
- const exitCode = await proc.exited;
1598
- return { stdout, stderr, exitCode };
1599
- } catch {
1600
- return { stdout: "", stderr: "", exitCode: -1 };
1766
+ return await Promise.race([captured, deadline]);
1601
1767
  } finally {
1602
- clearTimeout(timer);
1768
+ clearTimeout(term);
1769
+ clearTimeout(deadlineTimer);
1603
1770
  }
1604
1771
  }
1605
1772
 
@@ -1722,6 +1889,7 @@ export {
1722
1889
  validateValue,
1723
1890
  unsetSetting,
1724
1891
  subscribe,
1892
+ setSettingsWarnSink,
1725
1893
  setSetting,
1726
1894
  serializeIdentity,
1727
1895
  rtCommand,
@@ -1741,6 +1909,7 @@ export {
1741
1909
  paneFocus,
1742
1910
  paneDirectories,
1743
1911
  paneAccounts,
1912
+ openSmartPane,
1744
1913
  normalizeRemote,
1745
1914
  listTeams,
1746
1915
  listSettings,
@@ -1754,6 +1923,7 @@ export {
1754
1923
  expandVariables,
1755
1924
  eventsHead,
1756
1925
  deriveRepoIdentity,
1926
+ decidePlacement,
1757
1927
  daemonHealth,
1758
1928
  createRelay,
1759
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.1",
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/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
 
@@ -226,6 +226,127 @@ export interface AgentRecord {
226
226
  createdAt: number; lastResumedAt?: number; finishedAt?: number;
227
227
  }
228
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
+
229
350
  export interface Commands {
230
351
  "project-mrs:read": { payload: { repoName: string; maxAgeMs?: number; demand?: DemandDecl }; data: ProjectMRsData };
231
352
  "discussions:read": { payload: { repoName: string; iid: number }; data: DiscussionsData };
@@ -355,6 +476,60 @@ export interface Commands {
355
476
  };
356
477
  "pane:send": { payload: { paneId: string; text: string; callerPane?: string }; data: PaneSendResult };
357
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 };
358
533
  }
359
534
 
360
535
  export type CommandName = keyof Commands;
@@ -400,4 +575,44 @@ export const COMMAND_NAMES: readonly CommandName[] = [
400
575
  "pane:spawn",
401
576
  "pane:send",
402
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",
403
618
  ];
package/src/index.ts CHANGED
@@ -86,9 +86,12 @@ export { daemonHealth } from "./health.ts";
86
86
 
87
87
  export { repoNameForPath } from "./repos.ts";
88
88
 
89
+ export { decidePlacement, openSmartPane } from "./smart-pane.ts";
90
+ export type { Placement, PlacementOpts, HerdrCall } from "./smart-pane.ts";
91
+
89
92
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
90
93
 
91
- export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
94
+ export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER, setSettingsWarnSink } from "./settings/resolve.ts";
92
95
  export type {
93
96
  Scope,
94
97
  Provenance,