@jr2/cli 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,14 +39,14 @@
39
39
  "ts-blank-space": "^0.9.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@jr2/orchestrator": "0.6.0"
42
+ "@jr2/orchestrator": "0.7.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@anthropic-ai/sdk": "0.91.1",
46
46
  "@types/node": "^26.0.1",
47
47
  "xstate": "^5.18.0",
48
48
  "zod": "^4.4.3",
49
- "@jr2/orchestrator": "0.6.0"
49
+ "@jr2/orchestrator": "0.7.0"
50
50
  },
51
51
  "files": [
52
52
  "bin",
package/src/build.ts CHANGED
@@ -375,10 +375,10 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
375
375
  dockerfile: "deploy/orchestrator/Dockerfile",
376
376
  context: ".",
377
377
  // `@jr2/orchestrator` and every kit package it depends on, which the Dockerfile packs into the
378
- // image (ADR-0069). `packages/agent-protocol` is a runtime dependency, so an edit there changes
379
- // the image and must move its tag. The third-party closure (xstate, zod, hono, …) comes from
380
- // the registry, each package forced to the version `deploy/orchestrator/closure.json` pins, so
381
- // the tag addresses it through that file. closure.json equals pnpm-lock.yaml's closure, and
378
+ // image (ADR-0069). `packages/agent-protocol` is a runtime dependency, so
379
+ // an edit there changes the image and must move its tag. The third-party closure (xstate, zod,
380
+ // hono, …) comes from the registry, each package forced to the version
381
+ // `deploy/orchestrator/closure.json` pins, so the tag addresses it through that file. closure.json equals pnpm-lock.yaml's closure, and
382
382
  // this list equals the Orchestrator's `workspace:` dependencies, both asserted in
383
383
  // test/orchestrator-image.test.ts.
384
384
  sources: ["packages/orchestrator", "packages/agent-protocol", "deploy/orchestrator"],
@@ -0,0 +1,108 @@
1
+ // The named Caller tokens on the cluster (ADR-0078): what `jr2 up` and `jr2 caller rotate` share.
2
+ //
3
+ // Each name that `callers.tokens` declares has a Secret of its own, `jr2-caller-<name>`, which holds
4
+ // its value (`jr2c_` and 32 random bytes in base64url) and which no pod mounts. The Orchestrator
5
+ // gets only the digests, in `jr2-caller-digests`, which it mounts and reads again when it changes.
6
+ // So the value stays where kube RBAC guards it, as the Instance token does, and a new token or a
7
+ // rotation rolls no pod.
8
+ //
9
+ // `jr2 up` makes a value once, for a name that has none, and keeps it after that. It deletes the
10
+ // Secret of a name that the config no longer declares, so that value stops working. `jr2 caller
11
+ // rotate` makes a new value for one name and writes the digests again from every token Secret.
12
+
13
+ import { CALLER_TOKEN_PREFIX, mintCallerToken } from "@jr2/orchestrator/internal";
14
+ import { CALLER_TOKEN_KEY, callerTokenObjects, LABEL_CALLER_TOKEN, LABEL_INSTANCE } from "./deploy.ts";
15
+ import type { KubeAdmin, KubeObject } from "./kube.ts";
16
+ import { mountedDigests, type AppliedObject } from "./mount-wait.ts";
17
+
18
+ type Ctx = { context?: string };
19
+ type SecretObject = KubeObject & { data?: Record<string, string> };
20
+
21
+ /** The values of the token Secrets in the namespace, by the name on their label. A Secret with no
22
+ * value, or a value that is not a Caller token's, is left out. */
23
+ export async function storedCallerTokens(kube: KubeAdmin, namespace: string, ctx: Ctx): Promise<Map<string, string>> {
24
+ const found = new Map<string, string>();
25
+ for (const secret of await kube.listJson<SecretObject>({
26
+ kind: "secret",
27
+ selector: LABEL_CALLER_TOKEN,
28
+ namespace,
29
+ ...ctx,
30
+ })) {
31
+ const name = secret.metadata.labels?.[LABEL_CALLER_TOKEN];
32
+ const b64 = secret.data?.[CALLER_TOKEN_KEY];
33
+ if (!name || !b64) continue;
34
+ const value = Buffer.from(b64, "base64").toString("utf8");
35
+ if (value.startsWith(CALLER_TOKEN_PREFIX)) found.set(name, value);
36
+ }
37
+ return found;
38
+ }
39
+
40
+ /** What `jr2 up` does with the named Caller tokens: the value of each declared name, the names it
41
+ * made a value for now, and the names whose Secret it deletes. */
42
+ export type CallerTokenPlan = {
43
+ values: Record<string, string>;
44
+ made: string[];
45
+ undeclared: string[];
46
+ };
47
+
48
+ /**
49
+ * Plan the named Caller tokens for one converge (ADR-0078): keep the stored value of each declared
50
+ * name, make one for a declared name that has none, and name each stored name that the config no
51
+ * longer declares.
52
+ */
53
+ export async function planCallerTokens(
54
+ kube: KubeAdmin,
55
+ declared: readonly string[],
56
+ namespace: string,
57
+ ctx: Ctx,
58
+ ): Promise<CallerTokenPlan> {
59
+ const stored = await storedCallerTokens(kube, namespace, ctx);
60
+ const values: Record<string, string> = {};
61
+ const made: string[] = [];
62
+ for (const name of [...declared].sort()) {
63
+ const kept = stored.get(name);
64
+ if (kept !== undefined) values[name] = kept;
65
+ else {
66
+ values[name] = mintCallerToken();
67
+ made.push(name);
68
+ }
69
+ }
70
+ const undeclared = [...stored.keys()].filter((n) => !declared.includes(n)).sort();
71
+ return { values, made, undeclared };
72
+ }
73
+
74
+ /**
75
+ * Make a new value for one named Caller token, and write the digests again (ADR-0078). The value
76
+ * goes first: when the digests write fails, `jr2 caller rotate` runs again and the token still has
77
+ * a value that `jr2 caller token` prints. Refuses a name that has no token Secret: `jr2 up` makes
78
+ * it, from the config. Returns the digest of the digests Secret it applied, by name
79
+ * (`mountedDigests`): `jr2 caller rotate` waits until the Orchestrator has loaded it.
80
+ */
81
+ export async function rotateCallerToken(
82
+ kube: KubeAdmin,
83
+ opts: { instance: string; namespace: string; name: string; ctx: Ctx },
84
+ ): Promise<Record<string, string>> {
85
+ const stored = await storedCallerTokens(kube, opts.namespace, opts.ctx);
86
+ if (!stored.has(opts.name)) {
87
+ const known = [...stored.keys()].sort();
88
+ throw new Error(
89
+ `no Caller token "${opts.name}" in namespace "${opts.namespace}" — declare it in callers.tokens of ` +
90
+ `jr2.config.ts and run \`jr2 up\`${known.length ? ` (tokens here: ${known.join(", ")})` : ""}`,
91
+ );
92
+ }
93
+ stored.set(opts.name, mintCallerToken());
94
+ const labels = { [LABEL_INSTANCE]: opts.instance, "app.kubernetes.io/managed-by": "jr2" };
95
+ const meta = (name: string, extra: Record<string, string> = {}) => ({
96
+ name,
97
+ namespace: opts.namespace,
98
+ labels: { ...labels, ...extra },
99
+ });
100
+ const [valueSecret, digests] = callerTokenObjects(Object.fromEntries(stored), meta).filter(
101
+ (o) =>
102
+ (o.metadata as { labels: Record<string, string> }).labels[LABEL_CALLER_TOKEN] === opts.name ||
103
+ (o.metadata as { labels: Record<string, string> }).labels[LABEL_CALLER_TOKEN] === undefined,
104
+ );
105
+ await kube.apply({ manifest: JSON.stringify(valueSecret), ...opts.ctx });
106
+ await kube.apply({ manifest: JSON.stringify(digests), ...opts.ctx });
107
+ return mountedDigests([digests as AppliedObject]);
108
+ }
package/src/cli.ts CHANGED
@@ -11,15 +11,24 @@ import { loadDotenv } from "./env.ts";
11
11
  import { init } from "./commands/init.ts";
12
12
  import { run } from "./commands/run.ts";
13
13
  import { runs } from "./commands/runs.ts";
14
+ import { workflows } from "./commands/workflows.ts";
15
+ import { gates } from "./commands/gates.ts";
14
16
  import { status } from "./commands/status.ts";
15
17
  import { logs } from "./commands/logs.ts";
18
+ import { watch } from "./commands/watch.ts";
19
+ import { workspace } from "./commands/workspace.ts";
20
+ import { completion } from "./commands/completion.ts";
16
21
  import { send } from "./commands/send.ts";
22
+ import { steer } from "./commands/steer.ts";
17
23
  import { up } from "./commands/up.ts";
18
24
  import { check } from "./commands/check.ts";
19
25
  import { down } from "./commands/down.ts";
20
26
  import { gc } from "./commands/gc.ts";
21
27
  import { kit } from "./commands/kit.ts";
22
28
  import { version } from "./commands/version.ts";
29
+ import { whoami } from "./commands/whoami.ts";
30
+ import { hooks } from "./commands/hooks.ts";
31
+ import { caller } from "./commands/caller.ts";
23
32
  import { asksForHelp, commandsFor, GLOBAL, renderCommand, renderHelp } from "./command-table.ts";
24
33
 
25
34
  /**
@@ -48,8 +57,12 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
48
57
  // `jr2.config.ts` (whose deployment-varying values are read from there — ADR-0019).
49
58
  loadDotenv(io);
50
59
  const entries = cmd === undefined ? [] : commandsFor(cmd);
51
- if (entries.length && asksForHelp(entries, rest)) {
52
- activity(io, entries.map(renderCommand).join("\n\n"));
60
+ // `completion __complete` carries the line being completed, which can hold `-h` itself.
61
+ const completing = cmd === "completion" && rest[0] === "__complete";
62
+ if (entries.length && !completing && asksForHelp(entries, rest)) {
63
+ // `jr2 caller rotate --help` prints that sub-verb's entry; `jr2 caller --help` prints each.
64
+ const named = entries.filter((c) => c.sub !== undefined && c.sub === rest[0]);
65
+ activity(io, (named.length ? named : entries).map(renderCommand).join("\n\n"));
53
66
  return 0;
54
67
  }
55
68
  if (cmd !== undefined && (GLOBAL.help as readonly string[]).includes(cmd)) {
@@ -63,12 +76,20 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
63
76
  return await run(rest, io);
64
77
  case "runs":
65
78
  return await runs(rest, io);
79
+ case "workflows":
80
+ return await workflows(rest, io);
81
+ case "gates":
82
+ return await gates(rest, io);
66
83
  case "status":
67
84
  return await status(rest, io);
85
+ case "watch":
86
+ return await watch(rest, io);
68
87
  case "logs":
69
88
  return await logs(rest, io);
70
89
  case "send":
71
90
  return await send(rest, io);
91
+ case "steer":
92
+ return await steer(rest, io);
72
93
  case "check":
73
94
  return await check(rest, io);
74
95
  case "up":
@@ -79,6 +100,16 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
79
100
  return await gc(rest, io);
80
101
  case "kit":
81
102
  return await kit(rest, io);
103
+ case "whoami":
104
+ return await whoami(rest, io);
105
+ case "hooks":
106
+ return await hooks(rest, io);
107
+ case "caller":
108
+ return await caller(rest, io);
109
+ case "workspace":
110
+ return await workspace(rest, io);
111
+ case "completion":
112
+ return await completion(rest, io);
82
113
  case "version":
83
114
  case "--version":
84
115
  return await version(rest, io);
package/src/client.ts CHANGED
@@ -6,23 +6,42 @@
6
6
  // `fetchImpl` is injectable so tests drive it with a hono `app.request` (no socket) the same way the
7
7
  // orchestrator's own http tests do; in production it defaults to the global `fetch`.
8
8
 
9
- import type {
10
- ChildErrorMarker,
11
- DispatchedBy,
12
- MachineDoc,
13
- PlacingMarker,
14
- RepoStatus,
15
- RunChild,
16
- RunWaiting,
9
+ import {
10
+ ON_BEHALF_OF_HEADER,
11
+ type CallerRecord,
12
+ type ChildErrorMarker,
13
+ type GateMarker,
14
+ type HookLogLine,
15
+ type HookRefusedMarker,
16
+ type PoolMarker,
17
+ type DispatchedBy,
18
+ type GateInboxEntry,
19
+ type MachineDoc,
20
+ type PlacingMarker,
21
+ type RepoStatus,
22
+ type RunChild,
23
+ type RunPage,
24
+ type RunSummary,
25
+ type RunStarter,
26
+ type RunWaiting,
27
+ type Scopes,
28
+ type ShownStatus,
17
29
  } from "@jr2/orchestrator/internal";
18
30
  import { parseSSE } from "./sse.ts";
19
31
 
32
+ /** The query of `GET /runs` (ADR-0009): `status` is `live` (the default), `ended`, `all`, or a comma
33
+ * list; `since` a duration back from now or an ISO-8601 time; `cursor` a page's `next`. */
34
+ export type RunsQuery = { status?: string; workflow?: string; since?: string; cursor?: string; limit?: number };
35
+
36
+ export type { RunPage, RunSummary };
37
+
20
38
  /** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
21
39
  export type RunStatus = {
22
40
  runId: string;
23
41
  workflow: string;
24
42
  instanceId: string;
25
- status: string;
43
+ /** `active` while the run is live, else how it ended (ADR-0025): never xstate's `stopped`. */
44
+ status: ShownStatus;
26
45
  value: unknown;
27
46
  context: unknown;
28
47
  /** The live child machines beneath the root, context-free — where most of a run is. */
@@ -56,8 +75,21 @@ export type RunStatus = {
56
75
  /** The Worktrees this run's cuts made, oldest first: the Repo Slot, the path, and the branch,
57
76
  * `null` when detached (ADR-0075). Absent when it cut none. */
58
77
  worktrees?: Array<{ slot: string; path: string; branch: string | null }>;
78
+ /** Who started the run (ADR-0078): a Caller, a run through a local `dispatch`, or jr2. Behind the
79
+ * token; absent for a run that a host started with no starter. */
80
+ startedBy?: RunStarter;
81
+ /** The Caller that cancelled the run (ADR-0078). */
82
+ cancelledBy?: CallerRecord;
83
+ /** The Caller whose `jr2 run` resumed this standing run (ADR-0017, ADR-0078). */
84
+ resumedBy?: CallerRecord;
85
+ /** The run that took over from this standing run at a hand-over (ADR-0017). */
86
+ handedOverTo?: string;
59
87
  };
60
88
 
89
+ /** A request's Caller, as `GET /whoami` gives it (ADR-0078): how jr2 knows it, its name, the name
90
+ * jr2 shows, what it may do, and the party it says it acts for. */
91
+ export type WhoAmI = CallerRecord & { may: Scopes };
92
+
61
93
  /** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
62
94
  * a runtime Chart put by `PUT /workflows/:wrapper/charts/:name`. */
63
95
  export type WorkflowOrigin = "machine" | "file" | "runtime";
@@ -83,12 +115,37 @@ export type RunFeedEvent =
83
115
  | { kind: "emit"; event: { type: string } & Record<string, unknown> }
84
116
  | { kind: "retry"; child: string; attempt: number; reason: string }
85
117
  | PlacingMarker
86
- | ChildErrorMarker;
118
+ | ChildErrorMarker
119
+ | GateMarker
120
+ | HookRefusedMarker
121
+ | PoolMarker;
122
+
123
+ /** A Hook's log, as `GET /hooks/:name` gives it (ADR-0011): whether the current config declares the
124
+ * Hook, and its lines, newest first. */
125
+ export type HookLog = { hook: string; declared: boolean; lines: HookLogLine[] };
87
126
 
88
127
  /** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
89
128
  * `keep` leaves a cancelled run's Workspaces instead of destroying them (ADR-0025). */
90
129
  export type RunEvent = { type: string; keep?: boolean };
91
130
 
131
+ /** One kept conversation, as `GET /runs/:runId/conversations` lists it (ADR-0023). `slot` is the
132
+ * Agent slot from the run's admission ledger. */
133
+ export type KeptConversationView = {
134
+ agent: string;
135
+ slot?: string;
136
+ instanceId: string;
137
+ open: boolean;
138
+ bytes: number;
139
+ cut: boolean;
140
+ swept?: string;
141
+ };
142
+
143
+ /** One entry of a kept conversation (ADR-0023): one body of the print, its label as `kind`. */
144
+ export type ConversationEntryView = { id: string; seq: number; at: string; agent: string; kind: string; text: string };
145
+
146
+ /** One Agent slot of a live run, as `GET /runs/:runId/agents` lists it (ADR-0002). */
147
+ export type RunAgentView = { agent: string; open: boolean; steerable: boolean };
148
+
92
149
  /** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
93
150
  export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
94
151
 
@@ -139,19 +196,44 @@ export function workflowPath(workflow: string): string {
139
196
  export class JR2Client {
140
197
  readonly baseUrl: string;
141
198
  private readonly fetchImpl: FetchLike;
142
- /** The Instance token (ADR-0013). The run and gate surfaces are authenticated; without it every
143
- * call below is a 401. Absent only for an unauthenticated surface (a test's in-process app). */
199
+ /** The Caller's credential (ADR-0078): the Instance token, or any Caller's. The run and gate
200
+ * surfaces are authenticated; without it every call below is a 401. Absent only for an
201
+ * unauthenticated surface (a test's in-process app). */
144
202
  private readonly token?: string;
203
+ /** The party the Caller says it acts for, sent as `jr2-on-behalf-of` with the bearer (ADR-0078). */
204
+ private readonly onBehalfOf?: string;
145
205
 
146
- constructor(baseUrl: string, fetchImpl: FetchLike = globalThis.fetch, token?: string) {
206
+ constructor(baseUrl: string, fetchImpl: FetchLike = globalThis.fetch, token?: string, onBehalfOf?: string) {
147
207
  this.baseUrl = baseUrl.replace(/\/+$/, "");
148
208
  this.fetchImpl = fetchImpl;
149
209
  this.token = token;
210
+ this.onBehalfOf = onBehalfOf;
150
211
  }
151
212
 
152
- /** Request headers: the bearer, plus whatever the call adds. */
213
+ /** Request headers: the bearer and the party it acts for, plus whatever the call adds. */
153
214
  private headers(extra: Record<string, string> = {}): Record<string, string> {
154
- return this.token ? { ...extra, authorization: `Bearer ${this.token}` } : extra;
215
+ if (!this.token) return extra;
216
+ return {
217
+ ...extra,
218
+ authorization: `Bearer ${this.token}`,
219
+ ...(this.onBehalfOf ? { [ON_BEHALF_OF_HEADER]: this.onBehalfOf } : {}),
220
+ };
221
+ }
222
+
223
+ /** `GET /whoami` — the Caller this credential is (ADR-0078). It needs no scope, so a credential
224
+ * that may do nothing still learns why. */
225
+ async whoami(): Promise<WhoAmI> {
226
+ const res = await this.fetchImpl(`${this.baseUrl}/whoami`, { headers: this.headers() });
227
+ return (await this.json(res, "/whoami")) as WhoAmI;
228
+ }
229
+
230
+ /** `GET /hooks/:name` — a Hook's log, newest first (ADR-0011). The Instance token alone. */
231
+ async hookLog(name: string, limit?: number): Promise<HookLog> {
232
+ const query = limit === undefined ? "" : `?limit=${limit}`;
233
+ const res = await this.fetchImpl(`${this.baseUrl}/hooks/${encodeURIComponent(name)}${query}`, {
234
+ headers: this.headers(),
235
+ });
236
+ return (await this.json(res, "/hooks/:name")) as HookLog;
155
237
  }
156
238
 
157
239
  /** `GET /workflows` — every registered Workflow with its origin, each Chart wrapper's Charts inside
@@ -186,19 +268,57 @@ export class JR2Client {
186
268
  /** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. A named Chart's
187
269
  * name `<wrapper>/<name>` rides the path as one encoded segment (ADR-0070); a refused Chart in
188
270
  * the input → 422 → throws with the door's `report`. */
189
- async start(workflow: string, input: Record<string, unknown> = {}): Promise<{ runId: string; instanceId: string }> {
190
- const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs`, {
271
+ async start(
272
+ workflow: string,
273
+ input: Record<string, unknown> = {},
274
+ opts: { dropHeld?: boolean } = {},
275
+ ): Promise<{ runId: string; instanceId: string; resumed?: true }> {
276
+ // `drop-held` resumes a paused standing Workflow with no held item (ADR-0017).
277
+ const query = opts.dropHeld ? "?drop-held=true" : "";
278
+ const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs${query}`, {
191
279
  method: "POST",
192
280
  headers: this.headers(JSON_HEADERS),
193
281
  body: JSON.stringify(input),
194
282
  });
195
- return (await this.json(res, "/workflows/:name/runs")) as { runId: string; instanceId: string };
283
+ return (await this.json(res, "/workflows/:name/runs")) as { runId: string; instanceId: string; resumed?: true };
284
+ }
285
+
286
+ /** `GET /runs` — one page of the run listing (ADR-0009): live runs when `query` names no
287
+ * `status`, newest start first, and the cursor of the next page when there is one. */
288
+ async runs(query: RunsQuery = {}): Promise<RunPage> {
289
+ const params = new URLSearchParams();
290
+ for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
291
+ const qs = params.size ? `?${params}` : "";
292
+ const res = await this.fetchImpl(`${this.baseUrl}/runs${qs}`, { headers: this.headers() });
293
+ const page = (await this.json(res, "/runs")) as RunPage;
294
+ if (typeof page !== "object" || page === null || !Array.isArray(page.runs)) {
295
+ throw new Error("GET /runs: the answer is not a page of runs");
296
+ }
297
+ return page;
196
298
  }
197
299
 
198
- /** `GET /runs` — every live run's status. */
199
- async list(): Promise<RunStatus[]> {
200
- const res = await this.fetchImpl(`${this.baseUrl}/runs`, { headers: this.headers() });
201
- return (await this.json(res, "/runs")) as RunStatus[];
300
+ /** `GET /gates` — the Gate inbox (ADR-0011): the open Gates of every live run that this Caller
301
+ * may answer, oldest first, of one Workflow when `workflow` names it. */
302
+ async gates(workflow?: string): Promise<GateInboxEntry[]> {
303
+ const qs = workflow !== undefined ? `?${new URLSearchParams({ workflow })}` : "";
304
+ const res = await this.fetchImpl(`${this.baseUrl}/gates${qs}`, { headers: this.headers() });
305
+ const body = (await this.json(res, "/gates")) as { gates?: unknown };
306
+ if (typeof body !== "object" || body === null || !Array.isArray(body.gates)) {
307
+ throw new Error("GET /gates: the answer is not a list of Gates");
308
+ }
309
+ return body.gates as GateInboxEntry[];
310
+ }
311
+
312
+ /** Every run that `query` selects: each page of `GET /runs`, in turn, until a page has no `next`. */
313
+ async allRuns(query: Omit<RunsQuery, "cursor"> = {}): Promise<RunSummary[]> {
314
+ const runs: RunSummary[] = [];
315
+ let cursor: string | undefined;
316
+ do {
317
+ const page = await this.runs({ ...query, ...(cursor !== undefined ? { cursor } : {}) });
318
+ runs.push(...page.runs);
319
+ cursor = page.next;
320
+ } while (cursor !== undefined);
321
+ return runs;
202
322
  }
203
323
 
204
324
  /** `GET /repos` — whether the instance has a data plane, and every Repo resource as the cluster
@@ -241,6 +361,25 @@ export class JR2Client {
241
361
  await this.json(res, "/runs/:runId/gates/:gate/events"); // surface { error }; ignore { ok:true }
242
362
  }
243
363
 
364
+ /** `GET /runs/:runId/agents` — the Agent slots a Steering message can go to (ADR-0002). */
365
+ async agents(runId: string): Promise<RunAgentView[]> {
366
+ const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/agents`, {
367
+ headers: this.headers(),
368
+ });
369
+ return (await this.json(res, "/runs/:runId/agents")) as RunAgentView[];
370
+ }
371
+
372
+ /** `POST /runs/:runId/steering` — a Steering message to one Agent slot of a live run (ADR-0002).
373
+ * Answers its id, and whether a Turn of the slot is open now. */
374
+ async steer(runId: string, agent: string, text: string): Promise<{ id: string; agent: string; open: boolean }> {
375
+ const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/steering`, {
376
+ method: "POST",
377
+ headers: this.headers(JSON_HEADERS),
378
+ body: JSON.stringify({ agent, text }),
379
+ });
380
+ return (await this.json(res, "/runs/:runId/steering")) as { id: string; agent: string; open: boolean };
381
+ }
382
+
244
383
  /** `POST /runs/:runId/events` — feed one run-control event into a live run. */
245
384
  async send(runId: string, event: RunEvent): Promise<void> {
246
385
  const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
@@ -287,19 +426,81 @@ export class JR2Client {
287
426
  } else if (frame.event === "child-error") {
288
427
  // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024).
289
428
  yield { ...(JSON.parse(frame.data) as object), kind: "child-error" } as ChildErrorMarker;
429
+ } else if (frame.event === "looked") {
430
+ // A Gate's look gave its event, and the run took it (ADR-0011).
431
+ yield { ...(JSON.parse(frame.data) as object), kind: "looked" } as GateMarker;
432
+ } else if (frame.event === "hook-refused") {
433
+ // A Hook's delivery reached an open Gate, and the Gate did not take it (ADR-0011).
434
+ yield { ...(JSON.parse(frame.data) as object), kind: "hook-refused" } as HookRefusedMarker;
435
+ } else if (frame.event === "pool") {
436
+ // What a Pool of runs did with an item (ADR-0017).
437
+ yield { ...(JSON.parse(frame.data) as object), kind: "pool" } as PoolMarker;
290
438
  } else if (frame.event === "gone") {
291
439
  // The run left the live set without settling — stopped, parked for the next boot's
292
440
  // restore (ADR-0025). This end is the run's own (ADR-0022): nothing to re-attach to.
293
441
  return;
294
442
  }
295
443
  // Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
296
- // misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
444
+ // misread as a status: `jr2 watch` decides "settled" off `status.status`, and a marker
297
445
  // parsed as a status would end the follow mid-run.
298
446
  }
299
447
  if (settled) return;
300
448
  }
