@mattstack/rt-client 0.24.0 → 0.26.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.
@@ -171,14 +171,13 @@ export interface GateRow {
171
171
  at: number;
172
172
  } | null;
173
173
  released: boolean;
174
- /** Set once the nudged pane has provably read the answer: either it
175
- self-answered (stamped in the same transaction as the answer) or a
176
- later `markConsumed` call recorded that it acted on a push. `null`
177
- until then, so a sweep can tell an answered-but-unread row from a
178
- settled one. Currently only stamped for herd-subject gates (the
179
- self-answer path and `rt herd answer`'s nudged-session read); a
180
- non-herd gate with a nudge stays `null` even after its pane
181
- reconciles. */
174
+ /** Set once the nudged pane has provably read the answer: it self-answered
175
+ or lost the CAS to one (both stamped in the answer's own transaction),
176
+ or a later `markConsumed` call recorded that it acted on a push
177
+ (`rt herd answer` and `rt gate wait`, each on the nudged session's own
178
+ read). `null` until then, so a sweep can tell an answered-but-unread row
179
+ from a settled one. Every nudge-bearing subject has a stamping read;
180
+ a gate with no nudge never carries one, having no pane to read it. */
182
181
  consumedAt: number | null;
183
182
  owner: string | null;
184
183
  escalatedAt: number | null;
@@ -228,19 +227,20 @@ export interface HerdJobInfo {
228
227
  agentSession: string | null;
229
228
  agentId: string | null;
230
229
  handle: string;
231
- status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed";
230
+ status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed" | "stuck-at-modal";
232
231
  disposable: boolean;
233
232
  lastGate: string | null;
234
233
  lastReport: number | null;
235
234
  createdAt: number;
236
235
  updatedAt: number;
237
236
  }
