@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/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  paneDirectories,
39
39
  chatInvite,
40
40
  paneSend,
41
+ paneFocus,
41
42
  } from "./client.ts";
42
43
 
43
44
  export { COMMAND_NAMES } from "./commands.ts";
@@ -75,6 +76,7 @@ export type {
75
76
  InviteResult,
76
77
  PaneDelivery,
77
78
  PaneSendResult,
79
+ PaneFocusResult,
78
80
  } from "./commands.ts";
79
81
 
80
82
  export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
@@ -84,9 +86,12 @@ export { daemonHealth } from "./health.ts";
84
86
 
85
87
  export { repoNameForPath } from "./repos.ts";
86
88
 
89
+ export { decidePlacement, openSmartPane } from "./smart-pane.ts";
90
+ export type { Placement, PlacementOpts, HerdrCall } from "./smart-pane.ts";
91
+
87
92
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
88
93
 
89
- export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
94
+ export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER, setSettingsWarnSink } from "./settings/resolve.ts";
90
95
  export type {
91
96
  Scope,
92
97
  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.