@runuai/host 0.9.0 → 0.9.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.
@@ -52,6 +52,13 @@ const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
52
52
  // High is the default reasoning level. Update alongside CLAUDE_EFFORTS.
53
53
  const CLAUDE_DEFAULT_EFFORT = "high";
54
54
 
55
+ // ADR-083: cheap defaults apply at the enforcement boundary as well as in the
56
+ // picker. Explicit per-agent choices still win; an API client that omits them
57
+ // must not accidentally run the communicator on Claude's costly account
58
+ // defaults merely because it bypassed the web recommendation button.
59
+ const CLAUDE_COMMUNICATOR_MODEL = "haiku";
60
+ const CLAUDE_COMMUNICATOR_EFFORT = "low";
61
+
55
62
  // ---------------------------------------------------------------------------
56
63
  // Pure protocol mapping — stream-json line → AgentEvent[].
57
64
  // ---------------------------------------------------------------------------
@@ -177,7 +184,7 @@ function safeStringify(v: unknown): string {
177
184
  // The session.
178
185
  // ---------------------------------------------------------------------------
179
186
 
180
- const CLAUDE_ARGS = [
187
+ const CLAUDE_BASE_ARGS = [
181
188
  "--print",
182
189
  "--input-format",
183
190
  "stream-json",
@@ -190,6 +197,10 @@ const CLAUDE_ARGS = [
190
197
  // 400s when a later turn sees a changed thinking block, so keep each
191
198
  // managed stream-json process in-memory only.
192
199
  "--no-session-persistence",
200
+ ];
201
+
202
+ const CLAUDE_FULL_ACCESS_ARGS = [
203
+ ...CLAUDE_BASE_ARGS,
193
204
  // Full tool access, no per-call prompts. Safe here precisely because
194
205
  // a uai task runs in a throwaway, isolated container operating on a
195
206
  // disposable worktree (ADR-001 / ADR-010) — the container *is* the
@@ -208,6 +219,29 @@ const CLAUDE_ARGS = [
208
219
  "--strict-mcp-config",
209
220
  ];
210
221
 
222
+ /**
223
+ * 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.
228
+ */
229
+ const CLAUDE_COMMUNICATOR_ARGS = [
230
+ ...CLAUDE_BASE_ARGS,
231
+ "--safe-mode",
232
+ "--disable-slash-commands",
233
+ "--no-chrome",
234
+ "--permission-mode",
235
+ "dontAsk",
236
+ "--tools",
237
+ "Read,Glob,Grep",
238
+ "--disallowedTools",
239
+ "Bash,Edit,Write,NotebookEdit,Agent,Task,WebFetch,WebSearch",
240
+ "--mcp-config",
241
+ '{"mcpServers":{}}',
242
+ "--strict-mcp-config",
243
+ ];
244
+
211
245
  export class ClaudeSession implements AgentSession {
212
246
  readonly agentId: string;
213
247
  readonly kind: AgentKind = "claude";
@@ -228,6 +262,7 @@ export class ClaudeSession implements AgentSession {
228
262
  agent: RosterAgent;
229
263
  containerName: string;
230
264
  systemPreamble: string;
265
+ executionProfile?: "communicator";
231
266
  agentEnv?: Record<string, string>;
232
267
  }) {
233
268
  this.agentId = args.agent.id;
@@ -236,19 +271,31 @@ export class ClaudeSession implements AgentSession {
236
271
  // project's defaultPrompt) is passed as a real system prompt via
237
272
  // `--append-system-prompt`, so it applies to every turn — not
238
273
  // smuggled into the first user message.
239
- const cliArgs = [...CLAUDE_ARGS];
274
+ const cliArgs = [
275
+ ...(args.executionProfile === "communicator"
276
+ ? CLAUDE_COMMUNICATOR_ARGS
277
+ : CLAUDE_FULL_ACCESS_ARGS),
278
+ ];
240
279
  if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
241
280
  cliArgs.push("--include-partial-messages");
242
281
  }
243
- // The agent's model (when set) selects which Claude model the CLI
244
- // drives. Without it the CLI uses the account default.
245
- if (args.agent.model) {
246
- cliArgs.push("--model", args.agent.model);
282
+ // Explicit task configuration wins; the communicator uses its declared
283
+ // fast/cheap defaults when the task left either choice unspecified.
284
+ const model =
285
+ args.agent.model ??
286
+ (args.executionProfile === "communicator"
287
+ ? CLAUDE_COMMUNICATOR_MODEL
288
+ : undefined);
289
+ if (model) {
290
+ cliArgs.push("--model", model);
247
291
  }
248
- // The agent's effort (when set) selects the CLI reasoning level. Without
249
- // it the CLI uses its own default.
250
- if (args.agent.effort) {
251
- cliArgs.push("--effort", args.agent.effort);
292
+ const effort =
293
+ args.agent.effort ??
294
+ (args.executionProfile === "communicator"
295
+ ? CLAUDE_COMMUNICATOR_EFFORT
296
+ : undefined);
297
+ if (effort) {
298
+ cliArgs.push("--effort", effort);
252
299
  }
253
300
  if (args.systemPreamble.trim().length > 0) {
254
301
  cliArgs.push("--append-system-prompt", args.systemPreamble);
@@ -261,7 +308,10 @@ export class ClaudeSession implements AgentSession {
261
308
  // ADR-061: durable by default — the CLI is owned by an in-container
262
309
  // runner and survives host restarts (attach resumes it); legacy pipes
263
310
  // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
264
- // live runner can be re-attached (allowAttach).
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.
265
315
  this.proc = createAgentTransport({
266
316
  taskId: args.taskId,
267
317
  agentId: this.agentId,
@@ -270,7 +320,7 @@ export class ClaudeSession implements AgentSession {
270
320
  cliArgs,
271
321
  passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
272
322
  explicitEnv: args.agentEnv ?? {},
273
- allowAttach: true,
323
+ allowAttach: args.executionProfile !== "communicator",
274
324
  kind: "claude",
275
325
  debugLabel: `claude:${this.agentId}`,
276
326
  });
@@ -389,6 +439,14 @@ register({
389
439
  defaultModel: CLAUDE_DEFAULT_MODEL,
390
440
  supportedEfforts: () => [...CLAUDE_EFFORTS],
391
441
  defaultEffort: CLAUDE_DEFAULT_EFFORT,
442
+ executionProfiles: [
443
+ {
444
+ id: "communicator",
445
+ mechanism: "claude-safe-mode-tool-allowlist-v1",
446
+ defaultModel: CLAUDE_COMMUNICATOR_MODEL,
447
+ defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
448
+ },
449
+ ],
392
450
  // Usable only when a Claude credential is in the env (injected into task
393
451
  // containers at task-up). Gates advertisement (ADR-044 P2).
394
452
  available: () =>
@@ -397,6 +455,20 @@ register({
397
455
  process.env.ANTHROPIC_API_KEY ||
398
456
  process.env.ANTHROPIC_AUTH_TOKEN,
399
457
  ),
400
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
401
- new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
458
+ create: async ({
459
+ taskId,
460
+ agent,
461
+ containerName,
462
+ systemPreamble,
463
+ executionProfile,
464
+ agentEnv,
465
+ }) =>
466
+ new ClaudeSession({
467
+ taskId,
468
+ agent,
469
+ containerName,
470
+ systemPreamble,
471
+ executionProfile,
472
+ agentEnv,
473
+ }),
402
474
  });
@@ -23,7 +23,7 @@ import "./cursor";
23
23
  import "./opencode";
24
24
 
25
25
  import { agentClisReady } from "../standard-image";
26
- import { factoryFor } from "./registry";
26
+ import { factoryFor, supportsExecutionProfile } from "./registry";
27
27
  import type { AgentSession, AgentSessionFactory } from "./types";
28
28
 
29
29
  /** Cap the wait on agentClisReady so a spawn can never hang forever if the
@@ -54,6 +54,14 @@ export const realAgentFactory: AgentSessionFactory = {
54
54
  `no agent adapter registered for kind "${args.agent.kind}"`,
55
55
  );
56
56
  }
57
+ if (
58
+ args.executionProfile &&
59
+ !supportsExecutionProfile(args.agent.kind, args.executionProfile)
60
+ ) {
61
+ throw new Error(
62
+ `agent adapter "${args.agent.kind}" cannot enforce execution profile "${args.executionProfile}"`,
63
+ );
64
+ }
57
65
  // Don't spawn a CLI until the shared-volume agent CLIs are reconciled:
58
66
  // at boot the CLI auto-upgrade briefly removes then reinstalls codex/claude,
59
67
  // and a resume that races that window dies with "No codex executable found
@@ -32,6 +32,13 @@ export interface RegisteredAdapter {
32
32
  supportedEfforts(): string[];
33
33
  /** Preferred effort when the user doesn't pick one. */
34
34
  defaultEffort?: string;
35
+ /** Restricted profiles this adapter can enforce by construction. */
36
+ executionProfiles?: Array<{
37
+ id: string;
38
+ mechanism: string;
39
+ defaultModel?: string;
40
+ defaultEffort?: string;
41
+ }>;
35
42
  /**
36
43
  * Whether this kind is usable on THIS host right now — i.e. its credentials
37
44
  * are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
@@ -52,6 +59,12 @@ export interface AgentKindCapability {
52
59
  defaultModel?: string;
53
60
  supportedEfforts: string[];
54
61
  defaultEffort?: string;
62
+ executionProfiles?: Array<{
63
+ id: string;
64
+ mechanism: string;
65
+ defaultModel?: string;
66
+ defaultEffort?: string;
67
+ }>;
55
68
  }
56
69
 
57
70
  const adapters = new Map<string, RegisteredAdapter>();
@@ -79,6 +92,15 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
79
92
  return adapter ? { create: adapter.create } : undefined;
80
93
  }
81
94
 
95
+ /** Whether the adapter declares an engine-enforced execution profile. */
96
+ export function supportsExecutionProfile(kind: string, profileId: string): boolean {
97
+ return Boolean(
98
+ adapters
99
+ .get(kind)
100
+ ?.executionProfiles?.some((profile) => profile.id === profileId),
101
+ );
102
+ }
103
+
82
104
  /**
83
105
  * The `agentKinds` capability slice, derived from the registered adapters.
84
106
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
@@ -99,6 +121,11 @@ export function capabilities(): AgentKindCapability[] {
99
121
  if (adapter.defaultEffort !== undefined) {
100
122
  out.defaultEffort = adapter.defaultEffort;
101
123
  }
124
+ if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
125
+ out.executionProfiles = adapter.executionProfiles.map((profile) => ({
126
+ ...profile,
127
+ }));
128
+ }
102
129
  return out;
103
130
  });
104
131
  }
@@ -165,6 +165,9 @@ export interface AgentSessionFactory {
165
165
  containerName: string;
166
166
  /** Initial briefing — project.defaultPrompt — sent on session start. */
167
167
  systemPreamble: string;
168
+ /** Host-derived restricted execution policy (ADR-083). Never supplied by
169
+ * the browser or stored on the roster entry. */
170
+ executionProfile?: "communicator";
168
171
  /** ADR-048: extra per-agent env for the `docker exec` (e.g. this agent's
169
172
  * own UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */
170
173
  agentEnv?: Record<string, string>;
@@ -82,6 +82,9 @@ export type HostEventSubscriber = (event: HostEvent) => void;
82
82
  interface Channel {
83
83
  taskId: string;
84
84
  roster: Roster;
85
+ /** ADR-083: host-derived role assignment for execution-profile enforcement. */
86
+ mode: "open" | "secretary";
87
+ secretaryAgentId?: string;
85
88
  sessions: Map<string, AgentSession>;
86
89
  /** Session-spawn inputs, kept so sessions can be started lazily. */
87
90
  containerName: string;
@@ -299,6 +302,33 @@ export class Orchestrator {
299
302
  // -- channel lifecycle ----------------------------------------------------
300
303
 
301
304
  registerChannelSpec(spec: ChannelEnsureInput): void {
305
+ // Host-side fail closed. The cloud validates this too, but the adapter's
306
+ // advertised communicator guarantee must not depend on every caller
307
+ // spelling the id correctly: an unmatched designation would otherwise
308
+ // make `executionProfileFor` return undefined and spawn a full-access
309
+ // process in a task explicitly marked Secretary.
310
+ if (!hasValidSecretarySelection(spec)) {
311
+ throw new Error(
312
+ "secretary mode requires exactly one roster agent matching secretaryAgentId",
313
+ );
314
+ }
315
+ const channel = this.channels.get(spec.taskId);
316
+ const nextMode = spec.mode === "secretary" ? "secretary" : "open";
317
+ if (
318
+ channel &&
319
+ (channel.mode !== nextMode ||
320
+ (nextMode === "secretary" &&
321
+ channel.secretaryAgentId !== spec.secretaryAgentId))
322
+ ) {
323
+ // v0 makes mode/role creation-time only. More importantly, a live
324
+ // Open→Secretary rewrite cannot be applied as ordinary roster refresh:
325
+ // the designated agent may already be a durable full-access process.
326
+ // Changing these fields without recycling that exact session would
327
+ // advertise a communicator boundary the running process does not have.
328
+ throw new Error(
329
+ "a live channel cannot change secretary mode or designation",
330
+ );
331
+ }
302
332
  this.channelSpecs.set(spec.taskId, spec);
303
333
  if (this.blockedTasks.has(spec.taskId)) return;
304
334
  // ADR-049: the cloud re-sends the spec on every message AND right after a
@@ -306,7 +336,6 @@ export class Orchestrator {
306
336
  // fold the fresh spec in: append new roster agents (their sessions spawn
307
337
  // in the reconcile pass of ensureSessions) and rebuild every preamble so
308
338
  // a later respawn briefs agents with the CURRENT roster + humans.
309
- const channel = this.channels.get(spec.taskId);
310
339
  if (channel) this.refreshChannel(channel, spec);
311
340
  }
312
341
 
@@ -335,6 +364,8 @@ export class Orchestrator {
335
364
  }
336
365
  }
337
366
  channel.humans = spec.humans ?? [];
367
+ channel.mode = spec.mode === "secretary" ? "secretary" : "open";
368
+ channel.secretaryAgentId = spec.secretaryAgentId;
338
369
  channel.browserTesting = spec.browserTesting === true;
339
370
  channel.sharedFiles = spec.sharedFiles ?? "ro";
340
371
  channel.mcpConnections = spec.mcpConnections ?? [];
@@ -358,6 +389,8 @@ export class Orchestrator {
358
389
  channel.humans,
359
390
  channel.browserTesting,
360
391
  channel.sharedFiles,
392
+ spec.mode,
393
+ spec.secretaryAgentId,
361
394
  ),
362
395
  );
363
396
  }
@@ -429,6 +462,8 @@ export class Orchestrator {
429
462
  spec.humans,
430
463
  spec.browserTesting,
431
464
  spec.sharedFiles ?? "ro",
465
+ spec.mode,
466
+ spec.secretaryAgentId,
432
467
  ),
433
468
  );
434
469
  // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
@@ -447,6 +482,8 @@ export class Orchestrator {
447
482
  const channel: Channel = {
448
483
  taskId,
449
484
  roster,
485
+ mode: spec.mode === "secretary" ? "secretary" : "open",
486
+ secretaryAgentId: spec.secretaryAgentId,
450
487
  sessions: new Map(),
451
488
  containerName: `task-${taskId.toLowerCase()}-app-1`,
452
489
  preambles,
@@ -1163,6 +1200,7 @@ export class Orchestrator {
1163
1200
  agent,
1164
1201
  containerName: channel.containerName,
1165
1202
  systemPreamble: channel.preambles.get(agent.id) ?? "",
1203
+ executionProfile: this.executionProfileFor(channel, agent.id),
1166
1204
  agentEnv: this.accountAgentEnv(
1167
1205
  channel,
1168
1206
  agent,
@@ -1186,6 +1224,21 @@ export class Orchestrator {
1186
1224
  }
1187
1225
  }
1188
1226
 
1227
+ /**
1228
+ * The browser stores only the task-level secretary identity. Derive the
1229
+ * restricted adapter profile here so a roster entry can never self-assert a
1230
+ * weaker/stronger execution policy through its JSON payload.
1231
+ */
1232
+ private executionProfileFor(
1233
+ channel: Channel,
1234
+ agentId: string,
1235
+ ): "communicator" | undefined {
1236
+ return channel.mode === "secretary" &&
1237
+ channel.secretaryAgentId === agentId
1238
+ ? "communicator"
1239
+ : undefined;
1240
+ }
1241
+
1189
1242
  /**
1190
1243
  * ADR-076: per-agent exec env = the uai token base + the SELECTED engine
1191
1244
  * account's env. Picks the least-recently-used, non-cooling account for the
@@ -1380,9 +1433,10 @@ export class Orchestrator {
1380
1433
  {
1381
1434
  taskId: channel.taskId,
1382
1435
  agent,
1383
- containerName: channel.containerName,
1384
- systemPreamble: channel.preambles.get(agent.id) ?? "",
1385
- agentEnv: this.accountAgentEnv(
1436
+ containerName: channel.containerName,
1437
+ systemPreamble: channel.preambles.get(agent.id) ?? "",
1438
+ executionProfile: this.executionProfileFor(channel, agent.id),
1439
+ agentEnv: this.accountAgentEnv(
1386
1440
  channel,
1387
1441
  agent,
1388
1442
  agentCliEnv(
@@ -1785,6 +1839,7 @@ export class Orchestrator {
1785
1839
  agent,
1786
1840
  containerName: channel.containerName,
1787
1841
  systemPreamble: channel.preambles.get(agentId) ?? "",
1842
+ executionProfile: this.executionProfileFor(channel, agentId),
1788
1843
  agentEnv: boundAccount
1789
1844
  ? { ...base, ...boundAccount.execEnv }
1790
1845
  : this.accountAgentEnv(channel, agent, base),
@@ -1903,6 +1958,7 @@ export class Orchestrator {
1903
1958
  agent,
1904
1959
  containerName: channel.containerName,
1905
1960
  systemPreamble: channel.preambles.get(agentId) ?? "",
1961
+ executionProfile: this.executionProfileFor(channel, agentId),
1906
1962
  agentEnv: { ...base, ...next.execEnv },
1907
1963
  },
1908
1964
  );
@@ -2096,6 +2152,18 @@ export class Orchestrator {
2096
2152
  }
2097
2153
  }
2098
2154
 
2155
+ /** Exact host-wire role validation; never trims or canonicalises agent ids. */
2156
+ export function hasValidSecretarySelection(
2157
+ spec: Pick<ChannelEnsureInput, "mode" | "secretaryAgentId" | "agents">,
2158
+ ): boolean {
2159
+ if (spec.mode !== "secretary") return true;
2160
+ if (!spec.secretaryAgentId) return false;
2161
+ return (
2162
+ spec.agents.filter((agent) => agent.id === spec.secretaryAgentId).length ===
2163
+ 1
2164
+ );
2165
+ }
2166
+
2099
2167
  // ---------------------------------------------------------------------------
2100
2168
  // `@mention` addressing.
2101
2169
  // ---------------------------------------------------------------------------
@@ -2295,6 +2363,8 @@ export function buildSystemPreamble(
2295
2363
  humans?: ChannelHuman[],
2296
2364
  browserTesting?: boolean,
2297
2365
  sharedFiles?: string,
2366
+ mode?: "open" | "secretary",
2367
+ secretaryAgentId?: string,
2298
2368
  ): string {
2299
2369
  const channelList = roster
2300
2370
  .map((a) =>
@@ -2343,6 +2413,139 @@ export function buildSystemPreamble(
2343
2413
  (p) =>
2344
2414
  `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
2345
2415
  );
2416
+ const isSecretary =
2417
+ mode === "secretary" && secretaryAgentId === agent.id;
2418
+ const transcriptBrief =
2419
+ mode !== "secretary"
2420
+ ? [
2421
+ // Keep open-mode briefing byte-identical to the legacy preamble.
2422
+ "Because your input is only what you're addressed, you may be missing",
2423
+ "context from messages between the human and the other agents. The full",
2424
+ "channel transcript — every message + who wrote it (no tool calls) — is",
2425
+ "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
2426
+ "context (e.g. the human shared a file or instruction with another",
2427
+ "agent); it's appended live, so re-read it for the latest.",
2428
+ ]
2429
+ : isSecretary
2430
+ ? [
2431
+ "Because your input is only what you're addressed, you may be missing",
2432
+ "context from messages elsewhere in the channel. Secretary mode keeps",
2433
+ "two live transcripts: the backstage crew conversation is logged at",
2434
+ "`/workspace/.uai/chat.md`, and the frontstage human conversation is",
2435
+ "logged at `/workspace/.uai/chat-front.md`. Read BOTH whenever you need",
2436
+ "to catch up; each is appended live, so re-read them for the latest.",
2437
+ ]
2438
+ : [
2439
+ "Because your input is only what you're addressed, you may be missing",
2440
+ "context from the crew conversation. The backstage transcript — crew",
2441
+ "messages + who wrote them (no tool calls) — is logged at",
2442
+ "`/workspace/.uai/chat.md`. Read it whenever you need that context;",
2443
+ "it's appended live, so re-read it for the latest.",
2444
+ ];
2445
+ const secretaryRoleBrief = isSecretary
2446
+ ? [
2447
+ "## Secretary role",
2448
+ "",
2449
+ "You are the channel's sole human-facing communicator. Answer the human",
2450
+ "directly when the transcripts and read-only inspection are sufficient.",
2451
+ "When crew work is needed, address each recipient by @id with a concrete",
2452
+ "instruction; Uai delivers that instruction backstage. Synthesize the",
2453
+ "crew's replies for the human instead of forwarding a pile of raw updates.",
2454
+ "",
2455
+ "Your communicator execution profile is enforced by the engine: you can",
2456
+ "read and search the workspace, but you cannot edit files, run shell",
2457
+ "commands, write git state, deploy, or invoke arbitrary extensions. Do not",
2458
+ "claim that you performed a mutation; dispatch it to a crew agent instead.",
2459
+ "",
2460
+ ]
2461
+ : [];
2462
+ const groupMessageBrief = isSecretary
2463
+ ? [
2464
+ "When a human message names one or more crew agents, those names are",
2465
+ "routing hints for you — the crew has NOT been notified yet. Decide what",
2466
+ "work is actually needed, then dispatch a concrete @id instruction to each",
2467
+ "crew member you need. Do not merely say that someone else will answer.",
2468
+ ]
2469
+ : [
2470
+ "When a message already @-mentions several participants at once (the",
2471
+ "human asking the whole group, or a peer addressing multiple agents),",
2472
+ "it's a group broadcast — this is a GROUP CHAT and everyone named has",
2473
+ "ALREADY been notified and will answer for themselves. Just answer for",
2474
+ "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
2475
+ "question to them, or wait on them — no `I'll let @x speak`, `@x your",
2476
+ "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2477
+ "the message only wakes them again and spirals into duplicate replies.",
2478
+ "Say your piece and stop.",
2479
+ ];
2480
+ const handoffBrief = isSecretary
2481
+ ? [
2482
+ "When crew work finishes, synthesize the outcome for the human and make",
2483
+ "the next decision or blocker explicit. Do not abandon an unresolved",
2484
+ "request silently, and do not wake a peer for acknowledgments alone.",
2485
+ ]
2486
+ : [
2487
+ "Hand off when you finish your part of the work. When you've made",
2488
+ "and committed your changes, or completed a review, end your reply by",
2489
+ "@-mentioning the agent who should act next and telling them what you",
2490
+ "did and what you need (e.g. `@codex changes committed on <branch> —",
2491
+ "please review`, or `@claude review done, N issues to fix`). Don't",
2492
+ "abandon unfinished work silently — but once your part is done and no",
2493
+ "peer needs to act, it's fine to stop; only @-mention @you if you need",
2494
+ "their input or are handing back finished work for them to act on. Don't",
2495
+ "prolong an agent-to-agent exchange just to fill silence.",
2496
+ ];
2497
+ const checkInTranscriptBrief = isSecretary
2498
+ ? [
2499
+ "Read both transcript files named above, and speak ONLY if you have",
2500
+ "something substantive to add; otherwise reply with exactly `PASS` — a",
2501
+ "PASS reply is discarded and never shown to anyone, so it is always a",
2502
+ "safe way to decline a turn.",
2503
+ ]
2504
+ : [
2505
+ "Read the transcript, and speak ONLY if you have something substantive to",
2506
+ "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2507
+ "never shown to anyone, so it is always a safe way to decline a turn.",
2508
+ ];
2509
+ const workspaceBrief = isSecretary
2510
+ ? [
2511
+ "## Workspace layout",
2512
+ "",
2513
+ `Your read-only workspace root is \`${workspacePath}\`. It contains one`,
2514
+ "project worktree per repository:",
2515
+ "",
2516
+ ...projectLines,
2517
+ "",
2518
+ `Those worktrees are on \`${taskBranch}\`. Inspect files when that helps`,
2519
+ "you answer or scope a dispatch. Your profile cannot edit, commit, push,",
2520
+ "or open a PR; send that work to a crew agent.",
2521
+ "",
2522
+ "The `.uai/` directory is Uai scaffolding. Read the two transcript files",
2523
+ "there as described above; do not treat the rest as project content.",
2524
+ "",
2525
+ ]
2526
+ : [
2527
+ "## Workspace layout",
2528
+ "",
2529
+ `Your shell starts in \`${workspacePath}\` (the task workspace).`,
2530
+ "That directory is **not** itself a git repo — it holds one git",
2531
+ "worktree per project this task spans. Every project below is on the",
2532
+ "same task branch. To run git commands, **`cd` into one of the",
2533
+ "project directories first**:",
2534
+ "",
2535
+ ...projectLines,
2536
+ "",
2537
+ `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
2538
+ `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
2539
+ "create` (the container has gh authenticated). For multi-project",
2540
+ "tasks, each project's PR is independent — open one per project whose",
2541
+ "worktree you actually changed.",
2542
+ "",
2543
+ "The `.uai/` directory under each task is uai's own scaffolding",
2544
+ "(rendered Dockerfile, compose file, container scripts) — it is NOT",
2545
+ "part of the project. Never review, edit, stage, commit, or flag it;",
2546
+ "treat it as ignored, even though git may show it as untracked.",
2547
+ "",
2548
+ ];
2346
2549
  const comms = [
2347
2550
  "## uai task channel",
2348
2551
  "",
@@ -2378,63 +2581,21 @@ export function buildSystemPreamble(
2378
2581
  "you're waiting on the human), answer briefly and then wait — you don't",
2379
2582
  "need to @-mention anyone (including @you); they can see the channel.",
2380
2583
  "",
2381
- "When a message already @-mentions several participants at once (the",
2382
- "human asking the whole group, or a peer addressing multiple agents),",
2383
- "it's a group broadcast — this is a GROUP CHAT and everyone named has",
2384
- "ALREADY been notified and will answer for themselves. Just answer for",
2385
- "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
2386
- "question to them, or wait on them — no `I'll let @x speak`, `@x your",
2387
- "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2388
- "the message only wakes them again and spirals into duplicate replies.",
2389
- "Say your piece and stop.",
2584
+ ...groupMessageBrief,
2390
2585
  "",
2391
- "Because your input is only what you're addressed, you may be missing",
2392
- "context from messages between the human and the other agents. The full",
2393
- "channel transcript — every message + who wrote it (no tool calls) — is",
2394
- "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
2395
- "context (e.g. the human shared a file or instruction with another",
2396
- "agent); it's appended live, so re-read it for the latest.",
2586
+ ...transcriptBrief,
2397
2587
  "",
2588
+ ...secretaryRoleBrief,
2398
2589
  "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
2399
2590
  "uai hands it back to whoever prompted you — so when you're ANSWERING,",
2400
2591
  "just answer plainly; you don't need to re-mention the asker. Mention",
2401
2592
  "someone only to bring them in or hand work off. (2) You may occasionally",
2402
2593
  "receive a `[channel check-in]` asking you to catch up on the channel.",
2403
- "Read the transcript, and speak ONLY if you have something substantive to",
2404
- "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2405
- "never shown to anyone, so it is always a safe way to decline a turn.",
2406
- "",
2407
- "Hand off when you finish your part of the work. When you've made",
2408
- "and committed your changes, or completed a review, end your reply by",
2409
- "@-mentioning the agent who should act next and telling them what you",
2410
- "did and what you need (e.g. `@codex changes committed on <branch> —",
2411
- "please review`, or `@claude review done, N issues to fix`). Don't",
2412
- "abandon unfinished work silently — but once your part is done and no",
2413
- "peer needs to act, it's fine to stop; only @-mention @you if you need",
2414
- "their input or are handing back finished work for them to act on. Don't",
2415
- "prolong an agent-to-agent exchange just to fill silence.",
2416
- "",
2417
- "## Workspace layout",
2418
- "",
2419
- `Your shell starts in \`${workspacePath}\` (the task workspace).`,
2420
- "That directory is **not** itself a git repo — it holds one git",
2421
- "worktree per project this task spans. Every project below is on the",
2422
- "same task branch. To run git commands, **`cd` into one of the",
2423
- "project directories first**:",
2424
- "",
2425
- ...projectLines,
2594
+ ...checkInTranscriptBrief,
2426
2595
  "",
2427
- `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
2428
- `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
2429
- "create` (the container has gh authenticated). For multi-project",
2430
- "tasks, each project's PR is independent — open one per project whose",
2431
- "worktree you actually changed.",
2432
- "",
2433
- "The `.uai/` directory under each task is uai's own scaffolding",
2434
- "(rendered Dockerfile, compose file, container scripts) — it is NOT",
2435
- "part of the project. Never review, edit, stage, commit, or flag it;",
2436
- "treat it as ignored, even though git may show it as untracked.",
2596
+ ...handoffBrief,
2437
2597
  "",
2598
+ ...workspaceBrief,
2438
2599
  // ADR-062: shared files — only when this container actually carries the
2439
2600
  // mounts (task-up drops a marker; pre-feature containers have none).
2440
2601
  ...(sharedFiles &&
@@ -2443,12 +2604,12 @@ export function buildSystemPreamble(
2443
2604
  ? [
2444
2605
  "## Shared files",
2445
2606
  "",
2446
- `Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
2607
+ `Non-code files (${sharedFiles === "rw" && !isSecretary ? "read-write" : "READ-ONLY"} for this task):`,
2447
2608
  "- `/workspace/files/org` — the org's shared files (logos, specs,",
2448
2609
  " datasets), visible to every task in the org on this host.",
2449
2610
  "- `/workspace/files/me` — the task owner's personal files, shared",
2450
2611
  " across their tasks on this host.",
2451
- ...(sharedFiles === "rw"
2612
+ ...(sharedFiles === "rw" && !isSecretary
2452
2613
  ? [
2453
2614
  "When producing artifacts for humans, write them here (use a",
2454
2615
  "subdirectory named after the task to avoid collisions).",
@@ -2475,7 +2636,8 @@ export function buildSystemPreamble(
2475
2636
  // ADR-047: package skills are native Claude Agent Skills installed into the
2476
2637
  // container's skills dir (Claude-only). List them so the agent knows they're
2477
2638
  // available even if headless auto-discovery is unreliable.
2478
- ...(agent.kind === "claude" &&
2639
+ ...(!isSecretary &&
2640
+ agent.kind === "claude" &&
2479
2641
  (agent.skills ?? []).some((s) => s.type === "package")
2480
2642
  ? [
2481
2643
  "## Installed skills",
@@ -2489,7 +2651,7 @@ export function buildSystemPreamble(
2489
2651
  ]
2490
2652
  : []),
2491
2653
  // ADR-053: the in-container browser, when the project opted in.
2492
- ...(browserTesting
2654
+ ...(browserTesting && !isSecretary
2493
2655
  ? [
2494
2656
  "## Browser",
2495
2657
  "",
@@ -2512,7 +2674,11 @@ export function buildSystemPreamble(
2512
2674
  ]
2513
2675
  : []),
2514
2676
  // ADR-048: tell agents with permissions about their `uai` CLI.
2515
- ...((agent.permissions?.length ?? 0) > 0
2677
+ // The communicator profile deliberately exposes no Bash or arbitrary MCP
2678
+ // tool, so even a persona carrying CLI permissions cannot invoke cli.mjs.
2679
+ // Do not advertise unusable commands; a future typed communicator tool can
2680
+ // surface the safe task/todo subset without widening this boundary.
2681
+ ...((agent.permissions?.length ?? 0) > 0 && !isSecretary
2516
2682
  ? [
2517
2683
  "## The uai CLI",
2518
2684
  "",
@@ -2602,14 +2768,18 @@ export function buildSystemPreamble(
2602
2768
  "",
2603
2769
  ]
2604
2770
  : []),
2605
- "## Commit policy",
2606
- "",
2607
- "Commits are SSH-signed automatically (git is configured for it) — do",
2608
- "not disable or override signing. Do NOT add any `Co-Authored-By:`",
2609
- "trailers to commit messages, and do NOT add 'Generated with …' or any",
2610
- "tool/agent attribution footer to commit messages or PR/issue bodies.",
2611
- "Write commit messages and PR descriptions plainly, as the author, with",
2612
- "no agent attribution.",
2771
+ ...(!isSecretary
2772
+ ? [
2773
+ "## Commit policy",
2774
+ "",
2775
+ "Commits are SSH-signed automatically (git is configured for it) do",
2776
+ "not disable or override signing. Do NOT add any `Co-Authored-By:`",
2777
+ "trailers to commit messages, and do NOT add 'Generated with …' or any",
2778
+ "tool/agent attribution footer to commit messages or PR/issue bodies.",
2779
+ "Write commit messages and PR descriptions plainly, as the author, with",
2780
+ "no agent attribution.",
2781
+ ]
2782
+ : []),
2613
2783
  ].join("\n");
2614
2784
 
2615
2785
  // Persona / mission layers, always-on so they apply to every turn:
package/lib/transcript.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  * isolated conversations — each only hears what it's addressed. So they can opt
4
4
  * into cross-agent awareness, the host maintains a plain-text log of every chat
5
5
  * message at `<workspace>/.uai/chat.md` (mounted at /workspace), which any agent
6
- * can read on demand. Messages only no tool calls / actions.
6
+ * can read on demand. Secretary mode adds a frontstage projection at
7
+ * `chat-front.md`; the cloud decides which projection(s) a row belongs to and
8
+ * sends an explicit target list. Conversational rows (including dispatch
9
+ * instructions) only — no tool-call execution traces.
7
10
  */
8
11
 
9
12
  import { appendFileSync, mkdirSync } from "node:fs";
@@ -11,14 +14,23 @@ import { resolve } from "node:path";
11
14
 
12
15
  import { taskWorkspaceDir } from "./env";
13
16
  import { rewriteAttachmentRefs } from "./orchestrator";
17
+ import type { TranscriptTarget } from "../src/protocol";
14
18
 
15
19
  /** Container path agents are pointed at. */
16
20
  export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.uai/chat.md";
21
+ export const CONTAINER_FRONT_TRANSCRIPT_PATH =
22
+ "/workspace/.uai/chat-front.md";
23
+
24
+ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
25
+ chat: "chat.md",
26
+ "chat-front": "chat-front.md",
27
+ };
17
28
 
18
29
  export function appendTranscript(
19
30
  taskId: string,
20
31
  author: string,
21
32
  text: string,
33
+ targets: TranscriptTarget[],
22
34
  ): void {
23
35
  // Rewrite cloud attachment URLs to the in-container path so an agent reading
24
36
  // the transcript can open referenced files directly.
@@ -26,5 +38,8 @@ export function appendTranscript(
26
38
  if (!body) return;
27
39
  const dir = resolve(taskWorkspaceDir(taskId), ".uai");
28
40
  mkdirSync(dir, { recursive: true });
29
- appendFileSync(resolve(dir, "chat.md"), `## ${author}\n\n${body}\n\n`);
41
+ const entry = `## ${author}\n\n${body}\n\n`;
42
+ for (const target of new Set(targets)) {
43
+ appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
44
+ }
30
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  factoryFor as agentFactoryFor,
68
68
  list as listAgentAdapters,
69
69
  register as registerAgentAdapter,
70
+ supportsExecutionProfile,
70
71
  } from "../lib/agents/registry";
71
72
  export type {
72
73
  AgentKindCapability,
@@ -482,10 +483,10 @@ export const hostCommands: HostCommands = {
482
483
  }
483
484
  },
484
485
 
485
- async appendTranscript(_ctx, taskId, author, text) {
486
+ async appendTranscript(_ctx, taskId, author, text, targets) {
486
487
  // Per-message + high-frequency, so no logCommand (avoid log spam).
487
488
  try {
488
- writeTranscript(taskId, author, text);
489
+ writeTranscript(taskId, author, text, targets);
489
490
  return ok(undefined);
490
491
  } catch (err) {
491
492
  return failFromUnknown(err);
package/src/main.ts CHANGED
@@ -72,6 +72,7 @@ import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
72
72
  import { hostCommands, hostEvents } from "./index";
73
73
  import {
74
74
  HostErrorCode,
75
+ TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
75
76
  type CloudToHost,
76
77
  type McpOp,
77
78
  type CommandContext,
@@ -81,6 +82,8 @@ import {
81
82
  type HostCommands,
82
83
  type HostToCloud,
83
84
  type PermissionDecision,
85
+ parseChannelMode,
86
+ parseTranscriptTargets,
84
87
  type TaskAgent,
85
88
  type TaskCommandProject,
86
89
  type TaskCommandTask,
@@ -212,6 +215,7 @@ async function startLocalUi(): Promise<void> {
212
215
  function buildCapabilities(): HostCapabilities {
213
216
  return {
214
217
  version: packageVersion(),
218
+ protocolFeatures: [TRANSCRIPT_TARGETS_PROTOCOL_FEATURE],
215
219
  agentKinds: agentKindCapabilities(),
216
220
  runtimes: standardRuntimes(),
217
221
  githubUsers: connectedUserIds(),
@@ -956,6 +960,7 @@ function dispatchCommand(
956
960
  expectString(args, 0),
957
961
  expectString(args, 1),
958
962
  expectString(args, 2),
963
+ parseTranscriptTargets(args[3]),
959
964
  );
960
965
  case "previewEnsure":
961
966
  return hostCommands.previewEnsure(
@@ -1402,6 +1407,13 @@ function expectChannelEnsureInput(
1402
1407
  if (typeof input.globalContext === "string") {
1403
1408
  out.globalContext = input.globalContext;
1404
1409
  }
1410
+ // ADR-083: secretary identity drives the role-specific transcript preamble.
1411
+ // Both are optional for wire compatibility with older clouds.
1412
+ const mode = parseChannelMode(input.mode);
1413
+ if (mode !== undefined) out.mode = mode;
1414
+ if (typeof input.secretaryAgentId === "string") {
1415
+ out.secretaryAgentId = input.secretaryAgentId;
1416
+ }
1405
1417
  // ADR-053: browser testing flag (optional; tolerant of absence).
1406
1418
  if (input.browserTesting === true) out.browserTesting = true;
1407
1419
  // ADR-049: humans in the chat (optional; tolerant of absence for older
package/src/protocol.ts CHANGED
@@ -27,6 +27,10 @@ export type HostCommandResult<T> =
27
27
  retryable?: boolean;
28
28
  };
29
29
 
30
+ /** Capability ids shared by host advertisement and fail-closed cloud gates. */
31
+ export const TRANSCRIPT_TARGETS_PROTOCOL_FEATURE = "transcript-targets-v1";
32
+ export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
33
+
30
34
  export interface CommandContext {
31
35
  commandId: string;
32
36
  }
@@ -70,6 +74,9 @@ export interface HostCapabilities {
70
74
  /** The host-agent package version — the cloud UI shows it on the host page
71
75
  * and flags when npm has a newer release. Optional (older hosts omit it). */
72
76
  version?: string;
77
+ /** Optional protocol features. Absence means an older host and fails closed
78
+ * for features whose fallback would weaken isolation (ADR-083). */
79
+ protocolFeatures?: string[];
73
80
  agentKinds: Array<{
74
81
  kind: string;
75
82
  label: string;
@@ -77,6 +84,13 @@ export interface HostCapabilities {
77
84
  defaultModel?: string;
78
85
  supportedEfforts: string[];
79
86
  defaultEffort?: string;
87
+ /** Execution profiles this adapter enforces, not prompt-only claims. */
88
+ executionProfiles?: Array<{
89
+ id: string;
90
+ mechanism: string;
91
+ defaultModel?: string;
92
+ defaultEffort?: string;
93
+ }>;
80
94
  }>;
81
95
  runtimes: Array<{
82
96
  kind: string;
@@ -88,6 +102,34 @@ export interface HostCapabilities {
88
102
  githubUsers?: string[];
89
103
  }
90
104
 
105
+ /** One transcript projection the cloud asks the host to append to (ADR-083). */
106
+ export type TranscriptTarget = "chat" | "chat-front";
107
+
108
+ /** Parse the optional channel mode without turning malformed-new-cloud input
109
+ * into legacy Open mode. Absence is the only backwards-compatible fallback. */
110
+ export function parseChannelMode(
111
+ value: unknown,
112
+ ): "open" | "secretary" | undefined {
113
+ if (value === undefined) return undefined;
114
+ if (value === "open" || value === "secretary") return value;
115
+ throw new Error("invalid command args: expected channel mode");
116
+ }
117
+
118
+ /** Parse the appendTranscript wire argument. `undefined` alone is the legacy
119
+ * three-argument command and maps to chat.md during a rolling deploy. */
120
+ export function parseTranscriptTargets(value: unknown): TranscriptTarget[] {
121
+ if (value === undefined) return ["chat"];
122
+ if (
123
+ !Array.isArray(value) ||
124
+ value.length === 0 ||
125
+ value.length > 2 ||
126
+ value.some((target) => target !== "chat" && target !== "chat-front")
127
+ ) {
128
+ throw new Error("invalid command args: expected transcript targets");
129
+ }
130
+ return [...new Set(value)];
131
+ }
132
+
91
133
  export interface TaskUpResult {
92
134
  composeProject: string;
93
135
  worktreePath: string;
@@ -200,6 +242,10 @@ export interface ChannelHuman {
200
242
  export interface ChannelEnsureInput {
201
243
  taskId: string;
202
244
  agents: TaskAgent[];
245
+ /** ADR-083: channel projection/routing mode. Optional for older clouds. */
246
+ mode?: "open" | "secretary";
247
+ /** The one human-facing communicator in secretary mode. */
248
+ secretaryAgentId?: string;
203
249
  /** ADR-049: the humans in the chat. Optional for wire back-compat; absent
204
250
  * or single-entry behaves exactly like the pre-ADR-049 single-human task. */
205
251
  humans?: ChannelHuman[];
@@ -382,6 +428,7 @@ export interface HostCommands {
382
428
  taskId: string,
383
429
  author: string,
384
430
  text: string,
431
+ targets: TranscriptTarget[],
385
432
  ): Promise<HostCommandResult<void>>;
386
433
  }
387
434