@jr2/orchestrator 0.1.4 → 0.2.1

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/orchestrator",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -42,11 +42,11 @@
42
42
  "ts-blank-space": "^0.9.0",
43
43
  "xstate": "^5.18.0",
44
44
  "zod": "^4.4.3",
45
- "@jr2/agent-protocol": "0.1.4"
45
+ "@jr2/agent-protocol": "0.2.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^26.0.1",
49
- "@jr2/harness": "0.1.4"
49
+ "@jr2/harness": "0.2.1"
50
50
  },
51
51
  "files": [
52
52
  "bin",
package/src/actor.ts CHANGED
@@ -43,10 +43,16 @@
43
43
  // at all, and restore is a fresh process, so there is nothing to infer it from.
44
44
 
45
45
  import { fromCallback, type CallbackActorLogic } from "xstate";
46
- import { requireBoundAgent, type AgentDeclaration, type AgentDefinition, type ThinkingLevel } from "./agent.ts";
47
- import { ambientHandlesFor } from "./ambient.ts";
46
+ import {
47
+ requireBoundAgent,
48
+ type AgentDeclaration,
49
+ type AgentDefinition,
50
+ type ThinkingLevel,
51
+ type WorkspaceAccess,
52
+ } from "./agent.ts";
53
+ import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
48
54
  import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
49
- import { agentAddress, resolveAccepts, runBindingOf } from "./registration.ts";
55
+ import { agentAddress, continuedIid, resolveAccepts, runBindingOf } from "./registration.ts";
50
56
 
51
57
  /**
52
58
  * One admitted Submission — the durable re-attach handle (ADR-0016). The wire fields are
@@ -72,19 +78,33 @@ export type AgentAdmission = {
72
78
  };
73
79
 
74
80
  /**
75
- * What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049): this turn's prompt —
76
- * everything else is derived. The AGENT is not written here at all: the slot key is its name
77
- * (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine does not carry is a
78
- * compile error on `src` instead of a runtime miss. `jr2Setup.createMachine` wraps the invoke
79
- * input to finalize it into {@link AgentRunInput}: the slot key lands as `agentName`, the tool
80
- * menu derives from the invoking state's transitions, the instance id is minted (fresh session by
81
- * default; `session: "continue"` or a `conversation` pin derives a deterministic id so
82
- * re-invocations continue one conversation), and endpoint/sandbox resolve ambiently from the
83
- * enclosing `workspace()`.
81
+ * What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049/0057): this Turn's FRAME,
82
+ * its DIALS, and whether it CONTINUES — nothing else. The AGENT is not written here at all: the
83
+ * slot key is its name (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine
84
+ * does not carry is a compile error on `src` instead of a runtime miss.
85
+ *
86
+ * `jr2Setup.createMachine` wraps the invoke input to finalize it into {@link AgentRunInput}: the
87
+ * slot key lands as `agentName`, the tool menu derives from the invoking state's transitions, the
88
+ * instance id is MINTED (never written here — fresh by default; `continue: true` derives the
89
+ * Agent's one conversation in this Machine instance), and endpoint/sandbox resolve ambiently from
90
+ * the enclosing `workspace()`.
84
91
  */
85
92
  export type AgentTurnInput = {
86
- /** This turn's task framing — lands as the conversation's next user message. */
93
+ /** Half this Turn's FRAME (ADR-0057) — what it is about: the prompt lands as the conversation's
94
+ * next user message. Neither identity nor a Dial. */
87
95
  prompt: string;
96
+ /**
97
+ * The other half of the FRAME (ADR-0057) — WHERE this Turn works: the absolute directory the
98
+ * Harness roots the Working tools at. Per Turn by nature, which is why no definition names one:
99
+ * a Worktree path (`/work/<slot>/<branch>`) exists only once a run has a branch.
100
+ *
101
+ * Absent, the actor resolves it before admission: a `workspace: "none"` Agent frames no
102
+ * directory (it has no Working tools to root — ADR-0028), a Workspace with ONE Repo Slot frames
103
+ * that slot's Worktree, a Workspace with more REFUSES the Turn (the kit gives no slot a meaning
104
+ * — ADR-0051 — so the state must say which), and a run with no Workspace at all frames nothing
105
+ * and the Harness keeps `/work`. A relative path is refused at admission, by the Harness.
106
+ */
107
+ cwd?: string;
88
108
  /**
89
109
  * This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
90
110
  * values. One definition value may be carried by several Machines, so the same persona
@@ -92,38 +112,46 @@ export type AgentTurnInput = {
92
112
  * and the same reviewer on an architecture change want identical instructions and different
93
113
  * effort.
94
114
  *
95
- * IDENTITY is deliberately absent — no `instructions`, `workspace` or `cwd` here. A call site
96
- * that rewrote those would make the Agent's name a lie, and `workspace` in particular carries
115
+ * IDENTITY is deliberately absent — no `instructions` or `workspace` here. A call site that
116
+ * rewrote those would make the Agent's name a lie, and `workspace` in particular carries
97
117
  * ADR-0028's containment claim, which per-invocation escalation would void.
98
118
  */
99
119
  model?: string;
100
120
  thinkingLevel?: ThinkingLevel;
101
121
  /**
102
- * Session continuity (ADR-0016). Absent = FRESH: every invocation is a new conversation
103
- * (jr's lossy handoff — revision agents read notes + code, never the prior conversation).
104
- * `"continue"` = the same `(state path, agent, scope)` re-invocation continues ONE
105
- * conversation; the prompt lands as its next user turn.
106
- */
107
- session?: "continue";
108
- /** Distinguishes conversations that would otherwise share a `continue` identity (e.g. a
109
- * reviewer fresh per task: `scope: task.id`). */
110
- scope?: string;
111
- /**
112
- * Pin the conversation to a workflow-chosen name — the CROSS-MACHINE continue (ADR-0016's
113
- * opt-in continuation, where `session: "continue"` cannot reach: its derived id carries the
114
- * invoking actor's path, so it only spans states of one machine). Invocations naming the same
115
- * `conversation` derive ONE deterministic, run-scoped instance id (`<runId>/<name>/<agent>`)
116
- * wherever in the actor tree they sit — a triage state before the `workspace()` and an assess
117
- * state inside its body continue one conversation, the prompt landing as its next user turn.
118
- * Only sound where every invocation lands on the same Harness, because a conversation is an
119
- * Instance ID on ONE server: a `workspace: "none"` Agent (always the Instance Harness —
120
- * definition-wins, ADR-0031) or a fixed explicit `endpoint`.
122
+ * Continue this Agent's ONE conversation in this Machine instance (ADR-0016, ADR-0057) — the
123
+ * whole continuation surface. Absent, every invocation is a FRESH conversation (jr's lossy
124
+ * handoff — revision agents read notes + code, never the prior conversation). Present, the
125
+ * prompt lands as the next user turn of that conversation, whichever state of the Machine
126
+ * invokes it: states differ by Menu (a coder in `implement` offers `finish`; the same coder in
127
+ * `fix` offers `finish` and `dispute`) and the conversation is wanted across them.
128
+ *
129
+ * It names nothing, because the id is STRUCTURAL — `<runId>/<machine actor path>/<agent>` — and
130
+ * a caller-chosen key could only restate that, or contradict it. Two Agents therefore never
131
+ * share a conversation (the name is in the id), and two Pool children never collide (each
132
+ * worker is its own Machine instance). A conversation jr2 has faulted is never continued: the
133
+ * next `continue` lands on a virgin one (ADR-0035, ADR-0057) — and re-briefing that Turn is the
134
+ * author's, because jr2 cannot write that prompt.
121
135
  */
122
- conversation?: string;
123
- /** Workspace-less runs only (stub Harness): explicit endpoint, no ambient resolution. */
136
+ continue?: true;
137
+ };
138
+
139
+ /**
140
+ * MECHANISM, not authoring surface (ADR-0057): where a Turn's Harness is and which Sandbox scopes
141
+ * its deliveries, STATED instead of resolved. It is the seat the stub tier sits in — a
142
+ * mechanics-tier test has no `workspace()` to resolve from, and a dev Harness is just a URL
143
+ * (ADR-0011) — so it is exported for those tests and for nothing else.
144
+ *
145
+ * A real run states neither: a Sandbox Agent resolves both ambiently from the enclosing
146
+ * `workspace()` (ADR-0016), and a `workspace: "none"` Agent lands on the Instance Harness
147
+ * (ADR-0031). Stated, it wins over both, because a URL a test wrote is the one thing no ambient
148
+ * walk can know about.
149
+ */
150
+ export type AgentTurnPlacement = {
151
+ /** The Harness base URL this Turn is admitted over. */
124
152
  endpoint?: string;
125
- /** Escape hatch: override the derived menu. */
126
- tools?: readonly string[];
153
+ /** The Sandbox whose Adapter token may deliver this Turn's picks (ADR-0013). */
154
+ sandbox?: string;
127
155
  };
128
156
 
129
157
  /** What the actor is invoked with AFTER jr2Setup finalization: the durable handle, this turn's
@@ -149,6 +177,9 @@ export type AgentRunInput = {
149
177
  */
150
178
  sandbox?: string;
151
179
  prompt?: string;
180
+ /** The other half of this Turn's Frame (ADR-0057): where the Working tools are rooted. Rides the
181
+ * admit body beside the prompt; absent, the Harness keeps `/work`. */
182
+ cwd?: string;
152
183
  /** This turn's dials, passed through from {@link AgentTurnInput}. Plain strings, so they ride
153
184
  * the persisted child input; on restore the Submission already exists server-side with its
154
185
  * model fixed, so a re-attach never re-resolves them. */
@@ -160,20 +191,15 @@ export type AgentRunInput = {
160
191
  */
161
192
  attach?: AgentAdmission;
162
193
  /**
163
- * This invocation is closed to the ADR-0035 reroll — set by the input mapper for
164
- * `session: "continue"` and a `conversation` pin (both name an EXISTING conversation, and
165
- * the runaway's one recovery is a fresh one — exactly what they opted out of), and for a
166
- * caller-passed `instanceId` (fresh on its first invocation, but jr2 did not mint the id and
167
- * must not derive reroll identity from one it does not own — ADR-0016's minting doctrine).
168
- * A gated runaway goes straight to the terminal fault.
194
+ * This invocation CONTINUES an existing conversation — set by the input mapper from
195
+ * `continue: true` alone (ADR-0057), and nothing else. Two things hang off it:
169
196
  *
170
- * A continuation carries NO check that it continues the same persona (ADR-0049, closing
171
- * consequence, open): two Machines that each carry a `coder` slot and pin the same
172
- * `conversation` continue one conversation under two definitions, and the second Turn gets its
173
- * own instructions and Working-tool filter over the first's context. The ledger holds no
174
- * definition to compare against, and adding a digest would refuse an edited-`instructions`
175
- * redeploy too, which ADR-0030 lets continue — so the refusal waits on a persona identity that
176
- * survives a retune.
197
+ * - it is closed to the ADR-0035 reroll: the runaway's one recovery is a fresh conversation,
198
+ * which is exactly what a continuation opted out of, and a continued Turn's prompt
199
+ * ("Continue.") is meaningless replayed fresh. A gated runaway goes straight to the fault;
200
+ * - a terminal `agent.fault` BURNS the conversation (ADR-0057): the epoch bumps in the host
201
+ * ledger, so the next `continue` on this Agent mints a virgin id. Nothing is burned for a
202
+ * fresh invocation, which already mints its own conversation.
177
203
  */
178
204
  continuation?: boolean;
179
205
  /** Event names (from the invoking Machine's vocabulary) this invocation accepts over MCP. */
@@ -231,8 +257,10 @@ export interface AgentRunPort {
231
257
  * Machine edit that changed it. */
232
258
  export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
233
259
 
234
- /** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine). */
235
- export type AgentRunPortFactory = (endpoint: string) => AgentRunPort;
260
+ /** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine),
261
+ * plus the Harness bearer for the placement that input resolved to (ADR-0058) — absent where no
262
+ * placement is named (the stub path) or the host derives none. */
263
+ export type AgentRunPortFactory = (endpoint: string, bearer?: string) => AgentRunPort;
236
264
 
237
265
  /** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
238
266
  export type AgentRunOptions = {
@@ -277,6 +305,50 @@ function nudgePrompt(tools: readonly string[]): string {
277
305
  );
278
306
  }
279
307
 
308
+ /**
309
+ * Where this Turn works — the Frame's other half (ADR-0057), resolved before admission.
310
+ *
311
+ * The state's own `cwd` IS the Frame and wins outright, for every Agent: what is resolved here is
312
+ * an ABSENT one, and a directory a state named is never second-guessed — a Menu-only Agent handed
313
+ * one simply has no Working tools to root there (ADR-0028/0031).
314
+ *
315
+ * Absent, a Menu-only Agent frames no directory; a Workspace with ONE Repo Slot frames that
316
+ * slot's Worktree, which privileges nothing because there is nothing to choose between (ADR-0051);
317
+ * a Workspace with more REFUSES the Turn, naming the slots in declaration order and the line that
318
+ * ends it, because the kit gives no slot a meaning and a wrong guess is the silent failure
319
+ * ADR-0057 was written for; and a run outside any Workspace frames nothing, so the Harness keeps
320
+ * `/work`.
321
+ *
322
+ * Resolution counts SLOTS, and a slot's Worktree is the writable branch checkout. The detached
323
+ * review Worktree (`AmbientHandles.review`, ADR-0028's reviewer seat) is never resolved to: it is
324
+ * per review round, so only the reviewing state knows which one, and it frames it (ADR-0028 hands
325
+ * the reviewer its path as "cwd and prompt"). A `workspace: "read"` Agent that frames nothing
326
+ * under a one-slot Workspace therefore works in the branch checkout with `write`/`edit` withheld
327
+ * and `bash` kept — the tool layer's hint, not containment (ADR-0028): containment is the
328
+ * detached review Worktree, and only a reviewing state that requested one can frame it.
329
+ *
330
+ * The refusal throws at invoke, like the Open-model fence: loudly, at the first Turn, before a
331
+ * surface is registered or a pod is spent.
332
+ */
333
+ function frameCwd(
334
+ instanceId: string,
335
+ input: AgentRunInput,
336
+ workspace: WorkspaceAccess,
337
+ ambient: AmbientHandles | undefined,
338
+ ): string | undefined {
339
+ if (input.cwd !== undefined) return input.cwd;
340
+ if (workspace === "none") return undefined;
341
+ const slots = Object.keys(ambient?.repos ?? {});
342
+ if (slots.length === 0) return undefined;
343
+ if (slots.length === 1) return ambient!.repos[slots[0]!];
344
+ throw new Error(
345
+ `turn ${instanceId}: agent "${input.agentName}" works under a Workspace carrying more than one ` +
346
+ `Repo Slot (${slots.join(", ")}) and its Turn framed no \`cwd\` — say which Worktree it works ` +
347
+ `in (\`cwd: context.workspace.repos.<slot>\`, one of those), because the kit gives no slot a ` +
348
+ `meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)`,
349
+ );
350
+ }
351
+
280
352
  /** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
281
353
  * (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
282
354
  * own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
@@ -285,7 +357,10 @@ function nudgePrompt(tools: readonly string[]): string {
285
357
  * The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
286
358
  * type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
287
359
  * `definition` here would make every fake carry a definition it never uses. */
288
- export type AgentLogic = CallbackActorLogic<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>;
360
+ export type AgentLogic = CallbackActorLogic<
361
+ AgentRunReceiveEvent,
362
+ AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
363
+ >;
289
364
 
290
365
  /**
291
366
  * Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
@@ -313,7 +388,10 @@ export function agentActorWith(
313
388
  // Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
314
389
  // AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
315
390
  // setup() machines must pass the finalized shape themselves — checked loudly below.
316
- const logic = fromCallback<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>((args) => {
391
+ const logic = fromCallback<
392
+ AgentRunReceiveEvent,
393
+ AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
394
+ >((args) => {
317
395
  const { system, self, sendBack, receive } = args;
318
396
  const input = args.input as AgentRunInput;
319
397
  const { instanceId } = input;
@@ -339,6 +417,9 @@ export function agentActorWith(
339
417
  // are unreachable (ADR-0013).
340
418
  const binding = runBindingOf(system);
341
419
  const workspace = definition.workspace ?? "write";
420
+ // The enclosing workspace()'s handles, walked structurally up the actor parent chain — the
421
+ // Harness coordinates below and the Frame's `cwd` both read them (ADR-0013/0016/0057).
422
+ const ambient = ambientHandlesFor(self);
342
423
  let endpoint: string;
343
424
  let sandbox: string | undefined;
344
425
  if (input.endpoint) {
@@ -359,7 +440,6 @@ export function agentActorWith(
359
440
  // Workspace's. The Instance token still may (it is the operator, tokens.ts).
360
441
  sandbox = INSTANCE_HARNESS_SERVICE;
361
442
  } else {
362
- const ambient = ambientHandlesFor(self);
363
443
  if (!ambient?.endpoint) {
364
444
  throw new Error(
365
445
  `turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
@@ -371,6 +451,12 @@ export function agentActorWith(
371
451
  sandbox = input.sandbox ?? ambient.sandbox;
372
452
  }
373
453
 
454
+ // The Frame's other half (ADR-0057): WHERE this Turn works. Resolved HERE, before any surface
455
+ // is registered and before a pod is spent, because the answer is a fact about the run — a
456
+ // Worktree path exists only once a run has a branch, so no definition could have named it.
457
+ const cwd = frameCwd(instanceId, input, workspace, ambient);
458
+ const framed: AgentRunInput = cwd === undefined ? input : { ...input, cwd };
459
+
374
460
  // Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
375
461
  // vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
376
462
  // per-Machine — which errors the run loudly at the invoking state).
@@ -420,7 +506,10 @@ export function agentActorWith(
420
506
  );
421
507
  registerSurface(currentIid);
422
508
 
423
- const client = portFactory(endpoint);
509
+ // The Harness bearer (ADR-0058) is derived from the SAME name the registration records as its
510
+ // delivery scope: the pod hosting this Turn. One name, both directions — the Adapter there may
511
+ // speak for these picks, and only this bearer may drive the conversation there.
512
+ const client = portFactory(endpoint, sandbox === undefined ? undefined : binding.harnessBearer?.(sandbox));
424
513
  const controller = new AbortController();
425
514
  // Shared per run, created on demand so the ordering guarantee holds for any binding.
426
515
  const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
@@ -476,17 +565,23 @@ export function agentActorWith(
476
565
  // Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
477
566
  void (async () => {
478
567
  const fault = (reason: string) => {
479
- if (!stopped) sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
568
+ if (stopped) return;
569
+ // Burn the conversation before the fault lands (ADR-0057): the state the fault routes to
570
+ // may invoke this Agent again in the same macrostep, and its mapper must already read the
571
+ // bumped epoch. Continuations only — a fresh invocation mints its own conversation, so
572
+ // there is nothing to burn.
573
+ if (input.continuation) binding.bumpEpoch?.(continuedIid(self._parent, binding.runId, input.agentName));
574
+ sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
480
575
  };
481
576
  try {
482
577
  // Queue behind any abort still in flight for this iid (ADR-0024). Non-trivial only under
483
- // `session: "continue"`, which is the only way two invocations share an instance id — and
578
+ // `continue: true`, which is the only way two invocations share an instance id — and
484
579
  // there it is mandatory: the Harness queues per conversation and an abort settles what
485
580
  // is queued behind it, so losing this race would kill the new turn before it ran, silently.
486
581
  await pendingAborts.get(instanceId);
487
582
  let admission = input.attach;
488
583
  if (!admission) {
489
- admission = await client.admit(input, { definition, signal: controller.signal });
584
+ admission = await client.admit(framed, { definition, signal: controller.signal });
490
585
  ledger(admission);
491
586
  // The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
492
587
  // continues a Turn already announced, and a nudge (below) is mechanism, not narrative
@@ -521,7 +616,7 @@ export function agentActorWith(
521
616
  currentIid = `${instanceId}-r${rerolls}`;
522
617
  registerSurface(currentIid);
523
618
  admission = await client.admit(
524
- { ...input, attach: undefined, instanceId: currentIid },
619
+ { ...framed, attach: undefined, instanceId: currentIid },
525
620
  { definition, signal: controller.signal },
526
621
  );
527
622
  ledger(admission);
@@ -540,7 +635,7 @@ export function agentActorWith(
540
635
  reason: "no-signal nudge",
541
636
  });
542
637
  admission = await client.admit(
543
- { ...input, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
638
+ { ...framed, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
544
639
  { definition, signal: controller.signal },
545
640
  );
546
641
  ledger(admission);
package/src/agent.ts CHANGED
@@ -30,7 +30,11 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
30
30
  * (ADR-0031): the value the Agent actor reads off its own slot's definition to place the Turn. */
31
31
  export type WorkspaceAccess = "write" | "read" | "none";
32
32
 
33
- /** The plain-data Agent definition (ADR-0018). Must stay JSON-serializable: the definition rides
33
+ /** The plain-data Agent definition (ADR-0018): IDENTITY plus one default. A Turn's Frame — what
34
+ * it is about and where it works — is not here and cannot be (ADR-0057): a Worktree path exists
35
+ * only once a run has a branch, so no definition written before the run could name one.
36
+ *
37
+ * Must stay JSON-serializable: the definition rides
34
38
  * the Turn to the Harness (ADR-0049), so anything non-serializable would be silently lost — grow
35
39
  * this contract deliberately. ADR-0028 added a restriction vocabulary (`workspace`), not an
36
40
  * extension one: custom tool implementations stay out of the contract. */
@@ -45,9 +49,6 @@ export type AgentDefinition = {
45
49
  instructions: string;
46
50
  /** Optional static description — observability, never sent to the model. */
47
51
  description?: string;
48
- /** Working directory inside the Sandbox. Default `/work` — the pod volume the attach step put
49
- * the worktrees on (ADR-0005); override only for non-Workspace layouts. */
50
- cwd?: string;
51
52
  /** Reasoning effort. Omitted → the runtime's default. An invocation may override it for one
52
53
  * Turn (`AgentTurnInput.thinkingLevel`) — effort is a property of the task's difficulty, so the
53
54
  * same persona legitimately runs at different settings in different Machines. */
@@ -56,9 +57,9 @@ export type AgentDefinition = {
56
57
  * not `tools` (that names the control-plane Menu, what it may SAY). `"read"` withholds the
57
58
  * write/edit Working tools; bash stays, so this states intent and stops the honest path — the
58
59
  * detached review worktree is the containment. `"none"` withholds the ENTIRE Working toolset:
59
- * the Menu-only Agent converses and picks from its Menu, nothing else (`cwd` is moot — only
60
- * Working tools consume it) — and places the Turn on the Instance Harness (ADR-0031).
61
- * Default `"write"`. */
60
+ * the Menu-only Agent converses and picks from its Menu, nothing else (so its Turn frames no
61
+ * directory — only Working tools consume one) — and places the Turn on the Instance Harness
62
+ * (ADR-0031). Default `"write"`. */
62
63
  workspace?: WorkspaceAccess;
63
64
  };
64
65
 
package/src/gate.ts CHANGED
@@ -46,7 +46,7 @@ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input
46
46
  const binding = runBindingOf(system);
47
47
  // The derived id is deterministic from machine structure, so recomputing it on every (re)start
48
48
  // is restore-stable and needs no input-mapper persistence — the mapper is only load-bearing
49
- // where minting has a random component (mintIid's fresh-session suffix, ADR-0016).
49
+ // where minting has a random component (mintIid's fresh-Turn suffix, ADR-0016).
50
50
  const path = actorPath(self);
51
51
  const id = input.gate ?? path.join(".");
52
52
  if (!id) {
@@ -49,16 +49,21 @@ import {
49
49
  /** The three-verb wire client (the injectable seam — structurally what `@flue/sdk`'s
50
50
  * `agents.{send,wait,abort}` was, minus the SDK). */
51
51
  export type HarnessClient = {
52
- /** `POST /agents/:name/:id {message, definition, model?, thinkingLevel?}` → the Admission, with
53
- * `streamUrl` resolved absolute. The DEFINITION is the slot's, carried by the Machine and sent
54
- * with every admission (ADR-0049) — the Harness keeps no roster, so `:name` alone would name
55
- * nothing there. The optional dials are this Submission's override layer (ADR-0018); omitted,
56
- * the Harness runs the definition's own values. */
52
+ /** `POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?}` → the Admission,
53
+ * with `streamUrl` resolved absolute. The FRAME is `message` and `cwd` — what this Turn is about
54
+ * and where it works (ADR-0057), flat siblings on the body. The DEFINITION is the slot's,
55
+ * carried by the Machine and sent with every admission (ADR-0049) — the Harness keeps no roster,
56
+ * so `:name` alone would name nothing there — and it travels as IDENTITY alone, because this
57
+ * verb strips `description` (ADR-0057; see {@link admittedDefinition}). The optional dials are this Submission's override
58
+ * layer (ADR-0018); omitted, the Harness runs the definition's own values. */
57
59
  send(
58
60
  agentName: string,
59
61
  instanceId: string,
60
62
  options: {
61
63
  message: string;
64
+ /** Where the Working tools are rooted for this Turn (ADR-0057). Omitted, the Harness keeps
65
+ * `/work` — the stub-Harness run with no Workspace. */
66
+ cwd?: string;
62
67
  definition: AgentDefinition;
63
68
  model?: string;
64
69
  thinkingLevel?: ThinkingLevel;
@@ -75,6 +80,10 @@ export type HarnessClient = {
75
80
  export type HarnessClientOptions = {
76
81
  /** The Harness base URL — what a workspace publishes, or a stub's `url`. */
77
82
  baseUrl: string;
83
+ /** The Harness bearer for this placement (ADR-0058; `harnessToken` in tokens.ts) — sent on
84
+ * every call, the long-poll included. Absent only where no placement exists: an explicit-
85
+ * `endpoint` run against the stub Harness, which checks nothing. */
86
+ token?: string;
78
87
  /** Injectable for socket-free tests. Default: global `fetch`. */
79
88
  fetch?: typeof fetch;
80
89
  /** Reconnect backoff floor/ceiling (capped exponential, jittered per rung — see `jittered`;
@@ -120,6 +129,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
120
129
  const backoffMaxMs = options.backoffMaxMs ?? 5_000;
121
130
  const admitWindowMs = options.admitWindowMs ?? 90_000;
122
131
  const log = options.log ?? ((line: string) => console.warn(line));
132
+ const authorization: Record<string, string> = options.token ? { authorization: `Bearer ${options.token}` } : {};
123
133
 
124
134
  const conversationUrl = (agentName: string, instanceId: string) =>
125
135
  new URL(`/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, baseUrl).toString();
@@ -187,12 +197,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
187
197
  url,
188
198
  {
189
199
  method: "POST",
190
- headers: { "content-type": "application/json" },
200
+ headers: { "content-type": "application/json", ...authorization },
191
201
  body: JSON.stringify({
192
202
  message: sendOptions.message,
203
+ // The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
204
+ // UNSET, and the Harness keeps `/work`; anything else — including the empty string a
205
+ // miscomputed worktree path would produce — travels, because a `cwd` the Harness
206
+ // cannot read as an absolute path is a 400 there. Dropping it here would hand that
207
+ // Turn the `/work` default instead: the silent wrong directory ADR-0057 exists for.
208
+ ...(sendOptions.cwd === undefined ? {} : { cwd: sendOptions.cwd }),
193
209
  // The Agent this Turn runs (ADR-0049) — plain data, validated at admission by the
194
210
  // Harness, which 400s naming the slot rather than settling a Submission `failed`.
195
- definition: sendOptions.definition,
211
+ // IDENTITY only: `description` is stripped here (ADR-0057). It is Console material —
212
+ // `machine-doc.ts` and the surface read it — and the Harness neither validates nor
213
+ // uses it, so it has no business on the Turn's wire.
214
+ definition: admittedDefinition(sendOptions.definition),
196
215
  // Omitted when unset, so an admission with no dials is byte-identical to before.
197
216
  ...(sendOptions.model ? { model: sendOptions.model } : {}),
198
217
  ...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
@@ -224,13 +243,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
224
243
  let events: StreamEvent[];
225
244
  let nextOffset: string | null;
226
245
  try {
227
- const res = await fetchImpl(url.toString(), { signal });
246
+ const res = await fetchImpl(url.toString(), { headers: authorization, signal });
228
247
  if (res.status === 404) {
229
248
  throw new SettlementFault(
230
249
  `conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
231
250
  `a conversation lives as long as its Harness process (ADR-0027)`,
232
251
  );
233
252
  }
253
+ // Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
254
+ // rejects it on every retry — reconnecting would park the Turn forever, silently.
255
+ if (res.status === 401 || res.status === 403) {
256
+ throw new SettlementFault(
257
+ `harness refused the stream read (${res.status}) for submission "${admission.submissionId}": ` +
258
+ `${await errorDetail(res)} — the bearer is not this placement's (ADR-0058)`,
259
+ );
260
+ }
234
261
  if (!res.ok && res.status !== 204) {
235
262
  throw new ReconnectableError(`harness stream read failed (${res.status})`);
236
263
  }
@@ -260,6 +287,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
260
287
  async abort(agentName, instanceId, opts) {
261
288
  const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
262
289
  method: "POST",
290
+ headers: authorization,
263
291
  signal: opts?.signal,
264
292
  });
265
293
  if (!res.ok) {
@@ -271,6 +299,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
271
299
  };
272
300
  }
273
301
 
302
+ /** The definition as the ADMISSION carries it (ADR-0057): identity alone. `description` is
303
+ * Console material the Orchestrator strips before admission — the Harness has no use for it and
304
+ * no longer validates it. Every other field travels, so a field added to the definition reaches
305
+ * the Harness without a change here. */
306
+ function admittedDefinition(definition: AgentDefinition): Omit<AgentDefinition, "description"> {
307
+ const { description: _description, ...identity } = definition;
308
+ return identity;
309
+ }
310
+
274
311
  /** Build an `AgentRunPort` over an injected wire client. Stateless — the admission IS the handle. */
275
312
  export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
276
313
  return {
@@ -280,6 +317,10 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
280
317
  }
281
318
  return await client.send(input.agentName, input.instanceId, {
282
319
  message: input.prompt,
320
+ // The Frame's other half (ADR-0057) — absent on a workspace-less run, where `/work` is
321
+ // what the Harness keeps. Absent means UNSET: a cwd the mapper computed travels as it is,
322
+ // to the Harness's refusal if it is not a path.
323
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
283
324
  definition: opts.definition,
284
325
  model: input.model,
285
326
  thinkingLevel: input.thinkingLevel,
@@ -307,16 +348,16 @@ export function createHarnessAgentRunClient(options: HarnessClientOptions): Agen
307
348
 
308
349
  /**
309
350
  * The run-narrative echo push (ADR-0023): `POST /echo { events }` against one Workspace Harness,
310
- * bearing the Instance token (the endpoint is instance-token-gated — the Harness verifies the
311
- * bearer against the token's sha-256, never holding the token itself). The payload is the
351
+ * bearing that placement's Harness bearer (ADR-0058 — the Harness verifies it against a sha-256,
352
+ * never holding it at rest). The payload is the
312
353
  * STRUCTURED feed events; the Harness renders. A non-OK answer rejects, and the CALLER treats
313
354
  * that as log-and-continue — fire-and-forget lives in the tee (run-host.ts), not here, so a test
314
355
  * can still assert a push failed.
315
356
  */
316
357
  export function createEchoPush(options: {
317
358
  baseUrl: string;
318
- /** The Instance token — the echo bearer. */
319
- token: string;
359
+ /** The Workspace's Harness bearer (ADR-0058). Absent only against the stub Harness. */
360
+ token?: string;
320
361
  /** Injectable for socket-free tests. Default: global `fetch`. */
321
362
  fetch?: typeof fetch;
322
363
  }): (events: EchoEvent[]) => Promise<void> {
@@ -329,7 +370,10 @@ export function createEchoPush(options: {
329
370
  // `fetch failed` in the pod log is what let that condition hide.
330
371
  const res = await fetchImpl(url, {
331
372
  method: "POST",
332
- headers: { "content-type": "application/json", authorization: `Bearer ${options.token}` },
373
+ headers: {
374
+ "content-type": "application/json",
375
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
376
+ },
333
377
  body: JSON.stringify({ events }),
334
378
  }).catch((err: unknown) => {
335
379
  throw new Error(`harness echo to ${url} failed: ${transportDetail(err)}`, { cause: err });
@@ -359,7 +403,10 @@ export function createEchoPush(options: {
359
403
  * still an `AgentDefinition`: the actor narrows on start and refuses an Open one there.
360
404
  */
361
405
  export function agent(declaration: AgentDeclaration): AgentLogic {
362
- return agentActorWith((endpoint) => createHarnessAgentRunClient({ baseUrl: endpoint }), declaration);
406
+ return agentActorWith(
407
+ (endpoint, bearer) => createHarnessAgentRunClient({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
408
+ declaration,
409
+ );
363
410
  }
364
411
 
365
412
  /** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
package/src/http.ts CHANGED
@@ -27,9 +27,10 @@
27
27
  // control.
28
28
  // authenticated any principal we minted a token for. The Agent's surface lives here, scoped
29
29
  // further per-registration by `mayDeliverToAgent`.
30
- // instanceOnly the Instance token ALONE. Run control and full run state (`/runs*`): a Sandbox
31
- // token authenticates but is refused, because reading another feature's context or
32
- // cancelling a run is not on the Agent's surface any more than a Gate is.
30
+ // instanceOnly the Instance token ALONE. Run control — starting a run included (ADR-0058) — and
31
+ // full run state (`/runs*`): a Sandbox token authenticates but is refused, because
32
+ // starting work, reading another feature's context, or cancelling a run is not on
33
+ // the Agent's surface any more than a Gate is.
33
34
 
34
35
  import { readFile, stat } from "node:fs/promises";
35
36
  import { createRequire } from "node:module";
@@ -356,8 +357,10 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
356
357
  // Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
357
358
  // A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
358
359
  // shape — the same error class, and the same wire mapping, as a gate delivery failing its
359
- // schema (`POST /runs/:id/gates/:gate/events` below).
360
- app.post("/workflows/:name/runs", authenticated, async (c) => {
360
+ // schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
361
+ // run control, and a Sandbox token's scope is its own agent surface — no Adapter forwarding this
362
+ // route is what keeps an Agent from starting runs today, and that is not a control.
363
+ app.post("/workflows/:name/runs", instanceOnly, async (c) => {
361
364
  const name = c.req.param("name");
362
365
  const input = await readJson(c.req.text());
363
366
  try {