301
449
  }
302
450
 
451
+ /** `GET /runs/:runId/conversations` — the run's kept conversations (ADR-0023), the first first. */
452
+ async conversations(runId: string): Promise<KeptConversationView[]> {
453
+ const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations`, {
454
+ headers: this.headers(),
455
+ });
456
+ return (await this.json(res, "/runs/:runId/conversations")) as KeptConversationView[];
457
+ }
458
+
459
+ /** `GET /runs/:runId/conversations/:instanceId` — one page of a kept conversation's entries after
460
+ * `after` (-1: from the first). */
461
+ async conversationEntries(
462
+ runId: string,
463
+ instanceId: string,
464
+ after: number,
465
+ limit = 1000,
466
+ ): Promise<ConversationEntryView[]> {
467
+ const res = await this.fetchImpl(
468
+ `${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations/${encodeURIComponent(instanceId)}` +
469
+ `?after=${after}&limit=${limit}`,
470
+ { headers: this.headers() },
471
+ );
472
+ return (await this.json(res, "/runs/:runId/conversations/:instanceId")) as ConversationEntryView[];
473
+ }
474
+
475
+ /** `GET /runs/:runId/conversations/:instanceId/events` — follow one kept conversation: its entries
476
+ * after `after`, then each new one, until the server ends the feed (the conversation is not open,
477
+ * or it is cut or swept). A feed that ends with no `end` frame is opened again after the last
478
+ * entry read. */
479
+ async *followConversation(runId: string, instanceId: string, after: number): AsyncGenerator<ConversationEntryView> {
480
+ let last = after;
481
+ for (;;) {
482
+ const res = await this.fetchImpl(
483
+ `${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations/${encodeURIComponent(instanceId)}/events` +
484
+ `?after=${last}`,
485
+ { headers: this.headers({ accept: "text/event-stream" }) },
486
+ );
487
+ if (!res.ok) {
488
+ await this.json(res, "/runs/:runId/conversations/:instanceId/events");
489
+ return;
490
+ }
491
+ if (!res.body) return;
492
+ for await (const frame of parseSSE(res.body)) {
493
+ if (frame.event === "entry") {
494
+ const entry = JSON.parse(frame.data) as ConversationEntryView;
495
+ if (entry.seq > last) last = entry.seq;
496
+ yield entry;
497
+ } else if (frame.event === "end") {
498
+ return;
499
+ }
500
+ }
501
+ }
502
+ }
503
+
303
504
  /**
304
505
  * `GET /healthz` — what this orchestrator says it is. Unauthenticated (it is the readiness probe),
305
506
  * best-effort by contract: an unreachable or older instance answers `undefined`/`{}` rather than