@mattstack/rt-client 0.15.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.ts CHANGED
@@ -95,11 +95,27 @@ export interface EventsBusEvent { id: number; topic: string; payload: unknown; e
95
95
  export const GATE_BY_PANE = "pane";
96
96
 
97
97
  export type GateStatus = "open" | "answered" | "parked" | "closed";
98
- export interface GateQuestion { id: string; label: string; multi: boolean; options: string[] }
98
+ export type GateOption = string | { value: string; label: string };
99
+ export interface GateOrigin {
100
+ paneId?: string;
101
+ tabId?: string;
102
+ runId?: string;
103
+ worktree?: string;
104
+ presentation?: "form" | "wait";
105
+ }
106
+ export interface GateQuestion { id: string; label: string; multi: boolean; options: GateOption[] }
107
+ export function gateOptionValue(o: GateOption): string {
108
+ return typeof o === "string" ? o : o.value;
109
+ }
110
+ export function gateOptionLabel(o: GateOption): string {
111
+ return typeof o === "string" ? o : (o.label || o.value);
112
+ }
99
113
  export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number }
100
114
  export interface GateRow {
101
115
  id: string; subject: string; kind: string;
102
116
  questions: GateQuestion[]; meta: Record<string, unknown> | null;
117
+ context?: string | null;
118
+ origin?: GateOrigin | null;
103
119
  status: GateStatus; answer: GateAnswer | null;
104
120
  openedAt: number; parkedAt: number | null; closedAt: number | null;
105
121
  closedReason: "abandoned" | "superseded" | "pruned" | null;
@@ -118,6 +134,21 @@ export interface GateSubscription {
118
134
  dead: boolean;
119
135
  }
120
136
 
137
+ 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 }
138
+ /** A herd row as `herd:list` reports it: the registry row plus how many jobs hang off it. */
139
+ export interface HerdListRow extends HerdInfo { jobs: number }
140
+ 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 }
141
+ /** `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. */
142
+ export interface HerdStatusData {
143
+ herd: HerdInfo;
144
+ jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | null }>;
145
+ unread: number;
146
+ lifecycleConnected: boolean;
147
+ hiddenUp: boolean | null;
148
+ /** The shepherd session's own `herd:<id>/` subscription row, or null when none is live. */
149
+ subscription: { id: string; dead: boolean; lastDelivery: GateSubscription["lastDelivery"] } | null;
150
+ }
151
+
121
152
  /**
122
153
  * Duplicated shape on purpose, same reasoning as EventsBusEvent above:
123
154
  * these mirror lib/state/chat-store.ts's types, which rt-client cannot
@@ -213,7 +244,10 @@ export interface InviteResult { paneId: string; delivered: "accepted" | "queued"
213
244
  /** Duplicated shape on purpose: mirrors lib/daemon/inject.ts's InjectResult. */
214
245
  export type PaneDelivery = "accepted" | "queued" | "refused";
215
246
  export interface PaneSendResult { paneId: string; delivered: PaneDelivery; reason?: string }
216
- export interface PaneFocusResult { paneId: string; focused: boolean }
247
+ /** `attendTab` is set only for a `bg:` ref: focus for a background pane IS
248
+ the attend flow (a visible tab running a terminal attach), and this is
249
+ that tab's id. */
250
+ export interface PaneFocusResult { paneId: string; focused: boolean; attendTab?: string }
217
251
 
218
252
  // SKILLS-53: one judgment, computed once in rt, so the console and the tray
219
253
  // never derive two verdicts that can disagree.