238
- /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
237
+ /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: a TERMINAL gate (answered or closed) whose delivery is `dead-pane` is the "worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
239
238
  export interface HerdStatusData {
240
239
  herd: HerdInfo;
241
240
  jobs: Array<HerdJobInfo & {
242
241
  openGate: string | null;
243
- paneStatus: string | null;
242
+ paneStatus: string | null; /** The worker session behind this job's pane is gone while the job still reads in-flight: herdr lists the pane but no claude is on it. Null when herdr does not list the pane at all (it closed, or herdr is unreachable), which proves nothing either way. */
243
+ sessionDead: boolean | null;
244
244
  lastGateStatus: GateStatus | null;
245
245
  lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null;
246
246
  lastGateConsumed: boolean | null;
@@ -507,6 +507,7 @@ export interface AgentRecord {
507
507
  extraArgs?: string;
508
508
  exitCode?: number;
509
509
  resultPath?: string;
510
+ yolo?: boolean;
510
511
  createdAt: number;
511
512
  lastResumedAt?: number;
512
513
  finishedAt?: number;
@@ -1141,6 +1142,7 @@ export interface Commands {
1141
1142
  cwd: string;
1142
1143
  prompt?: string;
1143
1144
  surface?: AgentSurface;
1145
+ provider?: string;
1144
1146
  model?: string;
1145
1147
  effort?: string;
1146
1148
  account?: string;
@@ -1154,6 +1156,7 @@ export interface Commands {
1154
1156
  handle?: string;
1155
1157
  bg?: boolean;
1156
1158
  subject?: string;
1159
+ yolo?: boolean;
1157
1160
  };
1158
1161
  data: AgentRecord;
1159
1162
  };
@@ -1503,11 +1506,15 @@ export interface Commands {
1503
1506
  worktree?: string;
1504
1507
  };
1505
1508
  };
1509
+ /** `contextOmitted` appears only when the caller's context exceeded the
1510
+ 8192-byte cap and was dropped: the gate still opened, but with none of
1511
+ the material the reader needs. */
1506
1512
  data: {
1507
1513
  id: string;
1508
1514
  presentation: "form" | "wait";
1509
1515
  subject: string;
1510
1516
  supersededId: string | null;
1517
+ contextOmitted?: true;
1511
1518
  };
1512
1519
  };
1513
1520
  /**
@@ -1538,10 +1545,14 @@ export interface Commands {
1538
1545
  };
1539
1546
  /** `ok:false "not-found"` on an unknown id is terminal; the CLI loop must not re-enter on it.
1540
1547
  * `timeout` carries no row (nothing settled); `answered`/`closed` always carry the settled row. */
1548
+ /** `sessionId` is the caller's own session: an answered result returned to
1549
+ * the gate's nudged session is a recorded read and stamps `consumedAt`.
1550
+ * Omitting it reads without consuming. */
1541
1551
  "gate:wait": {
1542
1552
  payload: {
1543
1553
  id: string;
1544
1554
  waitMs?: number;
1555
+ sessionId?: string;
1545
1556
  };
1546
1557
  data: {
1547
1558
  status: "timeout";
@@ -1689,7 +1700,8 @@ export interface Commands {
1689
1700
  wasOnDeck: boolean | null;
1690
1701
  agentId: string;
1691
1702
  sessionId: string;
1692
- handle: string;
1703
+ handle: string; /** What the folder-trust check established: no modal was up, one was accepted and verified gone, one is still up (the job reads `stuck-at-modal`), or herdr could not be read. */
1704
+ trust: "none" | "accepted" | "stuck" | "unchecked";
1693
1705
  };
1694
1706
  };
1695
1707
  "herd:gates": {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
+ export { guardTestDaemonEnv } from "./test-isolation.ts";
3
4
  export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAsk, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
4
5
  export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
5
6
  export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
package/dist/index.js CHANGED
@@ -2,14 +2,52 @@ import { createRequire } from "node:module";
2
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
3
 
4
4
  // src/transport.ts
5
+ import { homedir as homedir2 } from "os";
6
+ import { join as join2 } from "path";
7
+
8
+ // src/test-isolation.ts
5
9
  import { homedir } from "os";
6
10
  import { join } from "path";
11
+ function parseForbidSocks(raw) {
12
+ const parsed = JSON.parse(raw);
13
+ if (!Array.isArray(parsed) || parsed.some((path) => typeof path !== "string")) {
14
+ throw new Error("rt-client: RT_TEST_FORBID_SOCKS must be a JSON string array of socket paths");
15
+ }
16
+ return parsed;
17
+ }
18
+ function guardTestDaemonEnv(env = process.env) {
19
+ const forbidden = new Set;
20
+ if (env.RT_TEST_FORBID_SOCKS) {
21
+ for (const path of parseForbidSocks(env.RT_TEST_FORBID_SOCKS))
22
+ forbidden.add(path);
23
+ }
24
+ if (env.RT_DAEMON_SOCK)
25
+ forbidden.add(env.RT_DAEMON_SOCK);
26
+ if (env.RT_APP_SOCKET)
27
+ forbidden.add(env.RT_APP_SOCKET);
28
+ forbidden.add(join(env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock"));
29
+ delete env.RT_DAEMON_SOCK;
30
+ delete env.RT_APP_SOCKET;
31
+ env.RT_TEST_FORBID_SOCKS = JSON.stringify([...forbidden]);
32
+ }
33
+
34
+ // src/transport.ts
7
35
  function defaultSock() {
8
- return process.env.RT_DAEMON_SOCK || join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
36
+ return process.env.RT_DAEMON_SOCK || join2(process.env.HOME ?? homedir2(), ".mattstack", "rt", "rt.sock");
9
37
  }
10
38
  var DEFAULT_SOCK = defaultSock();
39
+ function assertSockNotForbidden(cmd, sockPath) {
40
+ const raw = process.env.RT_TEST_FORBID_SOCKS;
41
+ if (!raw)
42
+ return;
43
+ const forbidden = parseForbidSocks(raw);
44
+ if (forbidden.includes(sockPath)) {
45
+ throw new Error(`rt-client: refusing "${cmd}" at forbidden socket ${sockPath}: RT_TEST_FORBID_SOCKS marks it as a live daemon socket, so this dispatch would have escaped test isolation`);
46
+ }
47
+ }
11
48
  async function rtCommand(cmd, payload, opts = {}) {
12
49
  const sockPath = opts.sockPath ?? defaultSock();
50
+ assertSockNotForbidden(cmd, sockPath);
13
51
  try {
14
52
  const res = await fetch(`http://localhost/${cmd}`, {
15
53
  unix: sockPath,
@@ -205,7 +243,7 @@ function eventsList(payload, o = {}) {
205
243
  }
206
244
  function agentStart(a, o = {}) {
207
245
  const payload = { repo: a.repo, cwd: a.cwd };
208
- for (const k of ["prompt", "surface", "model", "effort", "account", "label", "caller", "workspace", "tab", "extraArgs", "env", "herdrSocket", "handle", "bg", "subject"]) {
246
+ for (const k of ["prompt", "surface", "provider", "model", "effort", "account", "label", "caller", "workspace", "tab", "extraArgs", "env", "herdrSocket", "handle", "bg", "subject", "yolo"]) {
209
247
  if (a[k] !== undefined)
210
248
  payload[k] = a[k];
211
249
  }
@@ -306,6 +344,8 @@ function gateWait(a, o = {}) {
306
344
  const payload = { id: a.id };
307
345
  if (a.waitMs !== undefined)
308
346
  payload.waitMs = a.waitMs;
347
+ if (a.sessionId !== undefined)
348
+ payload.sessionId = a.sessionId;
309
349
  return rtCommand("gate:wait", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 250000 });
310
350
  }
311
351
  function gateList(a, o = {}) {
@@ -691,10 +731,10 @@ async function daemonHealth(opts = {}) {
691
731
  }
692
732
  // src/repos.ts
693
733
  import { existsSync, readFileSync } from "fs";
694
- import { homedir as homedir2 } from "os";
695
- import { dirname, join as join2 } from "path";
734
+ import { homedir as homedir3 } from "os";
735
+ import { dirname, join as join3 } from "path";
696
736
  function defaultReposJsonPath() {
697
- return join2(homedir2(), ".mattstack", "rt", "repos.json");
737
+ return join3(homedir3(), ".mattstack", "rt", "repos.json");
698
738
  }
699
739
  function loadBunSqliteDatabase() {
700
740
  try {
@@ -746,7 +786,7 @@ function repoNameFromJson(repoPath, reposJsonPath) {
746
786
  }
747
787
  function repoNameForPath(repoPath, reposJsonPath) {
748
788
  const jsonPath = reposJsonPath ?? defaultReposJsonPath();
749
- const dbPath = join2(dirname(jsonPath), "state.db");
789
+ const dbPath = join3(dirname(jsonPath), "state.db");
750
790
  const fromDb = repoNameFromStateDb(repoPath, dbPath);
751
791
  if (fromDb !== null)
752
792
  return fromDb;
@@ -826,33 +866,33 @@ function formatPaneRef(paneId, server) {
826
866
  return BG_PREFIX + paneId;
827
867
  }
828
868
  // src/settings/resolve.ts
829
- import { homedir as homedir4 } from "os";
830
- import { join as join5 } from "path";
869
+ import { homedir as homedir5 } from "os";
870
+ import { join as join6 } from "path";
831
871
 
832
872
  // src/settings/paths.ts
833
873
  import { readFileSync as readFileSync2 } from "fs";
834
- import { homedir as homedir3, hostname } from "os";
835
- import { join as join3 } from "path";
874
+ import { homedir as homedir4, hostname } from "os";
875
+ import { join as join4 } from "path";
836
876
  function home() {
837
- return process.env.HOME ?? homedir3();
877
+ return process.env.HOME ?? homedir4();
838
878
  }
839
879
  function userSettingsPath() {
840
- return join3(home(), ".mattstack", "user", "settings.user.jsonc");
880
+ return join4(home(), ".mattstack", "user", "settings.user.jsonc");
841
881
  }
842
882
  function teamSettingsPath(team) {
843
- return join3(teamsDir(), team, "mattstack", "settings.team.jsonc");
883
+ return join4(teamsDir(), team, "mattstack", "settings.team.jsonc");
844
884
  }
845
885
  function teamLocalPath(team) {
846
- return join3(home(), ".mattstack", "rt", "teams", `${team}.json`);
886
+ return join4(home(), ".mattstack", "rt", "teams", `${team}.json`);
847
887
  }
848
888
  function machineSettingsPath() {
849
- return join3(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
889
+ return join4(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
850
890
  }
851
891
  function teamsDir() {
852
- return join3(home(), ".mattstack", "teams");
892
+ return join4(home(), ".mattstack", "teams");
853
893
  }
854
894
  function machineKey() {
855
- const override = join3(home(), ".mattstack", "machine-key");
895
+ const override = join4(home(), ".mattstack", "machine-key");
856
896
  try {
857
897
  const v = readFileSync2(override, "utf8").trim();
858
898
  if (isSafeMachineKeySegment(v))
@@ -1481,32 +1521,75 @@ var REGISTRY = [
1481
1521
  description: "Destination (topic/URL/token) the configured chat.push.provider sends to."
1482
1522
  },
1483
1523
  {
1484
- key: "agent.model",
1524
+ key: "agent.provider",
1485
1525
  type: "string",
1486
1526
  scopes: ["user", "machine"],
1527
+ default: "claude",
1487
1528
  merge: "replace",
1488
- description: "Default --model for rt agent launches; unset omits the flag."
1529
+ description: 'Which provider rt agent start uses when --provider is not given: "claude" or "codex".'
1489
1530
  },
1490
1531
  {
1491
- key: "agent.effort",
1532
+ key: "agent.claude.model",
1492
1533
  type: "string",
1493
1534
  scopes: ["user", "machine"],
1494
1535
  merge: "replace",
1495
- description: "Default --effort for rt agent launches; unset omits the flag."
1536
+ description: "Default --model for claude rt agent launches; unset omits the flag."
1496
1537
  },
1497
1538
  {
1498
- key: "agent.account",
1539
+ key: "agent.claude.effort",
1499
1540
  type: "string",
1500
1541
  scopes: ["user", "machine"],
1501
1542
  merge: "replace",
1502
- description: "cswap account email rt agent launches under; unset uses the default claude profile."
1543
+ description: "Default --effort for claude rt agent launches; unset omits the flag."
1503
1544
  },
1504
1545
  {
1505
- key: "agent.extraArgs",
1546
+ key: "agent.claude.account",
1506
1547
  type: "string",
1507
1548
  scopes: ["user", "machine"],
1508
1549
  merge: "replace",
1509
- description: "Opaque extra claude arguments appended to every rt agent launch (escape hatch)."
1550
+ description: "cswap account email claude rt agent launches under; unset uses the default claude profile."
1551
+ },
1552
+ {
1553
+ key: "agent.claude.extraArgs",
1554
+ type: "string",
1555
+ scopes: ["user", "machine"],
1556
+ merge: "replace",
1557
+ description: "Opaque extra claude arguments appended to every claude rt agent launch (escape hatch)."
1558
+ },
1559
+ {
1560
+ key: "agent.claude.yolo",
1561
+ type: "boolean",
1562
+ scopes: ["user", "machine"],
1563
+ merge: "replace",
1564
+ description: "Default --yolo (--dangerously-skip-permissions) for claude rt agent launches; unset behaves as false."
1565
+ },
1566
+ {
1567
+ key: "agent.codex.model",
1568
+ type: "string",
1569
+ scopes: ["user", "machine"],
1570
+ merge: "replace",
1571
+ description: "Default -m/--model for codex rt agent launches; unset omits the flag."
1572
+ },
1573
+ {
1574
+ key: "agent.codex.effort",
1575
+ type: "string",
1576
+ scopes: ["user", "machine"],
1577
+ merge: "replace",
1578
+ description: "Default reasoning effort for codex rt agent launches, passed as -c model_reasoning_effort=<value>; unset omits the override."
1579
+ },
1580
+ {
1581
+ key: "agent.codex.extraArgs",
1582
+ type: "string",
1583
+ scopes: ["user", "machine"],
1584
+ merge: "replace",
1585
+ description: "Opaque extra codex arguments appended to every codex rt agent launch (escape hatch)."
1586
+ },
1587
+ {
1588
+ key: "agent.codex.yolo",
1589
+ type: "boolean",
1590
+ scopes: ["user", "machine"],
1591
+ merge: "replace",
1592
+ description: "Default --yolo (--dangerously-bypass-approvals-and-sandbox) for codex rt agent launches; unset behaves as false. codex has no per-agent account setting (see spec's Non-goals)."
1510
1593
  },
1511
1594
  {
1512
1595
  key: "rt.gates.escalationTtlMinutes",
@@ -1591,7 +1674,7 @@ function findPathGuardViolation(value, guardFields) {
1591
1674
  // src/settings/stores.ts
1592
1675
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
1593
1676
  import { parse } from "jsonc-parser";
1594
- import { join as join4 } from "path";
1677
+ import { join as join5 } from "path";
1595
1678
  var EMPTY_STORE = (file, exists) => ({
1596
1679
  global: {},
1597
1680
  repos: {},
@@ -1638,13 +1721,13 @@ function listTeams() {
1638
1721
  const teams = [];
1639
1722
  for (const entry of entries) {
1640
1723
  try {
1641
- const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join4(dir, entry.name)).isDirectory();
1724
+ const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join5(dir, entry.name)).isDirectory();
1642
1725
  if (!isDir)
1643
1726
  continue;
1644
1727
  if (existsSync2(teamSettingsPath(entry.name)))
1645
1728
  teams.push(entry.name);
1646
1729
  } catch (err) {
1647
- console.warn(`rt: skipping unreadable teams entry ${join4(dir, entry.name)}: ${err.message}`);
1730
+ console.warn(`rt: skipping unreadable teams entry ${join5(dir, entry.name)}: ${err.message}`);
1648
1731
  }
1649
1732
  }
1650
1733
  return teams;
@@ -1693,7 +1776,7 @@ function teamPath(teamsDir2, name) {
1693
1776
  if (name.includes("/") || name.includes("\\") || name.includes("..")) {
1694
1777
  throw new Error(`rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`);
1695
1778
  }
1696
- return join5(teamsDir2, name);
1779
+ return join6(teamsDir2, name);
1697
1780
  }
1698
1781
  function required(value, name, needs) {
1699
1782
  if (value === undefined || value === "") {
@@ -1868,7 +1951,7 @@ function expandCtxFrom(opts) {
1868
1951
  return {
1869
1952
  repoRoot: opts.expandCtx?.repoRoot,
1870
1953
  worktree: opts.expandCtx?.worktree,
1871
- home: process.env.HOME ?? homedir4(),
1954
+ home: process.env.HOME ?? homedir5(),
1872
1955
  teamsDir: teamsDir()
1873
1956
  };
1874
1957
  }
@@ -2409,6 +2492,7 @@ export {
2409
2492
  herdAttend,
2410
2493
  herdAsk,
2411
2494
  herdAnswer,
2495
+ guardTestDaemonEnv,
2412
2496
  getSetting,
2413
2497
  getRun,
2414
2498
  getDef,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Strict by design: a JSON string is iterable and has .includes(), so an
3
+ * unvalidated parse would let a malformed value corrupt the merge here or
4
+ * silently disarm the transport guard. Anything but a string array throws.
5
+ */
6
+ export declare function parseForbidSocks(raw: string): string[];
7
+ export declare function guardTestDaemonEnv(env?: NodeJS.ProcessEnv): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/client.ts CHANGED
@@ -356,7 +356,7 @@ export function agentStart(
356
356
  a: Commands["agent:start"]["payload"], o: RtClientOptions = {},
357
357
  ): Promise<RtResponse<AgentRecord>> {
358
358
  const payload: Record<string, unknown> = { repo: a.repo, cwd: a.cwd };
359
- for (const k of ["prompt", "surface", "model", "effort", "account", "label", "caller", "workspace", "tab", "extraArgs", "env", "herdrSocket", "handle", "bg", "subject"] as const) {
359
+ for (const k of ["prompt", "surface", "provider", "model", "effort", "account", "label", "caller", "workspace", "tab", "extraArgs", "env", "herdrSocket", "handle", "bg", "subject", "yolo"] as const) {
360
360
  if (a[k] !== undefined) payload[k] = a[k];
361
361
  }
362
362
  return rtCommand<AgentRecord>("agent:start", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 30_000 });
@@ -499,6 +499,7 @@ export function gateWait(
499
499
  ): Promise<RtResponse<Commands["gate:wait"]["data"]>> {
500
500
  const payload: Record<string, unknown> = { id: a.id };
501
501
  if (a.waitMs !== undefined) payload.waitMs = a.waitMs;
502
+ if (a.sessionId !== undefined) payload.sessionId = a.sessionId;
502
503
  return rtCommand<Commands["gate:wait"]["data"]>("gate:wait", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 250_000 });
503
504
  }
504
505
 
package/src/commands.ts CHANGED
@@ -134,14 +134,13 @@ export interface GateRow {
134
134
  nudge: { session: string } | null;
135
135
  delivery: { outcome: "delivered" | "dead-pane" | "confirmed" | "stuck"; at: number } | null;
136
136
  released: boolean;
137
- /** Set once the nudged pane has provably read the answer: either it
138
- self-answered (stamped in the same transaction as the answer) or a
139
- later `markConsumed` call recorded that it acted on a push. `null`
140
- until then, so a sweep can tell an answered-but-unread row from a
141
- settled one. Currently only stamped for herd-subject gates (the
142
- self-answer path and `rt herd answer`'s nudged-session read); a
143
- non-herd gate with a nudge stays `null` even after its pane
144
- reconciles. */
137
+ /** Set once the nudged pane has provably read the answer: it self-answered
138
+ or lost the CAS to one (both stamped in the answer's own transaction),
139
+ or a later `markConsumed` call recorded that it acted on a push
140
+ (`rt herd answer` and `rt gate wait`, each on the nudged session's own
141
+ read). `null` until then, so a sweep can tell an answered-but-unread row
142
+ from a settled one. Every nudge-bearing subject has a stamping read;
143
+ a gate with no nudge never carries one, having no pane to read it. */
145
144
  consumedAt: number | null;
146
145
  owner: string | null;
147
146
  escalatedAt: number | null;
@@ -166,11 +165,11 @@ export interface GateSubscription {
166
165
  export interface HerdInfo { id: string; repo: string; room: string; workspace: string; shepherdSession: string; shepherdHandle: string; herdrSocket: string | null; hidden: boolean; status: "active" | "wrapped"; createdAt: number; wrappedAt: number | null }
167
166
  /** A herd row as `herd:list` reports it: the registry row plus how many jobs hang off it. */
168
167
  export interface HerdListRow extends HerdInfo { jobs: number }
169
- export interface HerdJobInfo { herd: string; name: string; worktree: string; branch: string | null; tree: string | null; pane: string | null; agentSession: string | null; agentId: string | null; handle: string; status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed"; disposable: boolean; lastGate: string | null; lastReport: number | null; createdAt: number; updatedAt: number }
170
- /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
168
+ export interface HerdJobInfo { herd: string; name: string; worktree: string; branch: string | null; tree: string | null; pane: string | null; agentSession: string | null; agentId: string | null; handle: string; status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed" | "stuck-at-modal"; disposable: boolean; lastGate: string | null; lastReport: number | null; createdAt: number; updatedAt: number }
169
+ /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: a TERMINAL gate (answered or closed) whose delivery is `dead-pane` is the "worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
171
170
  export interface HerdStatusData {
172
171
  herd: HerdInfo;
173
- jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null; lastGateConsumed: boolean | null }>;
172
+ jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; /** The worker session behind this job's pane is gone while the job still reads in-flight: herdr lists the pane but no claude is on it. Null when herdr does not list the pane at all (it closed, or herdr is unreachable), which proves nothing either way. */ sessionDead: boolean | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null; lastGateConsumed: boolean | null }>;
174
173
  unread: number;
175
174
  lifecycleConnected: boolean;
176
175
  hiddenUp: boolean | null;
@@ -360,7 +359,7 @@ export interface AgentRecord {
360
359
  PreToolUse hook gets injected at all. */
361
360
  subject?: string;
362
361
  paneId?: string; tabId?: string; workspaceId?: string;
363
- extraArgs?: string; exitCode?: number; resultPath?: string;
362
+ extraArgs?: string; exitCode?: number; resultPath?: string; yolo?: boolean;
364
363
  createdAt: number; lastResumedAt?: number; finishedAt?: number;
365
364
  }
366
365
 
@@ -617,7 +616,7 @@ export interface Commands {
617
616
  "chat:dm-open": { payload: { from: string; to: string; sessionId?: string }; data: { room: string; created: boolean } };
618
617
 
619
618
  // ─── Agent handoff (rt agent) ────────────────────────────────────────────
620
- "agent:start": { payload: { repo: string; cwd: string; prompt?: string; surface?: AgentSurface; model?: string; effort?: string; account?: string; label?: string; caller?: string; workspace?: string; tab?: string; extraArgs?: string; env?: Record<string, string>; herdrSocket?: string; handle?: string; bg?: boolean; subject?: string }; data: AgentRecord };
619
+ "agent:start": { payload: { repo: string; cwd: string; prompt?: string; surface?: AgentSurface; provider?: string; model?: string; effort?: string; account?: string; label?: string; caller?: string; workspace?: string; tab?: string; extraArgs?: string; env?: Record<string, string>; herdrSocket?: string; handle?: string; bg?: boolean; subject?: string; yolo?: boolean }; data: AgentRecord };
621
620
  "agent:resume": { payload: { id: string; prompt?: string; surface?: AgentSurface; workspace?: string; tab?: string }; data: AgentRecord };
622
621
  "agent:get": { payload: { id: string }; data: AgentRecord };
623
622
  "agent:list": { payload: { repo?: string }; data: { agents: AgentRecord[] } };
@@ -712,7 +711,10 @@ export interface Commands {
712
711
  agent?: string;
713
712
  origin?: { surface?: string; tabId?: string; worktree?: string };
714
713
  };
715
- data: { id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null };
714
+ /** `contextOmitted` appears only when the caller's context exceeded the
715
+ 8192-byte cap and was dropped: the gate still opened, but with none of
716
+ the material the reader needs. */
717
+ data: { id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null; contextOmitted?: true };
716
718
  };
717
719
  /**
718
720
  * A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
@@ -730,7 +732,10 @@ export interface Commands {
730
732
  "gate:answer": { payload: { id: string; answers: GateAnswer["answers"]; by: string; session?: string; override?: boolean }; data: { row: GateRow; conflict?: true } };
731
733
  /** `ok:false "not-found"` on an unknown id is terminal; the CLI loop must not re-enter on it.
732
734
  * `timeout` carries no row (nothing settled); `answered`/`closed` always carry the settled row. */
733
- "gate:wait": { payload: { id: string; waitMs?: number }; data: { status: "timeout" } | { status: "answered" | "closed"; row: GateRow } };
735
+ /** `sessionId` is the caller's own session: an answered result returned to
736
+ * the gate's nudged session is a recorded read and stamps `consumedAt`.
737
+ * Omitting it reads without consuming. */
738
+ "gate:wait": { payload: { id: string; waitMs?: number; sessionId?: string }; data: { status: "timeout" } | { status: "answered" | "closed"; row: GateRow } };
734
739
  /** Paged like events:list: an omitted `limit` clamps daemon-side rather than
735
740
  * forcing a full-table read; `cursor` is the paging rowid to resume from. */
736
741
  "gate:list": { payload: { open?: boolean; subjectPrefix?: string; kind?: string; limit?: number; cursor?: number }; data: { gates: GateRow[]; cursor: number } };
@@ -750,7 +755,7 @@ export interface Commands {
750
755
  "herd:list": { payload: { all?: boolean }; data: { herds: HerdListRow[] } };
751
756
  "herd:close": { payload: { herd: string; job: string }; data: { job: string; status: "closed"; /** Advisory: a resumable run can still write into this job's worktree after close, so this warns rather than blocking. */ warning?: string } };
752
757
  /** `brief` is the brief TEXT, not a path: the CLI reads the file. It is stored at `<jobsRoot>/<herd>/<job>/job.md`, so a respawn with `dir` and no `brief` reads it back. */
753
- "herd:spawn": { payload: { herd: string; job: string; brief?: string; dir?: string; model?: string; effort?: string; account?: string; disposable?: boolean }; data: { herd: string; job: string; pane: string; worktree: string; branch: string | null; tree: string | null; /** null = no provisioning ran (--dir); false = cold create, worth announcing. */ wasOnDeck: boolean | null; agentId: string; sessionId: string; handle: string } };
758
+ "herd:spawn": { payload: { herd: string; job: string; brief?: string; dir?: string; model?: string; effort?: string; account?: string; disposable?: boolean }; data: { herd: string; job: string; pane: string; worktree: string; branch: string | null; tree: string | null; /** null = no provisioning ran (--dir); false = cold create, worth announcing. */ wasOnDeck: boolean | null; agentId: string; sessionId: string; handle: string; /** What the folder-trust check established: no modal was up, one was accepted and verified gone, one is still up (the job reads `stuck-at-modal`), or herdr could not be read. */ trust: "none" | "accepted" | "stuck" | "unchecked" } };
754
759
  "herd:gates": { payload: { herd: string }; data: { gates: GateRow[] } };
755
760
  "herd:ask": { payload: { herd: string; job: string; session: string; pane?: string; questions: GateQuestion[]; context?: string }; data: { gate: string } };
756
761
  "herd:milestone": { payload: { herd: string; job: string; session: string; pane?: string; artifact: string; summary?: string }; data: { gate: string; message: number } };
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
+ export { guardTestDaemonEnv } from "./test-isolation.ts";
3
4
 
4
5
  export {
5
6
  readProjectMRs,
@@ -666,35 +666,80 @@ export const REGISTRY: readonly SettingDef[] = [
666
666
  },
667
667
 
668
668
  // --- agent (rt agent handoff) --------------------------------------------
669
- // No defaults by design: an unset key means the flag is omitted from the
670
- // claude invocation entirely (spec "Settings").
669
+ // No defaults on any per-provider row, by design: an unset key means the
670
+ // flag is omitted from the launch entirely (spec "Settings"). agent.provider
671
+ // is the one exception -- it needs a concrete fallback to preserve
672
+ // claude-only behavior with zero code changes for callers who never set it.
671
673
  {
672
- key: "agent.model",
674
+ key: "agent.provider",
673
675
  type: "string",
674
676
  scopes: ["user", "machine"],
677
+ default: "claude",
675
678
  merge: "replace",
676
- description: "Default --model for rt agent launches; unset omits the flag.",
679
+ description: "Which provider rt agent start uses when --provider is not given: \"claude\" or \"codex\".",
677
680
  },
678
681
  {
679
- key: "agent.effort",
682
+ key: "agent.claude.model",
680
683
  type: "string",
681
684
  scopes: ["user", "machine"],
682
685
  merge: "replace",
683
- description: "Default --effort for rt agent launches; unset omits the flag.",
686
+ description: "Default --model for claude rt agent launches; unset omits the flag.",
684
687
  },
685
688
  {
686
- key: "agent.account",
689
+ key: "agent.claude.effort",
687
690
  type: "string",
688
691
  scopes: ["user", "machine"],
689
692
  merge: "replace",
690
- description: "cswap account email rt agent launches under; unset uses the default claude profile.",
693
+ description: "Default --effort for claude rt agent launches; unset omits the flag.",
691
694
  },
692
695
  {
693
- key: "agent.extraArgs",
696
+ key: "agent.claude.account",
694
697
  type: "string",
695
698
  scopes: ["user", "machine"],
696
699
  merge: "replace",
697
- description: "Opaque extra claude arguments appended to every rt agent launch (escape hatch).",
700
+ description: "cswap account email claude rt agent launches under; unset uses the default claude profile.",
701
+ },
702
+ {
703
+ key: "agent.claude.extraArgs",
704
+ type: "string",
705
+ scopes: ["user", "machine"],
706
+ merge: "replace",
707
+ description: "Opaque extra claude arguments appended to every claude rt agent launch (escape hatch).",
708
+ },
709
+ {
710
+ key: "agent.claude.yolo",
711
+ type: "boolean",
712
+ scopes: ["user", "machine"],
713
+ merge: "replace",
714
+ description: "Default --yolo (--dangerously-skip-permissions) for claude rt agent launches; unset behaves as false.",
715
+ },
716
+ {
717
+ key: "agent.codex.model",
718
+ type: "string",
719
+ scopes: ["user", "machine"],
720
+ merge: "replace",
721
+ description: "Default -m/--model for codex rt agent launches; unset omits the flag.",
722
+ },
723
+ {
724
+ key: "agent.codex.effort",
725
+ type: "string",
726
+ scopes: ["user", "machine"],
727
+ merge: "replace",
728
+ description: "Default reasoning effort for codex rt agent launches, passed as -c model_reasoning_effort=<value>; unset omits the override.",
729
+ },
730
+ {
731
+ key: "agent.codex.extraArgs",
732
+ type: "string",
733
+ scopes: ["user", "machine"],
734
+ merge: "replace",
735
+ description: "Opaque extra codex arguments appended to every codex rt agent launch (escape hatch).",
736
+ },
737
+ {
738
+ key: "agent.codex.yolo",
739
+ type: "boolean",
740
+ scopes: ["user", "machine"],
741
+ merge: "replace",
742
+ description: "Default --yolo (--dangerously-bypass-approvals-and-sandbox) for codex rt agent launches; unset behaves as false. codex has no per-agent account setting (see spec's Non-goals).",
698
743
  },
699
744
 
700
745
  // --- gates (escalation) ----------------------------------------------------
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Test-run isolation for suites that repoint HOME at a throwaway directory.
3
+ * An ambient RT_DAEMON_SOCK (herdr panes, agent shells) routes rtCommand at
4
+ * a live daemon over the top of the fake HOME, so a repointed HOME alone is
5
+ * not isolation. A test preload calls this BEFORE repointing HOME: it strips
6
+ * the live pointers from the env and arms RT_TEST_FORBID_SOCKS, the list of
7
+ * sockets rtCommand refuses to dispatch at (transport.ts).
8
+ */
9
+ import { homedir } from "os";
10
+ import { join } from "path";
11
+
12
+ /**
13
+ * Strict by design: a JSON string is iterable and has .includes(), so an
14
+ * unvalidated parse would let a malformed value corrupt the merge here or
15
+ * silently disarm the transport guard. Anything but a string array throws.
16
+ */
17
+ export function parseForbidSocks(raw: string): string[] {
18
+ const parsed: unknown = JSON.parse(raw);
19
+ if (!Array.isArray(parsed) || parsed.some((path) => typeof path !== "string")) {
20
+ throw new Error("rt-client: RT_TEST_FORBID_SOCKS must be a JSON string array of socket paths");
21
+ }
22
+ return parsed;
23
+ }
24
+
25
+ export function guardTestDaemonEnv(env: NodeJS.ProcessEnv = process.env): void {
26
+ const forbidden = new Set<string>();
27
+ if (env.RT_TEST_FORBID_SOCKS) {
28
+ for (const path of parseForbidSocks(env.RT_TEST_FORBID_SOCKS)) forbidden.add(path);
29
+ }
30
+ if (env.RT_DAEMON_SOCK) forbidden.add(env.RT_DAEMON_SOCK);
31
+ if (env.RT_APP_SOCKET) forbidden.add(env.RT_APP_SOCKET);
32
+ forbidden.add(join(env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock"));
33
+ delete env.RT_DAEMON_SOCK;
34
+ delete env.RT_APP_SOCKET;
35
+ env.RT_TEST_FORBID_SOCKS = JSON.stringify([...forbidden]);
36
+ }
package/src/transport.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { homedir } from "os";
11
11
  import { join } from "path";
12
+ import { parseForbidSocks } from "./test-isolation.ts";
12
13
 
13
14
  export interface RtResponse<T = unknown> {
14
15
  ok: boolean;
@@ -56,12 +57,31 @@ function defaultSock(): string {
56
57
  */
57
58
  export const DEFAULT_SOCK = defaultSock();
58
59
 
60
+ /**
61
+ * The one exception to the never-throws contract below: when a test preload
62
+ * has armed RT_TEST_FORBID_SOCKS (see test-isolation.ts), dispatching at a
63
+ * listed socket throws instead of degrading. A degrade envelope could be
64
+ * tolerated by the calling test; escaping isolation must fail the run loudly.
65
+ * A malformed list throws too rather than silently disarming the guard.
66
+ */
67
+ function assertSockNotForbidden(cmd: string, sockPath: string): void {
68
+ const raw = process.env.RT_TEST_FORBID_SOCKS;
69
+ if (!raw) return;
70
+ const forbidden = parseForbidSocks(raw);
71
+ if (forbidden.includes(sockPath)) {
72
+ throw new Error(
73
+ `rt-client: refusing "${cmd}" at forbidden socket ${sockPath}: RT_TEST_FORBID_SOCKS marks it as a live daemon socket, so this dispatch would have escaped test isolation`,
74
+ );
75
+ }
76
+ }
77
+
59
78
  export async function rtCommand<T = unknown>(
60
79
  cmd: string,
61
80
  payload: Record<string, unknown>,
62
81
  opts: { sockPath?: string; timeoutMs?: number } = {},
63
82
  ): Promise<RtResponse<T>> {
64
83
  const sockPath = opts.sockPath ?? defaultSock();
84
+ assertSockNotForbidden(cmd, sockPath);
65
85
  try {
66
86
  const res = await fetch(`http://localhost/${cmd}`, {
67
87
  unix: sockPath,