@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/client.d.ts +3 -0
- package/dist/commands.d.ts +523 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +191 -16
- package/dist/settings/exec.d.ts +2 -0
- package/dist/settings/resolve.d.ts +5 -0
- package/dist/smart-pane.d.ts +48 -0
- package/dist/transport.d.ts +7 -0
- package/package.json +4 -1
- package/src/client.ts +9 -0
- package/src/commands.ts +219 -1
- package/src/index.ts +6 -1
- package/src/settings/exec.ts +47 -13
- package/src/settings/registry-defs.ts +43 -0
- package/src/settings/resolve.ts +24 -3
- package/src/smart-pane.ts +94 -0
- package/src/transport.ts +5 -1
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,
|
package/src/settings/exec.ts
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
{
|
package/src/settings/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|