@@ -524,7 +558,7 @@ export interface Commands {
524
558
  "chat:dm-open": { payload: { from: string; to: string; sessionId?: string }; data: { room: string; created: boolean } };
525
559
 
526
560
  // ─── Agent handoff (rt agent) ────────────────────────────────────────────
527
- "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 }; data: AgentRecord };
561
+ "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 }; data: AgentRecord };
528
562
  "agent:resume": { payload: { id: string; prompt?: string; surface?: AgentSurface; workspace?: string; tab?: string }; data: AgentRecord };
529
563
  "agent:get": { payload: { id: string }; data: AgentRecord };
530
564
  "agent:list": { payload: { repo?: string }; data: { agents: AgentRecord[] } };
@@ -538,7 +572,9 @@ export interface Commands {
538
572
  data: { pane: ChatPane; ready: boolean };
539
573
  };
540
574
  "pane:send": { payload: { paneId: string; text: string; callerPane?: string }; data: PaneSendResult };
541
- "pane:focus": { payload: { paneId: string }; data: PaneFocusResult };
575
+ /** `callerWorkspace` (HERDR_WORKSPACE_ID) is required only for a `bg:`
576
+ ref, whose focus opens an attend tab in the caller's own workspace. */
577
+ "pane:focus": { payload: { paneId: string; callerWorkspace?: string }; data: PaneFocusResult };
542
578
 
543
579
  // ─── R013/R016 ────────────────────────────────────────────────
544
580
  "cache:read": { payload: { branches?: string[]; maxAgeMs?: number; repoIdentity?: string }; data: Record<string, BranchEnrichment> };
@@ -574,7 +610,7 @@ export interface Commands {
574
610
  "freshness:reconcile": { payload: Record<string, never>; data: unknown };
575
611
 
576
612
  // ─── Gate facility (BOARD-20/21) ─────────────────────────────────────────
577
- "gate:open": { payload: { subject: string; kind: string; questions: GateQuestion[]; meta?: Record<string, unknown>; agent?: string; pane?: string; nudge?: { session: string } }; data: { id: string; supersededId: string | null } };
613
+ "gate:open": { payload: { subject: string; kind: string; questions: GateQuestion[]; meta?: Record<string, unknown>; agent?: string; pane?: string; nudge?: { session: string }; context?: string; origin?: GateOrigin }; data: { id: string; supersededId: string | null } };
578
614
  /**
579
615
  * A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
580
616
  * `conflict:true` and the WINNING row, so every consumer gets the winner
@@ -596,6 +632,25 @@ export interface Commands {
596
632
  * onto delivery outcomes (dead marks included). */
597
633
  "gate:subscriptions": { payload: { session?: string; live?: boolean }; data: { subscriptions: GateSubscription[] } };
598
634
 
635
+ // ─── Herd (shepherd run registry) ────────────────────────────────────────
636
+ "herd:start": { payload: { name: string; repo: string; session: string; hidden?: boolean }; data: { herd: string; room: string; workspace: string; subscription: string; handle: string; hidden: boolean } };
637
+ "herd:resume": { payload: { herd: string; session: string }; data: { subscription: string; gates: GateRow[]; unread: number; status: HerdStatusData; handle: string } };
638
+ "herd:status": { payload: { herd: string }; data: HerdStatusData };
639
+ /** Active herds only unless `all`, so a shepherd's "which herd am I on" question has one answer. */
640
+ "herd:list": { payload: { all?: boolean }; data: { herds: HerdListRow[] } };
641
+ "herd:close": { payload: { herd: string; job: string }; data: { job: string; status: "closed" } };
642
+ /** `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. */
643
+ "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 } };
644
+ "herd:gates": { payload: { herd: string }; data: { gates: GateRow[] } };
645
+ "herd:ask": { payload: { herd: string; job: string; session: string; pane?: string; questions: GateQuestion[]; context?: string }; data: { gate: string } };
646
+ "herd:milestone": { payload: { herd: string; job: string; session: string; pane?: string; artifact: string; summary?: string }; data: { gate: string; message: number } };
647
+ "herd:answer": { payload: { gate: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
648
+ "herd:report": { payload: { herd: string; job: string; body: string }; data: { message: number } };
649
+ /** `callerWorkspace` is the attending session's own HERDR_WORKSPACE_ID: the attached tab opens there, not in the herd's workspace. */
650
+ "herd:attend": { payload: { herd: string; job: string; callerWorkspace: string }; data: { tab: string; pane: string } };
651
+ "herd:stop-hidden": { payload: Record<string, never>; data: { stopped: boolean } };
652
+ "herd:wrap-up": { payload: { herd: string; closePanes?: boolean; dispose?: string[]; deleteJobDirs?: boolean; archiveRoom?: boolean }; data: { closed: string[]; workspaceClosed: boolean; disposed: string[]; refused: Array<{ tree: string; reason: string }>; deletedJobDirs: boolean; archived: boolean } };
653
+
599
654
  /** Wire reply on success is always `{ok:true, repaired}` (no `data`
600
655
  * wrapper) — `data` here documents the extra field the same way PingData
601
656
  * does for `ping`, not the literal wire nesting (R3). */
@@ -616,6 +671,13 @@ export interface Commands {
616
671
  "worktree:restore": { payload: { repoName: string; tree: string }; data: WorktreeRestoreData };
617
672
  "worktree:freshen": { payload: { repoName?: string; tree?: string }; data: WorktreeFreshenData };
618
673
  "worktree:adopt": { payload: { repoName: string; claim?: boolean }; data: WorktreeAdoptData };
674
+
675
+ // ─── Background server (daemon-owned background herdr session) ──────────
676
+ "bg:ensure": { payload: { claim?: string }; data: { socket: string; started: boolean; parity: { ok: boolean; drift: string[] } | null } };
677
+ "bg:status": { payload: Record<string, never>; data: { up: boolean; socket: string; claims: Array<{ owner: string; pane: string | null; createdAt: number }> } };
678
+ /** Rejects (`ok:false`) naming every live claim owner while any claim is held. */
679
+ "bg:stop": { payload: Record<string, never>; data: { stopped: boolean } };
680
+ "bg:release": { payload: { claim: string }; data: { released: boolean } };
619
681
  }
620
682
 
621
683
  export type CommandName = keyof Commands;
@@ -699,6 +761,20 @@ export const COMMAND_NAMES: readonly CommandName[] = [
699
761
  "gate:subscribe",
700
762
  "gate:unsubscribe",
701
763
  "gate:subscriptions",
764
+ "herd:start",
765
+ "herd:resume",
766
+ "herd:status",
767
+ "herd:list",
768
+ "herd:close",
769
+ "herd:spawn",
770
+ "herd:gates",
771
+ "herd:ask",
772
+ "herd:milestone",
773
+ "herd:answer",
774
+ "herd:report",
775
+ "herd:attend",
776
+ "herd:stop-hidden",
777
+ "herd:wrap-up",
702
778
  "hooks:repair",
703
779
  "hooks:watch",
704
780
  "sdm:catalog",
@@ -713,4 +789,9 @@ export const COMMAND_NAMES: readonly CommandName[] = [
713
789
  "worktree:restore",
714
790
  "worktree:freshen",
715
791
  "worktree:adopt",
792
+
793
+ "bg:ensure",
794
+ "bg:status",
795
+ "bg:stop",
796
+ "bg:release",
716
797
  ];
package/src/index.ts CHANGED
@@ -54,9 +54,27 @@ export {
54
54
  gateSubscribe,
55
55
  gateUnsubscribe,
56
56
  gateSubscriptions,
57
+ herdStart,
58
+ herdSpawn,
59
+ herdAsk,
60
+ herdMilestone,
61
+ herdAnswer,
62
+ herdReport,
63
+ herdGates,
64
+ herdStatus,
65
+ herdList,
66
+ herdResume,
67
+ herdClose,
68
+ herdAttend,
69
+ herdWrapUp,
70
+ herdStopHidden,
71
+ bgEnsure,
72
+ bgStatus,
73
+ bgStop,
74
+ bgRelease,
57
75
  } from "./client.ts";
58
76
 
59
- export { COMMAND_NAMES, GATE_BY_PANE } from "./commands.ts";
77
+ export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
60
78
  export type {
61
79
  Discussion,
62
80
  DemandDecl,
@@ -94,10 +112,16 @@ export type {
94
112
  PaneSendResult,
95
113
  PaneFocusResult,
96
114
  GateStatus,
115
+ GateOption,
116
+ GateOrigin,
97
117
  GateQuestion,
98
118
  GateAnswer,
99
119
  GateRow,
100
120
  GateSubscription,
121
+ HerdInfo,
122
+ HerdListRow,
123
+ HerdJobInfo,
124
+ HerdStatusData,
101
125
  } from "./commands.ts";
102
126
 
103
127
  export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
@@ -110,6 +134,9 @@ export { repoNameForPath } from "./repos.ts";
110
134
  export { decidePlacement, openSmartPane } from "./smart-pane.ts";
111
135
  export type { Placement, PlacementOpts, HerdrCall } from "./smart-pane.ts";
112
136
 
137
+ export { BG_PREFIX, parsePaneRef, formatPaneRef } from "./pane-ref.ts";
138
+ export type { PaneServer, PaneRef } from "./pane-ref.ts";
139
+
113
140
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
114
141
 
115
142
  export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER, setSettingsWarnSink } from "./settings/resolve.ts";
@@ -0,0 +1,28 @@
1
+ export const BG_PREFIX = "bg:";
2
+
3
+ export type PaneServer = "visible" | "bg";
4
+
5
+ export interface PaneRef {
6
+ server: PaneServer;
7
+ paneId: string;
8
+ }
9
+
10
+ export function parsePaneRef(ref: string): PaneRef {
11
+ if (ref.startsWith(BG_PREFIX)) {
12
+ return {
13
+ server: "bg",
14
+ paneId: ref.slice(BG_PREFIX.length),
15
+ };
16
+ }
17
+ return {
18
+ server: "visible",
19
+ paneId: ref,
20
+ };
21
+ }
22
+
23
+ export function formatPaneRef(paneId: string, server: PaneServer): string {
24
+ if (server === "visible") {
25
+ return paneId;
26
+ }
27
+ return BG_PREFIX + paneId;
28
+ }
@@ -27,6 +27,17 @@ export function teamSettingsPath(team: string): string {
27
27
  return join(teamsDir(), team, "mattstack", "settings.team.jsonc");
28
28
  }
29
29
 
30
+ /**
31
+ * ~/.mattstack/rt/teams/<team>.json: the machine-local team record. Mirrored
32
+ * from repo-tools/lib/team/team-local.ts's teamLocalPath, which is the
33
+ * authority: rt-client has no dependency on rt's lib/, so this literal is
34
+ * duplicated here rather than imported (same convention as `teamSettingsPath`
35
+ * and `userSettingsPath` above).
36
+ */
37
+ export function teamLocalPath(team: string): string {
38
+ return join(home(), ".mattstack", "rt", "teams", `${team}.json`);
39
+ }
40
+
30
41
  /**
31
42
  * ~/.mattstack/user/local/<machineKey()>/settings.local.jsonc — the machine
32
43
  * store, TRACKED and keyed per machine (path literals legal here only).
@@ -85,7 +85,7 @@ export const REGISTRY: readonly SettingDef[] = [
85
85
  scopes: ["user"],
86
86
  default: [],
87
87
  merge: "replace",
88
- description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload. A fresh key, not an ownership-latch port, so a default is fine here.",
88
+ description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload's first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload's subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification's Open target; a gate rule should set it. A fresh key, not an ownership-latch port, so a default is fine here.",
89
89
  },
90
90
  {
91
91
  key: "rt.cron",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One field of the machine-local team record, for the write guard. The record
3
+ * itself is owned by repo-tools/lib/team/team-local.ts; this reads only what
4
+ * the guard needs and never writes.
5
+ */
6
+
7
+ import { readFileSync } from "fs";
8
+ import { teamLocalPath } from "./paths.ts";
9
+
10
+ /** Unreadable, absent or malformed all read as false, so nothing that predates the field is refused. */
11
+ export function isJoinedTeam(team: string): boolean {
12
+ try {
13
+ const parsed: unknown = JSON.parse(readFileSync(teamLocalPath(team), "utf8"));
14
+ return typeof parsed === "object" && parsed !== null && (parsed as { joinedByRt?: unknown }).joinedByRt === true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
@@ -100,6 +100,7 @@ import { dirname } from "path";
100
100
  import { machineSettingsPath, teamSettingsPath, userSettingsPath } from "./paths.ts";
101
101
  import { getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts";
102
102
  import { listTeams } from "./stores.ts";
103
+ import { isJoinedTeam } from "./team-local-read.ts";
103
104
 
104
105
  export interface SetSettingOpts {
105
106
  /** Normalized repo identity — required to target a repoScoped key's `repos.<identity>` section. */
@@ -208,6 +209,19 @@ function migratedFalseMessage(key: string, def: SettingDef): string {
208
209
  return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
209
210
  }
210
211
 
212
+ /**
213
+ * A clone that arrived by redeeming an invite is pull-only, so a write here
214
+ * would never reach the team AND would leave a tracked file dirty, which is
215
+ * enough on its own to make the daemon's fast-forward pull fail.
216
+ */
217
+ function refuseIfJoined(team: string): void {
218
+ if (isJoinedTeam(team)) {
219
+ refuse(
220
+ `this machine joined "${team}" by invite, so its clone is pull-only and team settings cannot be written here. Ask the team's owner to make this change. Member-proposed changes are tracked in MAT-415.`,
221
+ );
222
+ }
223
+ }
224
+
211
225
  /** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */
212
226
  function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
213
227
  if (scope === "user") return userSettingsPath();
@@ -218,6 +232,7 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
218
232
  if (!existsSync(path)) {
219
233
  refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`);
220
234
  }
235
+ refuseIfJoined(opts.team);
221
236
  return path;
222
237
  }
223
238
 
@@ -228,7 +243,9 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
228
243
  if (teams.length > 1) {
229
244
  refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
230
245
  }
231
- return teamSettingsPath(teams[0] as string);
246
+ const team = teams[0] as string;
247
+ refuseIfJoined(team);
248
+ return teamSettingsPath(team);
232
249
  }
233
250
 
234
251
  /**
@@ -243,7 +260,9 @@ function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): st
243
260
 
244
261
  if (opts.team !== undefined) {
245
262
  const path = teamSettingsPath(opts.team);
246
- return existsSync(path) ? path : null;
263
+ if (!existsSync(path)) return null;
264
+ refuseIfJoined(opts.team);
265
+ return path;
247
266
  }
248
267
 
249
268
  const teams = listTeams();
@@ -251,7 +270,9 @@ function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): st
251
270
  if (teams.length > 1) {
252
271
  refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
253
272
  }
254
- return teamSettingsPath(teams[0] as string);
273
+ const team = teams[0] as string;
274
+ refuseIfJoined(team);
275
+ return teamSettingsPath(team);
255
276
  }
256
277
 
257
278
  /** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */