@runuai/host 0.9.6 → 0.9.7

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.
@@ -0,0 +1,4 @@
1
+ -- Durable runners otherwise attach by task + agent alone. Persist the
2
+ -- host-owned launch-policy generation so a rolling policy/tool transition can
3
+ -- replace an incompatible live runner instead of silently inheriting it.
4
+ ALTER TABLE `host_agent_sessions` ADD `attach_compatibility_key` text;
@@ -85,6 +85,13 @@
85
85
  "when": 1779900012000,
86
86
  "tag": "0011_codex_thread_id",
87
87
  "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "6",
92
+ "when": 1779900013000,
93
+ "tag": "0012_agent_session_attach_key",
94
+ "breakpoints": true
88
95
  }
89
96
  ]
90
- }
97
+ }
package/db/schema.ts CHANGED
@@ -140,6 +140,10 @@ export const hostAgentSessions = sqliteTable(
140
140
  sessionDir: text("session_dir").notNull(),
141
141
  containerName: text("container_name").notNull(),
142
142
  kind: text("kind").notNull(), // "claude" | "codex" | ...
143
+ // Attach only when the process was launched under the same host-owned
144
+ // policy generation. Null preserves legacy attach behavior for ordinary
145
+ // agents; Secretary transitions use an explicit key.
146
+ attachCompatibilityKey: text("attach_compatibility_key"),
143
147
  outboxOffset: integer("outbox_offset", { mode: "number" })
144
148
  .notNull()
145
149
  .default(0),
package/lib/agent-cli.ts CHANGED
@@ -66,15 +66,23 @@ export function apiUrlFromCloudUrl(cloudUrl: string | undefined): string | null
66
66
  * Write the CLI script + shared config (apiUrl only — NO token) into the task
67
67
  * workspace. Each agent's OWN token is injected per-agent via its docker exec
68
68
  * env (agentCliEnv), so no shared file ever holds a token. Best-effort +
69
- * idempotent. No-op (false) if there's no api url or the roster has zero
70
- * permissions nothing the CLI could do.
69
+ * idempotent. No-op (false) if there's no api url, or if the roster has zero
70
+ * permissions and the task has no permissionless role action (Secretary
71
+ * dispatch). The latter is explicit rather than globally minting empty-power
72
+ * tokens for every task.
71
73
  */
72
74
  export function writeAgentCli(
73
75
  taskId: string,
74
76
  roster: RosterAgent[],
75
77
  apiUrl: string | null,
78
+ allowPermissionless = false,
76
79
  ): boolean {
77
- if (!apiUrl || rosterPermissions(roster).length === 0) return false;
80
+ if (
81
+ !apiUrl ||
82
+ (rosterPermissions(roster).length === 0 && !allowPermissionless)
83
+ ) {
84
+ return false;
85
+ }
78
86
  try {
79
87
  const dir = resolve(taskWorkspaceDir(taskId), ".uai");
80
88
  mkdirSync(dir, { recursive: true });
@@ -95,8 +103,10 @@ export function writeAgentCli(
95
103
  /**
96
104
  * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
97
105
  * task token (carrying only ITS permissions) + the API url. Empty when the agent
98
- * has no permissions (or no owner/url) — its `uai` CLI then reports "not
99
- * available" rather than borrowing anyone else's powers.
106
+ * has no permissions (or no owner/url) — unless `allowPermissionless` is set
107
+ * for the designated Secretary, whose dispatch route is role-gated rather than
108
+ * permission-gated. That token still carries an empty permission set and
109
+ * cannot borrow anyone else's powers.
100
110
  */
101
111
  export function agentCliEnv(
102
112
  taskId: string,
@@ -104,9 +114,17 @@ export function agentCliEnv(
104
114
  ownerUserId: string | null,
105
115
  apiUrl: string | null,
106
116
  cliSecret: string | null,
117
+ allowPermissionless = false,
107
118
  ): Record<string, string> {
108
119
  const permissions = agent.permissions ?? [];
109
- if (!ownerUserId || !apiUrl || !cliSecret || permissions.length === 0) return {};
120
+ if (
121
+ !ownerUserId ||
122
+ !apiUrl ||
123
+ !cliSecret ||
124
+ (permissions.length === 0 && !allowPermissionless)
125
+ ) {
126
+ return {};
127
+ }
110
128
  return {
111
129
  UAI_TASK_TOKEN: signTaskToken(
112
130
  { taskId, userId: ownerUserId, permissions, agentId: agent.id },
@@ -121,7 +139,7 @@ export function agentCliEnv(
121
139
  // file with no dependencies (Node 22 in the image provides global fetch).
122
140
  // ---------------------------------------------------------------------------
123
141
 
124
- const CLI_SOURCE = `#!/usr/bin/env node
142
+ export const CLI_SOURCE = `#!/usr/bin/env node
125
143
  // uai — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
126
144
  import { readFileSync } from "node:fs";
127
145
  import { dirname, join } from "node:path";
@@ -171,6 +189,30 @@ const [group, action, ...rest] = argv;
171
189
  const { flags, rest: pos } = parseFlags(rest);
172
190
 
173
191
  async function main() {
192
+ if (group === "dispatch") {
193
+ const args = [action, ...rest].filter((value) => typeof value === "string");
194
+ const recipientArgs = [];
195
+ while (args.length && /^@[A-Za-z0-9_-]+$/.test(args[0])) {
196
+ recipientArgs.push(args.shift().slice(1));
197
+ }
198
+ const instruction = args.join(" ").trim();
199
+ if (!recipientArgs.length || !instruction) {
200
+ console.error('uai: dispatch needs one or more @agent ids and a quoted instruction');
201
+ process.exit(1);
202
+ }
203
+ const result = await api("POST", "/api/agent/dispatch", {
204
+ to: recipientArgs,
205
+ instruction,
206
+ });
207
+ out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
208
+ if (Array.isArray(result.failedRecipients) && result.failedRecipients.length) {
209
+ console.error(
210
+ "uai: could not wake " +
211
+ result.failedRecipients.map((id) => "@" + id).join(", "),
212
+ );
213
+ }
214
+ return;
215
+ }
174
216
  const key = group + " " + (action || "");
175
217
  switch (key) {
176
218
  case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
@@ -298,6 +340,7 @@ async function main() {
298
340
  " uai task search <query> (your own past tasks)",
299
341
  " uai task history (list your past tasks)",
300
342
  " uai task read <taskId> (full chat of a past task you were on)",
343
+ ' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
301
344
  " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
302
345
  " uai whoami",
303
346
  ].join("\\n"));
@@ -24,6 +24,16 @@ import { newId } from "../ulid";
24
24
  import { createAgentTransport, type LineTransport } from "./transport";
25
25
  import { isRateLimitMessage } from "./rate-limit";
26
26
  import { register } from "./registry";
27
+ import {
28
+ DISPATCH_INPUT_SCHEMA,
29
+ DISPATCH_RECIPIENT_ID_PATTERN,
30
+ DISPATCH_TOOL_NAME,
31
+ MAX_DISPATCH_INSTRUCTION_CHARS,
32
+ MAX_DISPATCH_RECIPIENT_ID_CHARS,
33
+ MAX_DISPATCH_RECIPIENTS,
34
+ parseDispatchInput,
35
+ type DispatchInput,
36
+ } from "./dispatch";
27
37
  import { extractResultUsage } from "./usage";
28
38
  import type {
29
39
  AgentEvent,
@@ -59,6 +69,156 @@ const CLAUDE_DEFAULT_EFFORT = "high";
59
69
  const CLAUDE_COMMUNICATOR_MODEL = "haiku";
60
70
  const CLAUDE_COMMUNICATOR_EFFORT = "low";
61
71
 
72
+ /**
73
+ * ADR-083's one host-owned communicator tool. The server is deliberately
74
+ * dependency-free and rides in the adapter argv so an updated host can add it
75
+ * to an already-built task image. It performs no routing itself: Claude's
76
+ * structured tool_use block is the durable runner output, and ClaudeSession
77
+ * correlates its successful tool_result into the typed AgentEvent the
78
+ * orchestrator forwards.
79
+ *
80
+ * Keeping the MCP server a constant also keeps the strict config independent
81
+ * of project/user MCP files. The communicator gets exactly this server, never
82
+ * the task's arbitrary extension set.
83
+ */
84
+ export const CLAUDE_DISPATCH_TOOL = `mcp__uai__${DISPATCH_TOOL_NAME}`;
85
+
86
+ export const CLAUDE_DISPATCH_MCP_SOURCE = String.raw`
87
+ import { createInterface } from "node:readline";
88
+
89
+ const RECIPIENT_ID = new RegExp(${JSON.stringify(DISPATCH_RECIPIENT_ID_PATTERN)});
90
+
91
+ const TOOL = {
92
+ name: "dispatch",
93
+ title: "Dispatch work to Uai crew agents",
94
+ description:
95
+ "Send one concrete instruction to one or more Uai crew agents. Use this instead of naming agents in prose; prose stays in the normal human-facing lane.",
96
+ inputSchema: ${JSON.stringify(DISPATCH_INPUT_SCHEMA)},
97
+ };
98
+
99
+ const write = (value) => process.stdout.write(JSON.stringify(value) + "\n");
100
+ const ok = (id, result) => write({ jsonrpc: "2.0", id, result });
101
+ const fail = (id, code, message) =>
102
+ write({ jsonrpc: "2.0", id, error: { code, message } });
103
+ const validArguments = (value) =>
104
+ value &&
105
+ typeof value === "object" &&
106
+ !Array.isArray(value) &&
107
+ Array.isArray(value.recipients) &&
108
+ value.recipients.length > 0 &&
109
+ value.recipients.length <= ${MAX_DISPATCH_RECIPIENTS} &&
110
+ value.recipients.every(
111
+ (recipient) =>
112
+ typeof recipient === "string" &&
113
+ recipient.length <= ${MAX_DISPATCH_RECIPIENT_ID_CHARS} &&
114
+ RECIPIENT_ID.test(recipient),
115
+ ) &&
116
+ new Set(value.recipients).size === value.recipients.length &&
117
+ typeof value.instruction === "string" &&
118
+ value.instruction.trim().length > 0 &&
119
+ value.instruction.length <= ${MAX_DISPATCH_INSTRUCTION_CHARS};
120
+
121
+ createInterface({ input: process.stdin, crlfDelay: Infinity }).on(
122
+ "line",
123
+ (line) => {
124
+ let message;
125
+ try {
126
+ message = JSON.parse(line);
127
+ } catch {
128
+ return;
129
+ }
130
+ if (!message || typeof message !== "object" || !("id" in message)) return;
131
+ if (message.method === "initialize") {
132
+ ok(message.id, {
133
+ protocolVersion: message.params?.protocolVersion ?? "2025-06-18",
134
+ capabilities: { tools: {} },
135
+ serverInfo: { name: "uai-dispatch", version: "1" },
136
+ });
137
+ return;
138
+ }
139
+ if (message.method === "ping") {
140
+ ok(message.id, {});
141
+ return;
142
+ }
143
+ if (message.method === "tools/list") {
144
+ ok(message.id, { tools: [TOOL] });
145
+ return;
146
+ }
147
+ if (message.method === "tools/call") {
148
+ if (
149
+ message.params?.name !== "dispatch" ||
150
+ !validArguments(message.params?.arguments)
151
+ ) {
152
+ ok(message.id, {
153
+ content: [{ type: "text", text: "Dispatch rejected: provide 1-${MAX_DISPATCH_RECIPIENTS} unique recipient ids and a non-empty instruction." }],
154
+ isError: true,
155
+ });
156
+ return;
157
+ }
158
+ ok(message.id, {
159
+ content: [{ type: "text", text: "Dispatch request accepted by the host; Uai will validate recipients and delivery." }],
160
+ });
161
+ return;
162
+ }
163
+ fail(message.id, -32601, "Method not found");
164
+ },
165
+ );
166
+ `;
167
+
168
+ const CLAUDE_DISPATCH_MCP_CONFIG = JSON.stringify({
169
+ mcpServers: {
170
+ uai: {
171
+ command: "node",
172
+ args: ["--input-type=module", "--eval", CLAUDE_DISPATCH_MCP_SOURCE],
173
+ // The trusted bridge needs no task/provider credentials. Override the
174
+ // secrets the host intentionally forwards to Claude instead of enabling
175
+ // Claude's global subprocess scrub, which hard-requires bubblewrap and
176
+ // makes the CLI abort in existing task images that do not contain it.
177
+ env: {
178
+ CLAUDE_CODE_OAUTH_TOKEN: "",
179
+ ANTHROPIC_API_KEY: "",
180
+ ANTHROPIC_AUTH_TOKEN: "",
181
+ UAI_TASK_TOKEN: "",
182
+ },
183
+ // Claude defers MCP schemas by default. Dispatch is a core action named
184
+ // in the preamble, so it must be connected and visible on prompt one.
185
+ alwaysLoad: true,
186
+ },
187
+ },
188
+ });
189
+
190
+ const CLAUDE_COMMUNICATOR_SETTINGS = JSON.stringify({
191
+ disableAllHooks: true,
192
+ enabledPlugins: {},
193
+ });
194
+
195
+ /**
196
+ * `--safe-mode` cannot be used here: Claude 2.1.220 disables even an explicit
197
+ * `--mcp-config` in safe mode, which makes the host-owned dispatch tool
198
+ * disappear. Recreate the customization lockdown while leaving that one
199
+ * strict MCP server available. These values override any agent/account env.
200
+ */
201
+ const CLAUDE_COMMUNICATOR_ENV: Record<string, string> = {
202
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
203
+ CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
204
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
205
+ CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1",
206
+ CLAUDE_CODE_DISABLE_POLICY_SKILLS: "1",
207
+ CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
208
+ CLAUDE_CODE_SKIP_PLUGIN_MCP_SERVERS: "1",
209
+ CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1",
210
+ ENABLE_CLAUDEAI_MCP_SERVERS: "false",
211
+ // Some hosts export this globally. Claude's current implementation aborts
212
+ // when it is enabled but the task image has no bubblewrap binary.
213
+ CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "",
214
+ // The enforced profile dispatches through its host-owned MCP event, not the
215
+ // task CLI. Keep the bearer token and API origin out of the Claude process
216
+ // itself (not merely its MCP child), where Read could otherwise inspect
217
+ // /proc/self/environ and recover a permission-null task credential.
218
+ UAI_TASK_TOKEN: "",
219
+ UAI_API_URL: "",
220
+ };
221
+
62
222
  // ---------------------------------------------------------------------------
63
223
  // Pure protocol mapping — stream-json line → AgentEvent[].
64
224
  // ---------------------------------------------------------------------------
@@ -68,6 +228,15 @@ function isObj(v: unknown): v is Record<string, unknown> {
68
228
  return typeof v === "object" && v !== null;
69
229
  }
70
230
 
231
+ function parseLineObject(raw: string): Record<string, unknown> | null {
232
+ try {
233
+ const value: unknown = JSON.parse(raw);
234
+ return isObj(value) ? value : null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
71
240
  /**
72
241
  * Map one stream-json stdout line to zero or more AgentEvents.
73
242
  *
@@ -82,13 +251,8 @@ function isObj(v: unknown): v is Record<string, unknown> {
82
251
  * side-effect free.
83
252
  */
84
253
  export function mapClaudeLine(raw: string): AgentEvent[] {
85
- let json: unknown;
86
- try {
87
- json = JSON.parse(raw);
88
- } catch {
89
- return [];
90
- }
91
- if (!isObj(json)) return [];
254
+ const json = parseLineObject(raw);
255
+ if (!json) return [];
92
256
 
93
257
  const type = json.type;
94
258
 
@@ -114,6 +278,12 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
114
278
  for (const block of content) {
115
279
  if (isObj(block) && block.type === "tool_use") {
116
280
  const name = typeof block.name === "string" ? block.name : "tool";
281
+ if (name === CLAUDE_DISPATCH_TOOL) {
282
+ // Dispatch is intentionally absent here. Claude emits this assistant
283
+ // block before it calls the MCP server; ClaudeSession correlates the
284
+ // later tool_result and emits only after the call succeeds.
285
+ continue;
286
+ }
117
287
  out.push({
118
288
  type: "tool_call",
119
289
  id: typeof block.id === "string" ? block.id : newId(),
@@ -221,24 +391,29 @@ const CLAUDE_FULL_ACCESS_ARGS = [
221
391
 
222
392
  /**
223
393
  * ADR-083 communicator profile. This is deliberately an engine-level tool
224
- * boundary, not prompt advice: safe mode suppresses project/user extensions,
225
- * the explicit tool set contains no shell or mutation primitive, `dontAsk`
226
- * denies anything outside it in headless mode, and the empty strict MCP config
227
- * prevents a user-installed server from reintroducing a write/deploy tool.
394
+ * boundary, not prompt advice: setting sources, hooks, skills, workflows and
395
+ * plug-in MCP are suppressed; the explicit tool set contains no shell or
396
+ * mutation primitive; `dontAsk` denies anything outside it; and strict MCP
397
+ * exposes only Uai's host-owned dispatch tool.
228
398
  */
229
399
  const CLAUDE_COMMUNICATOR_ARGS = [
230
400
  ...CLAUDE_BASE_ARGS,
231
- "--safe-mode",
401
+ "--setting-sources",
402
+ "",
403
+ "--settings",
404
+ CLAUDE_COMMUNICATOR_SETTINGS,
232
405
  "--disable-slash-commands",
233
406
  "--no-chrome",
234
407
  "--permission-mode",
235
408
  "dontAsk",
236
409
  "--tools",
237
410
  "Read,Glob,Grep",
411
+ "--allowedTools",
412
+ CLAUDE_DISPATCH_TOOL,
238
413
  "--disallowedTools",
239
414
  "Bash,Edit,Write,NotebookEdit,Agent,Task,WebFetch,WebSearch",
240
415
  "--mcp-config",
241
- '{"mcpServers":{}}',
416
+ CLAUDE_DISPATCH_MCP_CONFIG,
242
417
  "--strict-mcp-config",
243
418
  ];
244
419
 
@@ -248,6 +423,8 @@ export class ClaudeSession implements AgentSession {
248
423
 
249
424
  private readonly proc: LineTransport;
250
425
  private readonly handlers = new Set<AgentEventHandler>();
426
+ private readonly pendingDispatches = new Map<string, DispatchInput>();
427
+ private readonly settledDispatches = new Set<string>();
251
428
  private closed = false;
252
429
  // Claude's `total_cost_usd` is CUMULATIVE across the persistent session
253
430
  // process (ADR-061 keeps one claude alive across turns), so per-turn cost is
@@ -263,6 +440,7 @@ export class ClaudeSession implements AgentSession {
263
440
  containerName: string;
264
441
  systemPreamble: string;
265
442
  executionProfile?: "communicator";
443
+ attachCompatibilityKey?: string;
266
444
  agentEnv?: Record<string, string>;
267
445
  }) {
268
446
  this.agentId = args.agent.id;
@@ -308,10 +486,11 @@ export class ClaudeSession implements AgentSession {
308
486
  // ADR-061: durable by default — the CLI is owned by an in-container
309
487
  // runner and survives host restarts (attach resumes it); legacy pipes
310
488
  // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
311
- // live runner can be re-attached (allowAttach). The communicator profile
312
- // is the exception: attach is keyed only by task + agent identity, not by
313
- // execution profile, so a pre-existing full-access runner must be stopped
314
- // and replaced rather than inherited across this security boundary.
489
+ // live runner can be re-attached (allowAttach). A communicator profile is
490
+ // never attached, and the orchestrator also supplies a persisted policy
491
+ // compatibility key for normal-tools Secretaries. That second fence makes
492
+ // the reverse rolling upgrade safe: an old restricted runner cannot be
493
+ // inherited after the default policy changes back to normal tools.
315
494
  this.proc = createAgentTransport({
316
495
  taskId: args.taskId,
317
496
  agentId: this.agentId,
@@ -319,12 +498,17 @@ export class ClaudeSession implements AgentSession {
319
498
  cli: "claude",
320
499
  cliArgs,
321
500
  passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
322
- explicitEnv: args.agentEnv ?? {},
501
+ explicitEnv:
502
+ args.executionProfile === "communicator"
503
+ ? { ...(args.agentEnv ?? {}), ...CLAUDE_COMMUNICATOR_ENV }
504
+ : (args.agentEnv ?? {}),
323
505
  allowAttach: args.executionProfile !== "communicator",
506
+ attachCompatibilityKey: args.attachCompatibilityKey,
324
507
  kind: "claude",
325
508
  debugLabel: `claude:${this.agentId}`,
326
509
  });
327
510
  this.proc.onLine((line) => {
511
+ for (const event of this.dispatchEventsForLine(line)) this.emit(event);
328
512
  for (const event of mapClaudeLine(line)) this.emit(this.perTurnCost(event));
329
513
  });
330
514
  this.proc.onExit((code) => {
@@ -349,6 +533,7 @@ export class ClaudeSession implements AgentSession {
349
533
  : " — no stderr. Is the claude CLI installed in the task container, and is the container running?"),
350
534
  });
351
535
  }
536
+ this.clearDispatches();
352
537
  this.closed = true;
353
538
  this.emit({ type: "exit", code: code ?? -1 });
354
539
  });
@@ -364,6 +549,73 @@ export class ClaudeSession implements AgentSession {
364
549
  for (const h of this.handlers) h(event);
365
550
  }
366
551
 
552
+ /**
553
+ * Claude writes an assistant tool_use before invoking MCP, then a user
554
+ * tool_result carrying the same id. Waking crew on the first line would turn
555
+ * a rejected or failed tool call into real work, so hold validated input
556
+ * until the matching result succeeds. The settled set makes replayed output
557
+ * idempotent for the remainder of the turn.
558
+ */
559
+ private dispatchEventsForLine(raw: string): AgentEvent[] {
560
+ const json = parseLineObject(raw);
561
+ if (!json) return [];
562
+
563
+ if (json.type === "assistant" && isObj(json.message)) {
564
+ const content = json.message.content;
565
+ if (!Array.isArray(content)) return [];
566
+ for (const block of content) {
567
+ if (
568
+ !isObj(block) ||
569
+ block.type !== "tool_use" ||
570
+ block.name !== CLAUDE_DISPATCH_TOOL ||
571
+ typeof block.id !== "string" ||
572
+ this.settledDispatches.has(block.id)
573
+ ) {
574
+ continue;
575
+ }
576
+ const input = parseDispatchInput(block.input);
577
+ if (input) this.pendingDispatches.set(block.id, input);
578
+ else this.pendingDispatches.delete(block.id);
579
+ }
580
+ return [];
581
+ }
582
+
583
+ if (json.type === "user" && isObj(json.message)) {
584
+ const content = json.message.content;
585
+ if (!Array.isArray(content)) return [];
586
+ const events: AgentEvent[] = [];
587
+ for (const block of content) {
588
+ if (
589
+ !isObj(block) ||
590
+ block.type !== "tool_result" ||
591
+ typeof block.tool_use_id !== "string"
592
+ ) {
593
+ continue;
594
+ }
595
+ const pending = this.pendingDispatches.get(block.tool_use_id);
596
+ if (!pending) continue;
597
+ this.pendingDispatches.delete(block.tool_use_id);
598
+ this.settledDispatches.add(block.tool_use_id);
599
+ if (block.is_error !== true) {
600
+ events.push({
601
+ type: "dispatch",
602
+ dispatchId: block.tool_use_id,
603
+ ...pending,
604
+ });
605
+ }
606
+ }
607
+ return events;
608
+ }
609
+
610
+ if (json.type === "result") this.clearDispatches();
611
+ return [];
612
+ }
613
+
614
+ private clearDispatches(): void {
615
+ this.pendingDispatches.clear();
616
+ this.settledDispatches.clear();
617
+ }
618
+
367
619
  /** Convert Claude's cumulative session cost to this turn's delta. Tokens are
368
620
  * already per-turn and pass through untouched. */
369
621
  private perTurnCost(event: AgentEvent): AgentEvent {
@@ -424,6 +676,7 @@ export class ClaudeSession implements AgentSession {
424
676
  return;
425
677
  }
426
678
  this.closed = true;
679
+ this.clearDispatches();
427
680
  await this.proc.close();
428
681
  this.emit({ type: "exit", code: 0 });
429
682
  this.handlers.clear();
@@ -442,7 +695,7 @@ register({
442
695
  executionProfiles: [
443
696
  {
444
697
  id: "communicator",
445
- mechanism: "claude-safe-mode-tool-allowlist-v1",
698
+ mechanism: "claude-explicit-tool-boundary-v2",
446
699
  defaultModel: CLAUDE_COMMUNICATOR_MODEL,
447
700
  defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
448
701
  },
@@ -461,6 +714,7 @@ register({
461
714
  containerName,
462
715
  systemPreamble,
463
716
  executionProfile,
717
+ attachCompatibilityKey,
464
718
  agentEnv,
465
719
  }) =>
466
720
  new ClaudeSession({
@@ -469,6 +723,7 @@ register({
469
723
  containerName,
470
724
  systemPreamble,
471
725
  executionProfile,
726
+ attachCompatibilityKey,
472
727
  agentEnv,
473
728
  }),
474
729
  });
@@ -0,0 +1,78 @@
1
+ /** Shared typed-dispatch contract for adapters that expose ADR-083's action. */
2
+
3
+ import {
4
+ MAX_AGENT_ID_CHARS,
5
+ MAX_SECRETARY_DISPATCH_INSTRUCTION_CHARS,
6
+ MAX_SECRETARY_DISPATCH_RECIPIENTS,
7
+ } from "@runuai/host/protocol";
8
+
9
+ export const DISPATCH_TOOL_NAME = "dispatch";
10
+ export const MAX_DISPATCH_RECIPIENTS = MAX_SECRETARY_DISPATCH_RECIPIENTS;
11
+ export const MAX_DISPATCH_RECIPIENT_ID_CHARS =
12
+ MAX_AGENT_ID_CHARS;
13
+ export const MAX_DISPATCH_INSTRUCTION_CHARS =
14
+ MAX_SECRETARY_DISPATCH_INSTRUCTION_CHARS;
15
+ export const DISPATCH_RECIPIENT_ID_PATTERN = "^[A-Za-z0-9_-]+$";
16
+
17
+ export const DISPATCH_INPUT_SCHEMA = {
18
+ type: "object",
19
+ properties: {
20
+ recipients: {
21
+ type: "array",
22
+ minItems: 1,
23
+ maxItems: MAX_DISPATCH_RECIPIENTS,
24
+ uniqueItems: true,
25
+ items: {
26
+ type: "string",
27
+ minLength: 1,
28
+ maxLength: MAX_DISPATCH_RECIPIENT_ID_CHARS,
29
+ pattern: DISPATCH_RECIPIENT_ID_PATTERN,
30
+ },
31
+ description:
32
+ "Exact @ids of the crew agents that should receive the instruction, without the @ prefix.",
33
+ },
34
+ instruction: {
35
+ type: "string",
36
+ minLength: 1,
37
+ maxLength: MAX_DISPATCH_INSTRUCTION_CHARS,
38
+ description:
39
+ "Self-contained backstage instruction. Include all context the recipients need; do not include prose meant only for the human.",
40
+ },
41
+ },
42
+ required: ["recipients", "instruction"],
43
+ additionalProperties: false,
44
+ } as const;
45
+
46
+ export interface DispatchInput {
47
+ recipients: string[];
48
+ instruction: string;
49
+ }
50
+
51
+ const recipientId = new RegExp(DISPATCH_RECIPIENT_ID_PATTERN);
52
+
53
+ export function parseDispatchInput(value: unknown): DispatchInput | null {
54
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
55
+ return null;
56
+ }
57
+ const record = value as Record<string, unknown>;
58
+ const recipients = record.recipients;
59
+ const instruction = record.instruction;
60
+ if (
61
+ !Array.isArray(recipients) ||
62
+ recipients.length === 0 ||
63
+ recipients.length > MAX_DISPATCH_RECIPIENTS ||
64
+ !recipients.every(
65
+ (recipient): recipient is string =>
66
+ typeof recipient === "string" &&
67
+ recipient.length <= MAX_DISPATCH_RECIPIENT_ID_CHARS &&
68
+ recipientId.test(recipient),
69
+ ) ||
70
+ new Set(recipients).size !== recipients.length ||
71
+ typeof instruction !== "string" ||
72
+ instruction.trim().length === 0 ||
73
+ instruction.length > MAX_DISPATCH_INSTRUCTION_CHARS
74
+ ) {
75
+ return null;
76
+ }
77
+ return { recipients, instruction };
78
+ }
@@ -55,3 +55,10 @@ export function resolveAgentMode(
55
55
  export function agentMode(): AgentMode {
56
56
  return resolveAgentMode().mode;
57
57
  }
58
+
59
+ /** Echo/mock sessions cannot execute the in-task structured-dispatch CLI. */
60
+ export function canAdvertiseTypedSecretaryDispatch(
61
+ raw: string | undefined = process.env.UAI_AGENTS,
62
+ ): boolean {
63
+ return resolveAgentMode(raw).mode === "real";
64
+ }
@@ -59,6 +59,8 @@ export interface AgentTransportOptions {
59
59
  * protocol state can't outlive the host process (codex handshake).
60
60
  */
61
61
  allowAttach: boolean;
62
+ /** Host-owned launch-policy generation. Both absent remains compatible. */
63
+ attachCompatibilityKey?: string;
62
64
  kind: string;
63
65
  debugLabel?: string;
64
66
  }
@@ -139,7 +141,14 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
139
141
  const tailKey = `${opts.taskId}:${opts.agentId}`;
140
142
 
141
143
  // ---- Attach: a previous host process left this agent's runner alive. ----
142
- if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
144
+ if (
145
+ opts.allowAttach &&
146
+ row &&
147
+ row.status === "running" &&
148
+ (row.attachCompatibilityKey ?? null) ===
149
+ (opts.attachCompatibilityKey ?? null) &&
150
+ heartbeatFresh(row.sessionDir)
151
+ ) {
143
152
  const proc = new DurableProcess({
144
153
  hostSessionDir: row.sessionDir,
145
154
  initialOutboxOffset: row.outboxOffset,
@@ -233,6 +242,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
233
242
  sessionDir,
234
243
  containerName: opts.containerName,
235
244
  kind: opts.kind,
245
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
236
246
  outboxOffset: 0,
237
247
  status: "running",
238
248
  createdAt: now,
@@ -244,6 +254,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
244
254
  sessionDir,
245
255
  containerName: opts.containerName,
246
256
  kind: opts.kind,
257
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
247
258
  outboxOffset: 0,
248
259
  status: "running",
249
260
  updatedAt: now,