@mattstack/rt-client 0.10.1 → 0.11.1

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/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
 
@@ -25,6 +25,10 @@ export interface ProjectMRsScope {
25
25
  sections?: string[];
26
26
  /** Demanded sections not yet swept for this client. */
27
27
  uncoveredSections?: string[];
28
+ /** Section headers in the default-branch CODEOWNERS at the last deep or
29
+ backfill. `[]` when the project has none. Absent from a pre-knownSections
30
+ daemon or before the first sweep that demanded a section. */
31
+ knownSections?: string[];
28
32
  }
29
33
 
30
34
  export interface ProjectMRsData {
@@ -108,6 +112,12 @@ export interface ChatMessage {
108
112
  postedAt: number;
109
113
  }
110
114
 
115
+ /** `claimed` is the only outcome that woke anyone; `previousHolder` marks a takeover of an expired claim. */
116
+ export type ChatClaimOutcome =
117
+ | { outcome: "claimed"; author: string; room: string; previousHolder?: string }
118
+ | { outcome: "held"; author: string; room: string }
119
+ | { outcome: "lost"; holder: string; claimedAt: number; expiresAt: number };
120
+
111
121
  export interface RoomSummary {
112
122
  room: string;
113
123
  memberCount: number;
@@ -226,6 +236,139 @@ export interface AgentRecord {
226
236
  createdAt: number; lastResumedAt?: number; finishedAt?: number;
227
237
  }
228
238
 
239
+ // ─── The daemon's remaining out-of-process commands (R013/R016) ──
240
+ // rt CLI <-> daemon, tray <-> daemon, and VS Code extension <-> daemon are
241
+ // all separate OS processes, so any command reachable from one counts as
242
+ // "external" here even when the only known caller today is rt's own CLI.
243
+
244
+ /** Duplicated shape on purpose (see EventsBusEvent above): mirrors lib/daemon/health.ts's HealthSnapshot. */
245
+ export type HealthLevel = "ok" | "degraded" | "unhealthy";
246
+ export interface HealthMetrics { rss: number; heapUsed: number; external: number; uptimeMs: number; wsClients: number; watchers: number }
247
+ export interface HealthEventLoop { maxLagMs: number; lastStallAt: number | null; lastStallCmd: string | null; stalls: number }
248
+ export interface DaemonIdentity { flavor: "dev" | "prod"; version: string; sourceRev: string | null; startedAt: number }
249
+
250
+ export interface PingData extends DaemonIdentity {
251
+ uptime: number;
252
+ pid: number;
253
+ health: HealthLevel;
254
+ eventLoop: HealthEventLoop;
255
+ heartbeatSeq: number;
256
+ supervision: { bootAttempts: number; lastReadyAt: number | null; recentFailures: unknown[]; lastExit: unknown };
257
+ }
258
+
259
+ export interface StatusData {
260
+ pid: number; uptime: number; watchedRepos: number; cacheEntries: number;
261
+ portsCached: number; portCacheAge: number | null;
262
+ freshness: unknown; identity: DaemonIdentity;
263
+ health: { level: HealthLevel; reasons: string[] }; metrics: HealthMetrics; eventLoop: HealthEventLoop;
264
+ worktreePool: { dormant: true; repos: string[]; message: string } | { dormant: false };
265
+ }
266
+
267
+ /** Duplicated shape on purpose: mirrors lib/worktree/ready-held.ts's ReadyHeldRepo. */
268
+ export interface ReadyHeldRepo {
269
+ /** Serialized repo identity. A key, never displayed. */
270
+ repo: string;
271
+ /** Decoded display name. Never sent back as a key. */
272
+ label: string;
273
+ hash: string;
274
+ approveCommand: string;
275
+ }
276
+
277
+ export interface TrayStatusData {
278
+ pid: number; uptime: number; memoryUsage: number; watchedRepos: number; cacheEntries: number;
279
+ portsCached: number; portCacheAge: number | null; lastRefresh: number | null;
280
+ portsByRepo: Record<string, number>; pendingNotifications: number;
281
+ health: { level: HealthLevel; reasons: string[] }; metrics: HealthMetrics; eventLoop: HealthEventLoop;
282
+ /** Optional because a daemon older than RT-98 does not send it. */
283
+ worktreeReadyHeld?: ReadyHeldRepo[];
284
+ }
285
+
286
+ /** Duplicated shape on purpose: mirrors lib/port-scanner.ts's PortEntry. */
287
+ export interface PortEntry {
288
+ port: number; pid: number; command: string; cwd: string;
289
+ repo: string | null; worktree: string | null; branch: string | null;
290
+ relativeDir: string; uptime: string;
291
+ }
292
+
293
+ export interface PortsData {
294
+ ports: PortEntry[];
295
+ grouped: Record<string, Record<string, PortEntry[]>>;
296
+ updatedAt: number;
297
+ age: number | null;
298
+ }
299
+
300
+ /** Duplicated shape on purpose: mirrors lib/state/notifier-store.ts's NotificationEvent. */
301
+ export interface RtNotificationEvent {
302
+ id: string; title: string; message: string; url?: string;
303
+ category: string; timestamp: number; pids?: number[];
304
+ }
305
+
306
+ export interface ReposData {
307
+ repos: Record<string, { path: string; worktrees: Array<{ path: string; branch: string }> }>;
308
+ watched: string[];
309
+ }
310
+
311
+ export interface TccCheckData {
312
+ blocked: Array<{ name: string; path: string; error: string }>;
313
+ accessible: string[];
314
+ totalRepos: number;
315
+ daemonPid: number;
316
+ }
317
+
318
+ export interface WorktreeTreeRow {
319
+ name: string; kind: string; state: string; path: string; branch: string | null;
320
+ repoName: string; mr: { iid: number; state: string; title: string } | null;
321
+ duplicateBranch?: true;
322
+ [extra: string]: unknown;
323
+ }
324
+ export interface WorktreeListData {
325
+ trees: WorktreeTreeRow[];
326
+ dormant?: true; dormantRepos?: string[]; message?: string;
327
+ readyHeld?: true; readyHeldRepos?: string[];
328
+ }
329
+ export interface WorktreeProvisionData {
330
+ tree: string; path: string; branch: string; wasOnDeck: boolean;
331
+ readyAt: string | null; branchState: "new" | "tracking-remote" | "existing-clean" | "diverged" | "behind";
332
+ readyFailed?: true; failedStep?: string;
333
+ }
334
+ export interface WorktreeCreateData { tree: string; path: string }
335
+ export interface WorktreeDisposeData {
336
+ disposed: string[];
337
+ refused: Array<{ tree: string; reason: string }>;
338
+ recoverable: Array<{ tree: string; path: string; until: string }>;
339
+ }
340
+ export interface WorktreeRestoreData {
341
+ restored: true; path: string; tree: string; readyFailed?: true; failedStep?: string;
342
+ }
343
+ export interface WorktreeFreshenData { ran: string[] }
344
+ export interface WorktreeAdoptData {
345
+ main: string; claimed: string[]; unmanaged: string[]; disposed: string[];
346
+ refused: Array<{ tree: string; reason: string }>;
347
+ }
348
+
349
+ /** Duplicated shape on purpose: mirrors lib/endpoint/store.ts's EndpointClaim. */
350
+ export interface EndpointClaim { worktree: string; role: string; port: number; ts: number }
351
+ export interface EndpointRoleRef { port: number; url: string; running: boolean }
352
+ export interface EndpointClaimData { role: string; port: number; url: string; refs: Record<string, EndpointRoleRef> }
353
+ export interface EndpointLookupData { claimed: boolean; port: number | null; url: string | null; running: boolean }
354
+ export interface EndpointReleaseData { released: number }
355
+ export interface EndpointStatusData { repos: Record<string, Array<EndpointClaim & { running: boolean }>> }
356
+
357
+ /**
358
+ * Duplicated shape on purpose: mirrors @mattstack/glance's `JobDetail`
359
+ * (types.ts), which the package does not re-export from its index.
360
+ */
361
+ export type MrJobDetail = { type: "trace"; content: string } | { type: "bridge"; downstreamPipeline: Pipeline };
362
+
363
+ export interface DiscussionsWriteData { discussions: Discussion[]; fetchedAt: number }
364
+ export interface DiscussionsDiffsData { diffs: Array<{ newPath: string; diff: string }>; truncated: boolean }
365
+
366
+ export type MRActionName =
367
+ | "merge" | "rebase" | "approve" | "unapprove"
368
+ | "setAutoMerge" | "cancelAutoMerge"
369
+ | "retryJob" | "retryPipeline"
370
+ | "toggleDraft" | "requestReReview";
371
+
229
372
  export interface Commands {
230
373
  "project-mrs:read": { payload: { repoName: string; maxAgeMs?: number; demand?: DemandDecl }; data: ProjectMRsData };
231
374
  "discussions:read": { payload: { repoName: string; iid: number }; data: DiscussionsData };
@@ -284,7 +427,11 @@ export interface Commands {
284
427
  "runs:abandon": { payload: { runId: string; repo?: string; reason?: string }; data: { ok: boolean } };
285
428
  "chat:join": { payload: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string }; data: { handle: string; memberCount: number; unread: number } };
286
429
  "chat:leave": { payload: { room: string; handle: string }; data: Record<string, never> };
287
- "chat:post": { payload: { room: string; handle: string; body: string; mentions?: string[] }; data: { id: number; recipients: string[] } };
430
+ /** `others` counts the room's members besides the author, so a caller can tell "woke nobody of 7" from "nobody else is here". */
431
+ "chat:post": { payload: { room: string; handle: string; body: string; mentions?: string[]; quiet?: boolean }; data: { id: number; recipients: string[]; others: number } };
432
+ "chat:ack": { payload: { id: number; handle: string }; data: { author: string; room: string; already: boolean } };
433
+ "chat:claim": { payload: { id: number; handle: string }; data: ChatClaimOutcome };
434
+ "chat:release": { payload: { id: number; handle: string }; data: { holder: string } };
288
435
  "chat:read": { payload: { handle: string; room?: string; limit?: number; sinceMs?: number }; data: { rooms: { room: string; messages: ChatMessage[] }[] } };
289
436
  "chat:rooms": { payload: { handle: string; includeArchived?: boolean }; data: { rooms: RoomSummary[] } };
290
437
  "chat:who": { payload: { room: string }; data: { members: ChatMember[] } };
@@ -355,6 +502,60 @@ export interface Commands {
355
502
  };
356
503
  "pane:send": { payload: { paneId: string; text: string; callerPane?: string }; data: PaneSendResult };
357
504
  "pane:focus": { payload: { paneId: string }; data: PaneFocusResult };
505
+
506
+ // ─── R013/R016 ────────────────────────────────────────────────
507
+ "cache:read": { payload: { branches?: string[]; maxAgeMs?: number; repoIdentity?: string }; data: Record<string, BranchEnrichment> };
508
+ /** `source` ("cache"|"fresh"|"empty") rides alongside `data` on the wire, not nested under it. */
509
+ "branch:enrich": { payload: { branch: string; repoPath?: string; remoteUrl?: string; repoIdentity?: string }; data: BranchEnrichment | null };
510
+ /** Fire-and-forget kickoff; wire reply is `{ok, message}`, not `{ok,data}`. */
511
+ "cache:refresh": { payload: Record<string, never>; data: { message: string } };
512
+ "daemon:log-level": { payload: { level?: "trace" | "debug" | "info" | "warn" | "error" }; data: { level: string } };
513
+ "ping": { payload: Record<string, never>; data: PingData };
514
+ "status": { payload: Record<string, never>; data: StatusData };
515
+ "tray:status": { payload: Record<string, never>; data: TrayStatusData };
516
+ "tcc:check": { payload: Record<string, never>; data: TccCheckData };
517
+ "repos": { payload: Record<string, never>; data: ReposData };
518
+ "ports": { payload: { repo?: string; refresh?: boolean }; data: PortsData };
519
+ "notifications": { payload: Record<string, never>; data: RtNotificationEvent[] };
520
+
521
+ "discussions:refresh": { payload: { repoName: string; iid: number }; data: DiscussionsWriteData };
522
+ "discussions:resolve": { payload: { repoName: string; iid: number; discussionId: string; resolved?: boolean }; data: DiscussionsWriteData };
523
+ "discussions:reply": { payload: { repoName: string; iid: number; discussionId: string; body: string }; data: DiscussionsWriteData };
524
+ "discussions:diffs": { payload: { repoName: string; iid: number }; data: DiscussionsDiffsData };
525
+
526
+ /** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
527
+ "mr:action": { payload: { repoName: string; iid: number; action: MRActionName; args?: unknown[] }; data: Record<string, never> };
528
+ "mr:fetch-job-detail": { payload: { repoName: string; iid: number; jobId: number; pipelineId?: number }; data: MrJobDetail };
529
+ "mr:fetch-job-trace": { payload: { repoName: string; iid: number; jobId: number }; data: string };
530
+
531
+ "endpoint:claim": { payload: { repo: string; worktree: string; role: string; pid?: number }; data: EndpointClaimData };
532
+ "endpoint:lookup": { payload: { repo: string; worktree: string; role: string }; data: EndpointLookupData };
533
+ "endpoint:release": { payload: { repo: string; worktree: string; role?: string }; data: EndpointReleaseData };
534
+ "endpoint:status": { payload: { repo?: string }; data: EndpointStatusData };
535
+
536
+ "repos:locate": { payload: { newPath: string; repo?: string; dryRun?: boolean }; data: unknown };
537
+ "freshness:reconcile": { payload: Record<string, never>; data: unknown };
538
+
539
+ /** Wire reply on success is always `{ok:true, repaired}` (no `data`
540
+ * wrapper) — `data` here documents the extra field the same way PingData
541
+ * does for `ping`, not the literal wire nesting (R3). */
542
+ "hooks:repair": { payload: { repo: string }; data: { repaired: boolean } };
543
+ "hooks:watch": { payload: { repo: string }; data: Record<string, never> };
544
+
545
+ "sdm:catalog": { payload: { refresh?: boolean }; data: unknown };
546
+ "sdm:snapshot": { payload: { force?: boolean }; data: unknown };
547
+ "sdm:recents": { payload: Record<string, never>; data: unknown };
548
+ "sdm:reconnect": { payload: { key: string }; data: unknown };
549
+
550
+ "system-processes": { payload: Record<string, never>; data: unknown };
551
+
552
+ "worktree:provision": { payload: { repoName: string; branch?: string; ticket?: string; ticketTitle?: string; disposal?: "job" | "merge"; owner?: string }; data: WorktreeProvisionData };
553
+ "worktree:create": { payload: { repoName: string; onDeck?: boolean }; data: WorktreeCreateData };
554
+ "worktree:dispose": { payload: { repoName?: string; owner?: string; tree?: string; force?: boolean; callerPid?: number }; data: WorktreeDisposeData };
555
+ "worktree:list": { payload: { repoName?: string }; data: WorktreeListData };
556
+ "worktree:restore": { payload: { repoName: string; tree: string }; data: WorktreeRestoreData };
557
+ "worktree:freshen": { payload: { repoName?: string; tree?: string }; data: WorktreeFreshenData };
558
+ "worktree:adopt": { payload: { repoName: string; claim?: boolean }; data: WorktreeAdoptData };
358
559
  }
359
560
 
360
561
  export type CommandName = keyof Commands;
@@ -372,6 +573,9 @@ export const COMMAND_NAMES: readonly CommandName[] = [
372
573
  "runs:list",
373
574
  "runs:get",
374
575
  "runs:abandon",
576
+ "chat:ack",
577
+ "chat:claim",
578
+ "chat:release",
375
579
  "chat:join",
376
580
  "chat:leave",
377
581
  "chat:post",
@@ -400,4 +604,44 @@ export const COMMAND_NAMES: readonly CommandName[] = [
400
604
  "pane:spawn",
401
605
  "pane:send",
402
606
  "pane:focus",
607
+
608
+ // ─── R013/R016 ────────────────────────────────────────────────
609
+ "cache:read",
610
+ "branch:enrich",
611
+ "cache:refresh",
612
+ "daemon:log-level",
613
+ "ping",
614
+ "status",
615
+ "tray:status",
616
+ "tcc:check",
617
+ "repos",
618
+ "ports",
619
+ "notifications",
620
+ "discussions:refresh",
621
+ "discussions:resolve",
622
+ "discussions:reply",
623
+ "discussions:diffs",
624
+ "mr:action",
625
+ "mr:fetch-job-detail",
626
+ "mr:fetch-job-trace",
627
+ "endpoint:claim",
628
+ "endpoint:lookup",
629
+ "endpoint:release",
630
+ "endpoint:status",
631
+ "repos:locate",
632
+ "freshness:reconcile",
633
+ "hooks:repair",
634
+ "hooks:watch",
635
+ "sdm:catalog",
636
+ "sdm:snapshot",
637
+ "sdm:recents",
638
+ "sdm:reconnect",
639
+ "system-processes",
640
+ "worktree:provision",
641
+ "worktree:create",
642
+ "worktree:dispose",
643
+ "worktree:list",
644
+ "worktree:restore",
645
+ "worktree:freshen",
646
+ "worktree:adopt",
403
647
  ];
package/src/index.ts CHANGED
@@ -12,6 +12,9 @@ export {
12
12
  abandonRun,
13
13
  chatJoin,
14
14
  chatLeave,
15
+ chatAck,
16
+ chatClaim,
17
+ chatRelease,
15
18
  chatPost,
16
19
  chatRead,
17
20
  chatRooms,
@@ -64,6 +67,7 @@ export type {
64
67
  WakeMode,
65
68
  ChatMember,
66
69
  ChatMessage,
70
+ ChatClaimOutcome,
67
71
  RoomSummary,
68
72
  BuddyStatus,
69
73
  PresenceRow,
@@ -86,9 +90,12 @@ export { daemonHealth } from "./health.ts";
86
90
 
87
91
  export { repoNameForPath } from "./repos.ts";
88
92
 
93
+ export { decidePlacement, openSmartPane } from "./smart-pane.ts";
94
+ export type { Placement, PlacementOpts, HerdrCall } from "./smart-pane.ts";
95
+
89
96
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
90
97
 
91
- export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
98
+ export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER, setSettingsWarnSink } from "./settings/resolve.ts";
92
99
  export type {
93
100
  Scope,
94
101
  Provenance,
@@ -12,6 +12,8 @@ export interface RunResult {
12
12
  stdout: string;
13
13
  stderr: string;
14
14
  exitCode: number;
15
+ /** Set true only when the deadline fired before the child settled. */
16
+ timedOut?: boolean;
15
17
  }
16
18
 
17
19
  /**
@@ -47,21 +49,53 @@ export async function runCapture(
47
49
  return { stdout: "", stderr: "", exitCode: -1 };
48
50
  }
49
51
 
50
- const timer = setTimeout(() => {
51
- try { proc.kill(); } catch { /* already exited */ }
52
- }, opts.timeoutMs ?? 10_000);
52
+ const timeoutMs = opts.timeoutMs ?? 10_000;
53
+ // SIGTERM at the deadline, SIGKILL a short grace later. A child that ignores
54
+ // SIGTERM (or a D-state descendant) cannot be reaped in-band, so the read is
55
+ // raced against the deadline below rather than awaited unconditionally: that
56
+ // is what lets runCapture settle while a grandchild still holds the pipe.
57
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
58
+ const term = setTimeout(() => {
59
+ try { proc.kill("SIGTERM"); } catch { /* already exited */ }
60
+ killTimer = setTimeout(() => {
61
+ try { proc.kill("SIGKILL"); } catch { /* already exited */ }
62
+ }, 2000);
63
+ // unref: a short-lived CLI process must not be held open by a timed-out
64
+ // call waiting on this timer; the daemon stays alive regardless, so its
65
+ // SIGKILL still fires.
66
+ killTimer.unref?.();
67
+ }, timeoutMs);
68
+
69
+ const captured: Promise<RunResult> = (async () => {
70
+ try {
71
+ const stdoutPromise = new Response(proc.stdout as ReadableStream).text();
72
+ const stderrPromise = captureStderr
73
+ ? new Response(proc.stderr as ReadableStream).text()
74
+ : Promise.resolve("");
75
+ const [stdout, stderr, exitCode] = await Promise.all([
76
+ stdoutPromise,
77
+ stderrPromise,
78
+ proc.exited,
79
+ ]);
80
+ return { stdout, stderr, exitCode };
81
+ } catch {
82
+ return { stdout: "", stderr: "", exitCode: -1 };
83
+ }
84
+ })();
85
+
86
+ let deadlineTimer: ReturnType<typeof setTimeout>;
87
+ const deadline: Promise<RunResult> = new Promise((resolve) => {
88
+ deadlineTimer = setTimeout(() => resolve({ stdout: "", stderr: "", exitCode: -1, timedOut: true }), timeoutMs);
89
+ });
53
90
 
54
91
  try {
55
- const stdoutPromise = new Response(proc.stdout as ReadableStream).text();
56
- const stderrPromise = captureStderr
57
- ? new Response(proc.stderr as ReadableStream).text()
58
- : Promise.resolve("");
59
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
60
- const exitCode = await proc.exited;
61
- return { stdout, stderr, exitCode };
62
- } catch {
63
- return { stdout: "", stderr: "", exitCode: -1 };
92
+ return await Promise.race([captured, deadline]);
64
93
  } finally {
65
- clearTimeout(timer);
94
+ clearTimeout(term);
95
+ clearTimeout(deadlineTimer!);
96
+ // killTimer intentionally NOT cleared here: on the timeout path it must
97
+ // survive this finally to fire SIGKILL against a child that ignored
98
+ // SIGTERM. proc.kill is already try/catch guarded, so it is a harmless
99
+ // no-op if the child exited before the 2s grace elapses.
66
100
  }
67
101
  }
@@ -44,6 +44,15 @@ export const REGISTRY: readonly SettingDef[] = [
44
44
  migrated: true,
45
45
  description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader.",
46
46
  },
47
+ {
48
+ key: "rt.worktreeReadyApproval",
49
+ type: "string",
50
+ scopes: ALL_SCOPES,
51
+ merge: "replace",
52
+ repoScoped: true,
53
+ migrated: true,
54
+ 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.",
55
+ },
47
56
  {
48
57
  key: "rt.repoIdentityOverrides",
49
58
  type: "object",
@@ -196,6 +205,24 @@ export const REGISTRY: readonly SettingDef[] = [
196
205
  migrated: true,
197
206
  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.",
198
207
  },
208
+ {
209
+ key: "rt.logLevel",
210
+ type: "string",
211
+ scopes: ["machine", "user"],
212
+ default: "info",
213
+ merge: "replace",
214
+ migrated: true,
215
+ 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.",
216
+ },
217
+ {
218
+ key: "rt.apiPort",
219
+ type: "number",
220
+ scopes: ["machine", "user"],
221
+ default: 9401,
222
+ merge: "replace",
223
+ migrated: true,
224
+ 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).",
225
+ },
199
226
  {
200
227
  key: "rt.hooks",
201
228
  type: "object",
@@ -205,6 +232,22 @@ export const REGISTRY: readonly SettingDef[] = [
205
232
  migrated: true,
206
233
  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.",
207
234
  },
235
+ {
236
+ key: "rt.daemonPath",
237
+ type: "string",
238
+ scopes: ["machine"],
239
+ merge: "replace",
240
+ description:
241
+ "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.",
242
+ },
243
+ {
244
+ key: "rt.trustedBrowserOrigins",
245
+ type: "array",
246
+ scopes: ["user", "machine"],
247
+ default: [],
248
+ merge: "replace",
249
+ 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.",
250
+ },
208
251
 
209
252
  // --- mattstack (installer-lane) -----------------------------------------
210
253
  {
@@ -487,8 +487,29 @@ function expandCtxFrom(opts: ResolveOpts): ExpandCtx {
487
487
  };
488
488
  }
489
489
 
490
+ let warnSink: ((msg: string) => void) | null = null;
491
+ const warnedOnce = new Set<string>();
492
+
493
+ /** The daemon binds a deduped log.warn here so a hot-path getSetting on a
494
+ * disallowed-scope key warns once, not every tick. Default: console.warn
495
+ * (CLI/test behavior unchanged). null restores the default. */
496
+ export function setSettingsWarnSink(sink: ((msg: string) => void) | null): void {
497
+ warnSink = sink;
498
+ warnedOnce.clear();
499
+ }
500
+
501
+ export function emitSettingsWarning(msg: string): void {
502
+ if (warnSink) {
503
+ if (warnedOnce.has(msg)) return;
504
+ warnedOnce.add(msg);
505
+ warnSink(msg);
506
+ return;
507
+ }
508
+ console.warn(msg);
509
+ }
510
+
490
511
  function warnInvalid(key: string, entry: InvalidScope): void {
491
- console.warn(
512
+ emitSettingsWarning(
492
513
  `rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`,
493
514
  );
494
515
  }
@@ -543,7 +564,7 @@ export function listSettings(opts: ResolveOpts = {}): ListedSetting[] {
543
564
  listed.value = expandVariables(resolution.value, ctx);
544
565
  } catch (err) {
545
566
  listed.expandError = (err as Error).message;
546
- console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
567
+ emitSettingsWarning(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
547
568
  }
548
569
  }
549
570
 
@@ -583,7 +604,7 @@ function listUnregistered(stores: StoreBundle, opts: ResolveOpts): ListedSetting
583
604
  return [...found.entries()]
584
605
  .sort(([a], [b]) => a.localeCompare(b))
585
606
  .map(([key, hit]) => {
586
- console.warn(
607
+ emitSettingsWarning(
587
608
  `rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`,
588
609
  );
589
610
  return {
@@ -0,0 +1,94 @@
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
+
8
+ export type Placement =
9
+ | { kind: "split"; direction: "right" | "down" }
10
+ | { kind: "tab" };
11
+
12
+ export interface PlacementOpts {
13
+ minCols?: number;
14
+ minRows?: number;
15
+ }
16
+
17
+ export interface HerdrCall {
18
+ (method: string, params: Record<string, unknown>): Promise<
19
+ { ok: true; result: any } | { ok: false; code: string; message: string }
20
+ >;
21
+ }
22
+
23
+ // A child below these is not worth splitting into; spill to a new tab instead.
24
+ const DEFAULT_MIN_COLS = 50;
25
+ const DEFAULT_MIN_ROWS = 14;
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 function decidePlacement(
34
+ rect: { width: number; height: number } | null,
35
+ opts: PlacementOpts = {},
36
+ ): Placement {
37
+ const minCols = opts.minCols ?? DEFAULT_MIN_COLS;
38
+ const minRows = opts.minRows ?? DEFAULT_MIN_ROWS;
39
+ if (!rect || rect.width <= 0 || rect.height <= 0) return { kind: "split", direction: "right" };
40
+ const canRight = rect.width >= 2 * minCols;
41
+ const canDown = rect.height >= 2 * minRows;
42
+ if (!canRight && !canDown) return { kind: "tab" };
43
+ if (canRight && canDown) {
44
+ return rect.width > 2 * rect.height
45
+ ? { kind: "split", direction: "right" }
46
+ : { kind: "split", direction: "down" };
47
+ }
48
+ return canRight ? { kind: "split", direction: "right" } : { kind: "split", direction: "down" };
49
+ }
50
+
51
+ /** The anchor pane's rect from a herdr `pane.layout`, or null if unavailable. */
52
+ async function anchorRect(
53
+ herdr: HerdrCall,
54
+ anchorPaneId: string,
55
+ ): Promise<{ width: number; height: number } | null> {
56
+ const r = await herdr("pane.layout", { pane_id: anchorPaneId });
57
+ if (!r.ok) return null;
58
+ const pane = (r.result?.layout?.panes ?? []).find((p: any) => p.pane_id === anchorPaneId);
59
+ return pane?.rect ? { width: pane.rect.width, height: pane.rect.height } : null;
60
+ }
61
+
62
+ /**
63
+ * Open a herdr pane placed intelligently next to `anchorPaneId` (split right or
64
+ * down, or a new tab when crowded). Optionally run `command` in it. Returns the
65
+ * new pane id and the placement chosen. Throws on a herdr failure.
66
+ */
67
+ export async function openSmartPane(
68
+ herdr: HerdrCall,
69
+ anchorPaneId: string,
70
+ opts: { command?: string; focus?: boolean } & PlacementOpts = {},
71
+ ): Promise<{ paneId: string; placement: Placement }> {
72
+ const focus = opts.focus ?? true;
73
+ const placement = decidePlacement(await anchorRect(herdr, anchorPaneId), opts);
74
+
75
+ let paneId: string | undefined;
76
+ if (placement.kind === "split") {
77
+ const s = await herdr("pane.split", { pane_id: anchorPaneId, direction: placement.direction, focus });
78
+ if (!s.ok) throw new Error(`pane.split failed: ${s.message}`);
79
+ paneId = s.result?.pane?.pane_id;
80
+ if (!paneId) throw new Error("pane.split returned no pane_id");
81
+ } else {
82
+ const workspaceId = anchorPaneId.split(":")[0];
83
+ const t = await herdr("tab.create", { workspace_id: workspaceId, focus });
84
+ if (!t.ok) throw new Error(`tab.create failed: ${t.message}`);
85
+ paneId = t.result?.root_pane?.pane_id;
86
+ if (!paneId) throw new Error("tab.create returned no pane_id");
87
+ }
88
+
89
+ if (opts.command) {
90
+ await herdr("pane.send_text", { pane_id: paneId, text: opts.command });
91
+ await herdr("pane.send_keys", { pane_id: paneId, keys: ["enter"] });
92
+ }
93
+ return { paneId, placement };
94
+ }
package/src/transport.ts CHANGED
@@ -14,6 +14,10 @@ export interface RtResponse<T = unknown> {
14
14
  ok: boolean;
15
15
  data?: T;
16
16
  error?: string;
17
+ /** Structured form of `error` on a handler throw (R035): `code` defaults
18
+ * to "handler-threw" when the thrown error carries none. Additive; older
19
+ * daemons and the reject path never set this. */
20
+ failure?: { code: string; message: string };
17
21
  }
18
22
 
19
23
  export interface RtClientOptions {
@@ -57,7 +61,7 @@ export async function rtCommand<T = unknown>(
57
61
  const res = await fetch(`http://localhost/${cmd}`, {
58
62
  unix: sockPath,
59
63
  method: "POST",
60
- headers: { "Content-Type": "application/json" },
64
+ headers: { "Content-Type": "application/json", "X-RT-Client": `rt-client/${process.pid}` },
61
65
  body: JSON.stringify(payload),
62
66
  signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
63
67
  // Bun's `unix` fetch option isn't in the standard RequestInit type.