@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.
- package/dist/commands.d.ts +24 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +114 -30
- package/dist/test-isolation.d.ts +7 -0
- package/package.json +1 -1
- package/src/client.ts +2 -1
- package/src/commands.ts +21 -16
- package/src/index.ts +1 -0
- package/src/settings/registry-defs.ts +55 -10
- package/src/test-isolation.ts +36 -0
- package/src/transport.ts +20 -0
package/dist/commands.d.ts
CHANGED
|
@@ -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:
|
|
175
|
-
|
|
176
|
-
later `markConsumed` call recorded that it acted on a push
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
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 ||
|
|
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
|
|
695
|
-
import { dirname, join as
|
|
734
|
+
import { homedir as homedir3 } from "os";
|
|
735
|
+
import { dirname, join as join3 } from "path";
|
|
696
736
|
function defaultReposJsonPath() {
|
|
697
|
-
return
|
|
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 =
|
|
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
|
|
830
|
-
import { join as
|
|
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
|
|
835
|
-
import { join as
|
|
874
|
+
import { homedir as homedir4, hostname } from "os";
|
|
875
|
+
import { join as join4 } from "path";
|
|
836
876
|
function home() {
|
|
837
|
-
return process.env.HOME ??
|
|
877
|
+
return process.env.HOME ?? homedir4();
|
|
838
878
|
}
|
|
839
879
|
function userSettingsPath() {
|
|
840
|
-
return
|
|
880
|
+
return join4(home(), ".mattstack", "user", "settings.user.jsonc");
|
|
841
881
|
}
|
|
842
882
|
function teamSettingsPath(team) {
|
|
843
|
-
return
|
|
883
|
+
return join4(teamsDir(), team, "mattstack", "settings.team.jsonc");
|
|
844
884
|
}
|
|
845
885
|
function teamLocalPath(team) {
|
|
846
|
-
return
|
|
886
|
+
return join4(home(), ".mattstack", "rt", "teams", `${team}.json`);
|
|
847
887
|
}
|
|
848
888
|
function machineSettingsPath() {
|
|
849
|
-
return
|
|
889
|
+
return join4(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
|
|
850
890
|
}
|
|
851
891
|
function teamsDir() {
|
|
852
|
-
return
|
|
892
|
+
return join4(home(), ".mattstack", "teams");
|
|
853
893
|
}
|
|
854
894
|
function machineKey() {
|
|
855
|
-
const override =
|
|
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.
|
|
1524
|
+
key: "agent.provider",
|
|
1485
1525
|
type: "string",
|
|
1486
1526
|
scopes: ["user", "machine"],
|
|
1527
|
+
default: "claude",
|
|
1487
1528
|
merge: "replace",
|
|
1488
|
-
description:
|
|
1529
|
+
description: 'Which provider rt agent start uses when --provider is not given: "claude" or "codex".'
|
|
1489
1530
|
},
|
|
1490
1531
|
{
|
|
1491
|
-
key: "agent.
|
|
1532
|
+
key: "agent.claude.model",
|
|
1492
1533
|
type: "string",
|
|
1493
1534
|
scopes: ["user", "machine"],
|
|
1494
1535
|
merge: "replace",
|
|
1495
|
-
description: "Default --
|
|
1536
|
+
description: "Default --model for claude rt agent launches; unset omits the flag."
|
|
1496
1537
|
},
|
|
1497
1538
|
{
|
|
1498
|
-
key: "agent.
|
|
1539
|
+
key: "agent.claude.effort",
|
|
1499
1540
|
type: "string",
|
|
1500
1541
|
scopes: ["user", "machine"],
|
|
1501
1542
|
merge: "replace",
|
|
1502
|
-
description: "
|
|
1543
|
+
description: "Default --effort for claude rt agent launches; unset omits the flag."
|
|
1503
1544
|
},
|
|
1504
1545
|
{
|
|
1505
|
-
key: "agent.
|
|
1546
|
+
key: "agent.claude.account",
|
|
1506
1547
|
type: "string",
|
|
1507
1548
|
scopes: ["user", "machine"],
|
|
1508
1549
|
merge: "replace",
|
|
1509
|
-
description: "
|
|
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
|
|
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(
|
|
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 ${
|
|
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
|
|
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 ??
|
|
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
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:
|
|
138
|
-
|
|
139
|
-
later `markConsumed` call recorded that it acted on a push
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
670
|
-
//
|
|
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.
|
|
674
|
+
key: "agent.provider",
|
|
673
675
|
type: "string",
|
|
674
676
|
scopes: ["user", "machine"],
|
|
677
|
+
default: "claude",
|
|
675
678
|
merge: "replace",
|
|
676
|
-
description: "
|
|
679
|
+
description: "Which provider rt agent start uses when --provider is not given: \"claude\" or \"codex\".",
|
|
677
680
|
},
|
|
678
681
|
{
|
|
679
|
-
key: "agent.
|
|
682
|
+
key: "agent.claude.model",
|
|
680
683
|
type: "string",
|
|
681
684
|
scopes: ["user", "machine"],
|
|
682
685
|
merge: "replace",
|
|
683
|
-
description: "Default --
|
|
686
|
+
description: "Default --model for claude rt agent launches; unset omits the flag.",
|
|
684
687
|
},
|
|
685
688
|
{
|
|
686
|
-
key: "agent.
|
|
689
|
+
key: "agent.claude.effort",
|
|
687
690
|
type: "string",
|
|
688
691
|
scopes: ["user", "machine"],
|
|
689
692
|
merge: "replace",
|
|
690
|
-
description: "
|
|
693
|
+
description: "Default --effort for claude rt agent launches; unset omits the flag.",
|
|
691
694
|
},
|
|
692
695
|
{
|
|
693
|
-
key: "agent.
|
|
696
|
+
key: "agent.claude.account",
|
|
694
697
|
type: "string",
|
|
695
698
|
scopes: ["user", "machine"],
|
|
696
699
|
merge: "replace",
|
|
697
|
-
description: "
|
|
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,
|